From 16e8b1e9a9b2a444785a0228513c991ec8ff9d1d Mon Sep 17 00:00:00 2001 From: dev-banane Date: Wed, 3 Jun 2026 23:06:48 +0200 Subject: [PATCH 1/6] chore(config): switch to single quotes --- .editorconfig | 4 ++-- .prettierignore | 5 +++++ .prettierrc | 5 +++-- .vscode/extensions.json | 6 ++++++ 4 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 .prettierignore create mode 100644 .vscode/extensions.json diff --git a/.editorconfig b/.editorconfig index ebe51d3b..64d3d6a0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,5 +8,5 @@ indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 -trim_trailing_whitespace = false -insert_final_newline = false \ No newline at end of file +trim_trailing_whitespace = true +insert_final_newline = true \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..59f256e8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist +server/dist +node_modules +package-lock.json +*.min.js diff --git a/.prettierrc b/.prettierrc index 94b0c02b..e492e143 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,6 @@ { - "singleQuote": false, + "singleQuote": true, "tabWidth": 2, - "trailingComma": "es5" + "trailingComma": "es5", + "printWidth": 100 } diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..5bc0e652 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint" + ] +} From be697dedd0226f5a22f8cf9eed472678366eb605 Mon Sep 17 00:00:00 2001 From: dev-banane Date: Wed, 3 Jun 2026 23:21:52 +0200 Subject: [PATCH 2/6] chore: add prettier to CI --- .github/workflows/CI.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f2f410d7..2676a7d4 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -33,6 +33,9 @@ jobs: - name: Run npm audit run: npm audit --audit-level=high + - name: Check formatting + run: npx prettier --check "server/**/*.ts" "src/**/*.{ts,tsx}" + - name: Run lint run: npm run lint From 0e6379be383e36367fcfc8b3dc6c38ddc3796752 Mon Sep 17 00:00:00 2001 From: dev-banane Date: Wed, 3 Jun 2026 23:25:55 +0200 Subject: [PATCH 3/6] fix(prettier): LF line endings --- .prettierrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.prettierrc b/.prettierrc index e492e143..28b39ecc 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,5 +2,6 @@ "singleQuote": true, "tabWidth": 2, "trailingComma": "es5", - "printWidth": 100 + "printWidth": 100, + "endOfLine": "lf" } From d6fd8609fa80108fbfae75a3f2071c0949903a55 Mon Sep 17 00:00:00 2001 From: dev-banane Date: Wed, 3 Jun 2026 23:44:45 +0200 Subject: [PATCH 4/6] fix: switch formatter to vp/oxfmt with single qoutes --- .github/workflows/CI.yml | 2 +- .oxlintrc.json | 13 +++---------- .prettierignore | 5 ----- .prettierrc | 7 ------- .vscode/extensions.json | 5 +---- tsconfig.server.json | 10 +++------- vite.config.js | 20 ++++++++++---------- 7 files changed, 18 insertions(+), 44 deletions(-) delete mode 100644 .prettierignore delete mode 100644 .prettierrc diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 2676a7d4..db9f7e66 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -34,7 +34,7 @@ jobs: run: npm audit --audit-level=high - name: Check formatting - run: npx prettier --check "server/**/*.ts" "src/**/*.{ts,tsx}" + run: npx vp fmt --check - name: Run lint run: npm run lint diff --git a/.oxlintrc.json b/.oxlintrc.json index 87f9dcfb..b536bd6a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,11 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": [ - "oxc", - "typescript", - "unicorn", - "react" - ], + "plugins": ["oxc", "typescript", "unicorn", "react"], "categories": { "correctness": "warn" }, @@ -19,9 +14,7 @@ ], "overrides": [ { - "files": [ - "**/*.{ts,tsx}" - ], + "files": ["**/*.{ts,tsx}"], "rules": { "constructor-super": "error", "for-direction": "error", @@ -132,4 +125,4 @@ } } ] -} \ No newline at end of file +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 59f256e8..00000000 --- a/.prettierignore +++ /dev/null @@ -1,5 +0,0 @@ -dist -server/dist -node_modules -package-lock.json -*.min.js diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 28b39ecc..00000000 --- a/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "singleQuote": true, - "tabWidth": 2, - "trailingComma": "es5", - "printWidth": 100, - "endOfLine": "lf" -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 5bc0e652..99e2f7dd 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,3 @@ { - "recommendations": [ - "esbenp.prettier-vscode", - "dbaeumer.vscode-eslint" - ] + "recommendations": ["oxc.oxc-vscode"] } diff --git a/tsconfig.server.json b/tsconfig.server.json index 1c8666f2..38dc92bb 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -2,9 +2,7 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo", "target": "ES2022", - "lib": [ - "ES2022" - ], + "lib": ["ES2022"], "module": "ESNext", "moduleResolution": "bundler", "outDir": "dist/server", @@ -16,7 +14,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": [ - "server" - ] -} \ No newline at end of file + "include": ["server"] +} diff --git a/vite.config.js b/vite.config.js index 591b4482..9fc0ccf3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,29 +1,29 @@ -import { defineConfig } from "vite-plus"; -import react from "@vitejs/plugin-react"; -import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from 'vite-plus'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [react(), tailwindcss()], fmt: { printWidth: 80, - singleQuote: false, - trailingComma: "es5", + singleQuote: true, + trailingComma: 'es5', tabWidth: 2, }, build: { - sourcemap: "hidden", + sourcemap: 'hidden', }, staged: { - "*.{js,jsx,ts,tsx}": "vp check --fix", + '*.{js,jsx,ts,tsx}': 'vp check --fix', }, server: { - host: "0.0.0.0", + host: '0.0.0.0', port: 5173, strictPort: true, }, preview: { - host: "0.0.0.0", + host: '0.0.0.0', port: 5173, strictPort: true, }, -}); \ No newline at end of file +}); From 930c9faf0954d16d1d83165f1ae6f398b4f0002f Mon Sep 17 00:00:00 2001 From: dev-banane Date: Wed, 3 Jun 2026 23:57:11 +0200 Subject: [PATCH 5/6] build: add node env for tests/scripts in oxlint, drop prettier dep --- .oxlintrc.json | 7 +++++ .prettierignore | 7 +++++ package-lock.json | 4 +-- package.json | 67 +++++++++++++++++++++++------------------------ 4 files changed, 49 insertions(+), 36 deletions(-) create mode 100644 .prettierignore diff --git a/.oxlintrc.json b/.oxlintrc.json index b536bd6a..aebad874 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -123,6 +123,13 @@ "node": true, "es2020": true } + }, + { + "files": ["tests/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx}"], + "env": { + "node": true, + "es2020": true + } } ] } diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..feb36471 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +dist +server/dist +node_modules +package-lock.json +server/data +*.md +docker-compose*.yml diff --git a/package-lock.json b/package-lock.json index dbd92f47..a440514e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,7 +89,6 @@ "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.4.0", "nodemon": "^3.1.10", - "prettier": "^3.6.2", "ts-node": "^10.9.2", "tsx": "^4.20.6", "typescript": "~5.8.3", @@ -12275,8 +12274,9 @@ "version": "3.8.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", - "devOptional": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, diff --git a/package.json b/package.json index e913f9e2..494ec382 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pfcontrol-2", - "private": true, "version": "0.0.0", + "private": true, "type": "module", "scripts": { "dev": "set NODE_ENV=development && npm run generate:developer-docs && concurrently \"vp dev\" \"npx nodemon\"", @@ -18,8 +18,8 @@ "test": "vitest run", "test:watch": "vitest", "lint": "eslint .", - "format:check": "prettier --check .", - "format": "prettier --write .", + "format:check": "vp fmt --check", + "format": "vp fmt", "type-check": "tsc --noEmit", "postinstall": "node scripts/verify-node-modules.mjs" }, @@ -82,36 +82,6 @@ "socket.io-client": "^4.8.1", "tailwindcss": "^4.1.13" }, - "overrides": { - "react-floater": { - "react": "$react", - "react-dom": "$react-dom" - }, - "@jridgewell/sourcemap-codec": "1.5.5" - }, - "optionalDependencies": { - "@esbuild/linux-x64": "0.27.7", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@oxfmt/binding-linux-x64-gnu": "0.46.0", - "@oxfmt/binding-linux-x64-musl": "0.46.0", - "@oxlint/binding-linux-x64-gnu": "1.61.0", - "@oxlint/binding-linux-x64-musl": "1.61.0", - "@resvg/resvg-js-linux-x64-gnu": "2.6.2", - "@resvg/resvg-js-linux-x64-musl": "2.6.2", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", - "@rollup/rollup-linux-x64-gnu": "4.60.3", - "@rollup/rollup-linux-x64-musl": "4.60.3", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@voidzero-dev/vite-plus-linux-x64-gnu": "0.1.20", - "@voidzero-dev/vite-plus-linux-x64-musl": "0.1.20", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0" - }, "devDependencies": { "@eslint/js": "^9.36.0", "@types/cookie-parser": "^1.4.9", @@ -134,7 +104,6 @@ "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.4.0", "nodemon": "^3.1.10", - "prettier": "^3.6.2", "ts-node": "^10.9.2", "tsx": "^4.20.6", "typescript": "~5.8.3", @@ -142,5 +111,35 @@ "vite": "^8.0.8", "vite-plus": "^0.1.16", "vitest": "^4.1.8" + }, + "optionalDependencies": { + "@esbuild/linux-x64": "0.27.7", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@oxfmt/binding-linux-x64-gnu": "0.46.0", + "@oxfmt/binding-linux-x64-musl": "0.46.0", + "@oxlint/binding-linux-x64-gnu": "1.61.0", + "@oxlint/binding-linux-x64-musl": "1.61.0", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@voidzero-dev/vite-plus-linux-x64-gnu": "0.1.20", + "@voidzero-dev/vite-plus-linux-x64-musl": "0.1.20", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0" + }, + "overrides": { + "@jridgewell/sourcemap-codec": "1.5.5", + "react-floater": { + "react": "$react", + "react-dom": "$react-dom" + } } } From 119caf53a941391da2e559443b9159eef7e72325 Mon Sep 17 00:00:00 2001 From: dev-banane Date: Wed, 3 Jun 2026 23:57:11 +0200 Subject: [PATCH 6/6] style: reformat codebase to single quotes via oxfmt --- astro/package.json | 2 +- astro/src/env.d.ts | 2 +- astro/src/lib/api.ts | 2 +- astro/src/lib/siteOrigin.ts | 2 +- astro/src/lib/spaSeo.ts | 2 +- astro/src/lib/submitSeo.ts | 2 +- astro/src/middleware.ts | 2 +- astro/src/styles/global.css | 2 +- eslint.config.js | 39 +- scripts/generate-developer-api-docs.ts | 130 +- scripts/install-hooks.mjs | 2 +- server/db/admin.ts | 435 ++++--- server/db/audit.ts | 126 +- server/db/ban.ts | 4 +- server/db/chats.ts | 164 +-- server/db/connection.ts | 46 +- server/db/databaseMetrics.ts | 126 +- server/db/databaseProjection.ts | 48 +- server/db/databaseRetention.ts | 18 +- server/db/developer.ts | 242 ++-- server/db/developerDashboard.ts | 40 +- server/db/flightLogs.ts | 144 +- server/db/flights.ts | 492 +++---- server/db/ratings.ts | 8 +- server/db/schemas.ts | 896 ++++++------- server/db/sessions.ts | 218 ++-- server/db/sitemapProfiles.ts | 2 +- server/db/statistics.ts | 40 +- server/db/types/connection/MainDatabase.ts | 60 +- .../types/connection/main/AppSettingsTable.ts | 4 +- .../connection/main/ControllerRatingsTable.ts | 2 +- .../main/DailyTableActivityTable.ts | 2 +- .../connection/main/DeveloperApiKeysTable.ts | 4 +- .../connection/main/DeveloperApiUsageTable.ts | 4 +- .../main/DeveloperApplicationsTable.ts | 2 +- .../connection/main/DeveloperProfilesTable.ts | 2 +- .../types/connection/main/GlobalChatTable.ts | 2 +- .../db/types/connection/main/SessionsTable.ts | 2 +- .../main/WebsocketSnapshotsTable.ts | 2 +- server/db/users.ts | 21 +- server/db/version.ts | 44 +- server/db/vpnExceptions.ts | 12 +- server/db/websocketSnapshots.ts | 22 +- server/developer/apiDocumentation.ts | 94 +- server/developer/apiKeySecret.ts | 18 +- .../developerNotificationUnsubscribeToken.ts | 36 +- server/developer/extRoutes.ts | 425 +++--- server/developer/scopeRegistry.ts | 157 ++- .../sendDeveloperAdminNoticeEmail.ts | 39 +- server/main.ts | 258 ++-- server/middleware/apiLogger.ts | 100 +- server/middleware/auth.ts | 4 +- server/middleware/developerExtApi.ts | 90 +- server/middleware/flightAccess.ts | 94 +- server/middleware/httpErrorHandler.ts | 34 +- server/og/SubmitOgCard.tsx | 2 +- server/og/loadInterFonts.ts | 2 +- server/og/ogLinkIcons.ts | 3 +- server/og/profileBackground.ts | 10 +- server/og/profileOgCache.ts | 2 +- server/og/renderProfileOgPng.ts | 2 +- server/og/renderSubmitOgPng.ts | 2 +- server/og/resolvedBackgroundToDataUrl.ts | 2 +- server/og/submitBackground.ts | 2 +- server/og/submitOgCache.ts | 2 +- server/og/toSatoriSafeDataUrl.ts | 2 +- server/realtime/activeSessions.ts | 28 +- server/realtime/arrivals.ts | 24 +- server/realtime/chatCache.ts | 16 +- server/realtime/flightsRead.ts | 42 +- server/realtime/invalidate.ts | 22 +- server/realtime/keys.ts | 18 +- server/realtime/overview.ts | 42 +- server/realtime/perf.ts | 10 +- server/realtime/socketRegistry.ts | 8 +- server/realtime/userCache.ts | 28 +- server/routes/admin/alts.ts | 72 +- server/routes/admin/ban.ts | 54 +- server/routes/admin/database.ts | 20 +- server/routes/admin/developers.ts | 512 ++++---- server/routes/admin/index.ts | 96 +- server/routes/admin/ratings.ts | 2 +- server/routes/admin/roles.ts | 14 +- server/routes/admin/sessions.ts | 6 +- server/routes/admin/testers.ts | 23 +- server/routes/admin/users.ts | 10 +- server/routes/admin/websockets.ts | 14 +- server/routes/atis.ts | 7 +- server/routes/auth.ts | 80 +- server/routes/chats.ts | 229 ++-- server/routes/data.ts | 562 +++++--- server/routes/developer.ts | 365 +++--- server/routes/ext/developerExtras.ts | 82 +- server/routes/ext/sessionsFlights.ts | 532 ++++---- server/routes/ext/v1.ts | 16 +- server/routes/feedback.ts | 6 +- server/routes/flights.ts | 260 ++-- server/routes/index.ts | 2 +- server/routes/metar.ts | 14 +- server/routes/ogImages.ts | 2 +- server/routes/pilot.ts | 2 +- server/routes/ratings.ts | 12 +- server/routes/seo.ts | 2 +- server/routes/sessions.ts | 38 +- server/routes/uploads.ts | 31 +- server/routes/version.ts | 37 +- server/services/publicPilotProfile.ts | 2 +- server/services/publicSubmitSession.ts | 2 +- server/types/express.d.ts | 4 +- server/utils/advancedNetworkSession.ts | 13 +- server/utils/cacheTtl.ts | 4 +- server/utils/detectVPN.ts | 15 +- server/utils/encryption.ts | 5 +- server/utils/findRoute.ts | 38 +- server/utils/getData.ts | 2 +- server/utils/getIpAddress.ts | 4 +- server/utils/metarAviationWeather.ts | 90 +- server/utils/posthog.ts | 64 +- server/utils/publicSessionAtis.ts | 2 +- server/utils/sessionNetworkFlags.ts | 15 +- server/utils/statisticsCache.ts | 2 +- server/websockets/arrivalsWebsocket.ts | 124 +- server/websockets/chatWebsocket.ts | 90 +- server/websockets/flightsWebsocket.ts | 298 ++--- server/websockets/globalChatWebsocket.ts | 282 ++-- server/websockets/overviewWebsocket.ts | 218 ++-- .../websockets/sectorControllerWebsocket.ts | 98 +- server/websockets/sessionUsersWebsocket.ts | 150 +-- server/websockets/voiceChatWebsocket.ts | 65 +- src/App.tsx | 92 +- src/components/AccessDenied.tsx | 2 +- src/components/Footer.tsx | 2 +- src/components/Navbar.tsx | 2 +- src/components/PostHogErrorFallback.tsx | 19 +- .../Settings/BackgroundImageSettings.tsx | 75 +- src/components/admin/AdminChart.tsx | 76 +- .../AdminDeveloperApplicationReviewModal.tsx | 44 +- .../admin/AdminDeveloperEditModal.tsx | 112 +- .../admin/AdminDeveloperManagePanel.tsx | 96 +- src/components/admin/AdminDurationPresets.tsx | 18 +- src/components/admin/AdminIconInput.tsx | 22 +- src/components/admin/AdminLayout.tsx | 14 +- src/components/admin/AdminModal.tsx | 30 +- src/components/admin/AdminPageHeader.tsx | 18 +- src/components/admin/AdminRefreshButton.tsx | 14 +- src/components/admin/AdminSearchInput.tsx | 14 +- src/components/admin/AdminSectionTitle.tsx | 6 +- src/components/admin/AdminSidebar.tsx | 198 +-- src/components/admin/AdminStatCards.tsx | 20 +- src/components/admin/AdminStatStrip.tsx | 16 +- src/components/admin/AdminTable.tsx | 10 +- src/components/admin/AdminTextInput.tsx | 14 +- src/components/admin/AdminToggleSwitch.tsx | 10 +- src/components/admin/AdminToolbar.tsx | 6 +- .../admin/DeveloperDiscordAvatar.tsx | 6 +- src/components/admin/UpdateModalsSection.tsx | 136 +- src/components/admin/adminConstants.ts | 180 +-- .../admin/adminDurationPresetConfig.ts | 10 +- src/components/chat/ChatMessageRow.tsx | 14 +- src/components/chat/ChatSidebar.tsx | 636 ++++++--- src/components/chat/ChatTextComposer.tsx | 10 +- src/components/chat/VoiceChat.tsx | 21 +- src/components/chat/index.ts | 5 +- src/components/common/Checkbox.tsx | 21 +- src/components/common/Dropdown.tsx | 124 +- .../developers/DeveloperAccessRequestForm.tsx | 89 +- .../developers/DeveloperUsageCharts.tsx | 107 +- .../developers/ScopeTagSelector.tsx | 100 +- src/components/dropdowns/RunwayDropdown.tsx | 4 +- src/components/dropdowns/SidDropdown.tsx | 4 +- src/components/home/ProductShowcase.tsx | 949 ++++++++++---- src/components/map/RouteMap.tsx | 367 +++--- .../modals/AddCustomFlightModal.tsx | 31 +- src/components/modals/AtisReminderModal.tsx | 53 +- src/components/tables/ArrivalsTable.tsx | 2 +- .../tables/CombinedFlightsTable.tsx | 5 +- src/components/tables/DepartureTable.tsx | 2 +- .../tables/mobile/ArrivalsTableMobile.tsx | 17 +- .../tables/mobile/DepartureTableMobile.tsx | 118 +- src/components/tools/ATIS.tsx | 230 ++-- .../tools/ControllerRatingPopup.tsx | 8 +- src/components/tools/FlightDetailModal.tsx | 5 +- src/components/tools/RouteModal.tsx | 64 +- src/components/tools/Toolbar.tsx | 2 +- src/components/tools/UserButton.tsx | 2 +- src/components/tools/WindDisplay.tsx | 203 +-- .../tutorial/TutorialStepsCreate.ts | 49 +- src/hooks/auth/AuthProvider.tsx | 28 +- src/hooks/auth/useFingerprint.ts | 2 +- src/hooks/useActiveUpdateModal.ts | 4 +- src/islands/FlightContent.tsx | 2 +- src/islands/HomeContent.tsx | 2 +- src/islands/PostHogProviderWrapper.tsx | 2 +- src/islands/ProfileContent.tsx | 2 +- src/islands/PublicChrome.tsx | 2 +- src/islands/SubmitSessionContent.tsx | 13 +- src/islands/loadIslandStyles.ts | 2 +- src/main.tsx | 35 +- src/pages/ACARS.tsx | 5 +- src/pages/Admin.tsx | 134 +- src/pages/Create.tsx | 2 +- src/pages/Flights.tsx | 505 ++++--- src/pages/Home.tsx | 130 +- src/pages/Login.tsx | 3 +- src/pages/MyFlightDetail.tsx | 220 +++- src/pages/MyFlights.tsx | 126 +- src/pages/PFATCFlights.tsx | 1158 ++++++++++------- src/pages/PilotProfile.tsx | 2 +- src/pages/PublicFlightView.tsx | 7 +- src/pages/Sessions.tsx | 185 ++- src/pages/Settings.tsx | 7 +- src/pages/Submit.tsx | 66 +- src/pages/admin/AdminAltDetection.tsx | 148 +-- src/pages/admin/AdminApiLogs.tsx | 176 +-- src/pages/admin/AdminAudit.tsx | 502 +++---- src/pages/admin/AdminBan.tsx | 186 +-- src/pages/admin/AdminChatReports.tsx | 150 +-- src/pages/admin/AdminDatabase.tsx | 58 +- src/pages/admin/AdminDevelopers.tsx | 150 +-- src/pages/admin/AdminFeedback.tsx | 88 +- src/pages/admin/AdminFlightLogs.tsx | 178 +-- src/pages/admin/AdminNotifications.tsx | 116 +- src/pages/admin/AdminRatings.tsx | 44 +- src/pages/admin/AdminRoles.tsx | 170 +-- src/pages/admin/AdminSessions.tsx | 228 ++-- src/pages/admin/AdminTesters.tsx | 110 +- src/pages/admin/AdminUsers.tsx | 208 +-- src/pages/admin/AdminWebsockets.tsx | 54 +- src/pages/developers/Console.tsx | 140 +- src/pages/developers/DeveloperLayout.tsx | 73 +- .../DeveloperPillSegmentedControl.tsx | 19 +- src/pages/developers/DeveloperSubnav.tsx | 37 +- src/pages/developers/Docs.tsx | 158 ++- src/pages/developers/Keys.tsx | 120 +- src/pages/developers/Overview.tsx | 145 ++- src/pages/developers/constants.ts | 20 +- .../developers/developerPortalContext.tsx | 154 ++- src/sockets/globalChatSocket.ts | 36 +- src/sockets/notificationsSocket.ts | 2 +- src/sockets/overviewSocket.ts | 66 +- src/sockets/voiceChatSocket.ts | 500 +++++-- src/types/developerApiSpec.ts | 9 +- src/types/overview.ts | 9 +- src/types/session.ts | 4 +- src/utils/apiFetch.ts | 39 +- src/utils/chats.ts | 34 +- src/utils/developerSampleCurl.ts | 150 ++- src/utils/fetch/admin.ts | 178 +-- src/utils/fetch/adminDevelopers.ts | 191 +-- src/utils/fetch/chats.ts | 99 +- src/utils/fetch/data.ts | 42 +- src/utils/fetch/developer.ts | 170 ++- src/utils/fetch/flights.ts | 48 +- src/utils/fetch/ratings.ts | 25 +- src/utils/fetch/sessions.ts | 84 +- src/utils/fetch/testers.ts | 19 +- src/utils/playSound.ts | 6 +- src/utils/roles.ts | 6 +- src/utils/sessionKind.ts | 2 +- src/utils/tutorialTracking.ts | 29 +- tests/astro/submitSeo.test.ts | 2 +- tests/server/db/audit.test.ts | 5 +- tests/server/db/ban.test.ts | 39 +- tests/server/db/feedback.test.ts | 6 +- tests/server/db/leaderboard.test.ts | 5 +- tests/server/db/ratings.test.ts | 5 +- tests/server/db/roles.test.ts | 14 +- tests/server/db/sessions.test.ts | 5 +- tests/server/db/statistics.test.ts | 28 +- tests/server/db/testers.test.ts | 4 +- tests/server/og/renderSubmitOgPng.test.ts | 2 +- tests/server/routes/metar.route.test.ts | 40 +- tests/server/routes/sessions.route.test.ts | 36 +- .../server/utils/metarAviationWeather.test.ts | 34 +- tests/server/utils/publicSessionAtis.test.ts | 2 +- .../server/utils/sessionNetworkFlags.test.ts | 32 +- tests/server/utils/validation.test.ts | 24 +- tests/setup/vitestSetup.ts | 3 +- 278 files changed, 12901 insertions(+), 9441 deletions(-) diff --git a/astro/package.json b/astro/package.json index e1411a15..e4da4558 100644 --- a/astro/package.json +++ b/astro/package.json @@ -1,7 +1,7 @@ { "name": "pfcontrol-astro", - "private": true, "version": "0.0.0", + "private": true, "type": "module", "scripts": { "dev": "astro dev --port 4321", diff --git a/astro/src/env.d.ts b/astro/src/env.d.ts index f1d44470..70d74c80 100644 --- a/astro/src/env.d.ts +++ b/astro/src/env.d.ts @@ -32,4 +32,4 @@ declare module '@app/islands/FlightContent' { props: FlightContentProps ) => import('react').JSX.Element; export default FlightContent; -} \ No newline at end of file +} diff --git a/astro/src/lib/api.ts b/astro/src/lib/api.ts index 5ef808db..0c4b0ab6 100644 --- a/astro/src/lib/api.ts +++ b/astro/src/lib/api.ts @@ -13,4 +13,4 @@ export async function fetchApi(path: string): Promise { } catch { return null; } -} \ No newline at end of file +} diff --git a/astro/src/lib/siteOrigin.ts b/astro/src/lib/siteOrigin.ts index b0430d96..9ef1ed8e 100644 --- a/astro/src/lib/siteOrigin.ts +++ b/astro/src/lib/siteOrigin.ts @@ -61,4 +61,4 @@ export function getSiteOrigin(request: Request): string { } return url.origin; -} \ No newline at end of file +} diff --git a/astro/src/lib/spaSeo.ts b/astro/src/lib/spaSeo.ts index bf015ca9..a406bc15 100644 --- a/astro/src/lib/spaSeo.ts +++ b/astro/src/lib/spaSeo.ts @@ -56,4 +56,4 @@ export const SPA_WEB_APPLICATION_LD = { name: 'Cephie Studios', url: SPA_SITE_URL, }, -}; \ No newline at end of file +}; diff --git a/astro/src/lib/submitSeo.ts b/astro/src/lib/submitSeo.ts index 23aad24e..ab6996f3 100644 --- a/astro/src/lib/submitSeo.ts +++ b/astro/src/lib/submitSeo.ts @@ -206,4 +206,4 @@ export function buildSubmitSessionSeo( '@graph': graph, }, }; -} \ No newline at end of file +} diff --git a/astro/src/middleware.ts b/astro/src/middleware.ts index 125c43d4..9e3fc5b6 100644 --- a/astro/src/middleware.ts +++ b/astro/src/middleware.ts @@ -20,4 +20,4 @@ export const onRequest = defineMiddleware(async (_context, next) => { }); } return response; -}); \ No newline at end of file +}); diff --git a/astro/src/styles/global.css b/astro/src/styles/global.css index 5d33e71b..9ecac883 100644 --- a/astro/src/styles/global.css +++ b/astro/src/styles/global.css @@ -1,4 +1,4 @@ -@import "tailwindcss"; +@import 'tailwindcss'; @source "../../../src/**/*.{ts,tsx}"; @source "../../src/**/*.astro"; diff --git a/eslint.config.js b/eslint.config.js index 2dafcde8..194bb67c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,18 +1,23 @@ -import js from "@eslint/js"; -import globals from "globals"; -import reactHooks from "eslint-plugin-react-hooks"; -import reactRefresh from "eslint-plugin-react-refresh"; -import tseslint from "typescript-eslint"; -import { defineConfig, globalIgnores } from "eslint/config"; +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import { defineConfig, globalIgnores } from 'eslint/config'; export default defineConfig([ - globalIgnores(["dist/**", "server/dist/**", "src/utils/hateSpeechFilter.ts", "astro/.astro/**"]), + globalIgnores([ + 'dist/**', + 'server/dist/**', + 'src/utils/hateSpeechFilter.ts', + 'astro/.astro/**', + ]), { - files: ["**/*.{ts,tsx}"], + files: ['**/*.{ts,tsx}'], extends: [ js.configs.recommended, tseslint.configs.recommended, - reactHooks.configs["recommended-latest"], + reactHooks.configs['recommended-latest'], reactRefresh.configs.vite, ], languageOptions: { @@ -20,23 +25,23 @@ export default defineConfig([ globals: globals.browser, }, rules: { - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": [ - "warn", + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', }, ], }, }, { - files: ["server/**/*.ts"], + files: ['server/**/*.ts'], languageOptions: { globals: { ...globals.node, }, }, }, -]); \ No newline at end of file +]); diff --git a/scripts/generate-developer-api-docs.ts b/scripts/generate-developer-api-docs.ts index 6dd662de..5d8403a1 100644 --- a/scripts/generate-developer-api-docs.ts +++ b/scripts/generate-developer-api-docs.ts @@ -1,98 +1,120 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import process from "node:process"; -import { fileURLToPath } from "node:url"; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; -import { buildDeveloperApiPublicSpec } from "../server/developer/apiDocumentation.js"; +import { buildDeveloperApiPublicSpec } from '../server/developer/apiDocumentation.js'; import type { DeveloperApiDocEndpoint, DeveloperApiPublicSpec, -} from "../server/developer/apiDocumentation.js"; +} from '../server/developer/apiDocumentation.js'; const __filename = fileURLToPath(import.meta.url); -const repoRoot = path.resolve(path.dirname(__filename), ".."); +const repoRoot = path.resolve(path.dirname(__filename), '..'); -const publicOutputPath = path.join(repoRoot, "public", "developer-api-docs.json"); -const markdownOutputPath = path.join(repoRoot, "docs", "developer-api.generated.md"); +const publicOutputPath = path.join( + repoRoot, + 'public', + 'developer-api-docs.json' +); +const markdownOutputPath = path.join( + repoRoot, + 'docs', + 'developer-api.generated.md' +); function renderParams( title: string, - params: { name: string; required?: boolean; description: string; example?: string }[] | undefined, + params: + | { + name: string; + required?: boolean; + description: string; + example?: string; + }[] + | undefined ): string { - if (!params?.length) return ""; + if (!params?.length) return ''; - const lines = [`**${title}**`, ""]; + const lines = [`**${title}**`, '']; for (const param of params) { const required = - param.required === undefined ? "" : param.required ? " (required)" : " (optional)"; - const example = param.example ? ` e.g. \`${param.example}\`` : ""; - lines.push(`- \`${param.name}\`${required}${example}: ${param.description}`); + param.required === undefined + ? '' + : param.required + ? ' (required)' + : ' (optional)'; + const example = param.example ? ` e.g. \`${param.example}\`` : ''; + lines.push( + `- \`${param.name}\`${required}${example}: ${param.description}` + ); } - lines.push(""); - return `${lines.join("\n")}\n`; + lines.push(''); + return `${lines.join('\n')}\n`; } function renderEndpoint(endpoint: DeveloperApiDocEndpoint): string { const sections = [ `### ${endpoint.title}`, - "", + '', `**Scope:** \`${endpoint.scopeId}\` `, - "", + '', `- **${endpoint.method}** \`${endpoint.pathTemplate}\``, `- **Response:** ${endpoint.responseContentType} — ${endpoint.responseSummary}`, - "", - renderParams("Path parameters", endpoint.pathParams), - renderParams("Query parameters", endpoint.queryParams), + '', + renderParams('Path parameters', endpoint.pathParams), + renderParams('Query parameters', endpoint.queryParams), ]; if (endpoint.requestBodySummary || endpoint.requestBodyExampleJson) { - sections.push("**Request body**", ""); - if (endpoint.requestBodySummary) sections.push(endpoint.requestBodySummary, ""); + sections.push('**Request body**', ''); + if (endpoint.requestBodySummary) + sections.push(endpoint.requestBodySummary, ''); if (endpoint.requestBodyExampleJson) { - sections.push("```json", endpoint.requestBodyExampleJson, "```", ""); + sections.push('```json', endpoint.requestBodyExampleJson, '```', ''); } } - sections.push("**Example**", "", "```bash", endpoint.exampleCurl, "```", ""); - return sections.filter((section) => section !== "").join("\n"); + sections.push('**Example**', '', '```bash', endpoint.exampleCurl, '```', ''); + return sections.filter((section) => section !== '').join('\n'); } function renderMarkdown(spec: DeveloperApiPublicSpec): string { const lines = [ - "# Developer API (generated)", - "", + '# Developer API (generated)', + '', `> Generated at **${spec.generatedAt}**. Do not edit by hand - run \`npm run generate:developer-docs\` or \`npm run build\`.`, - "", - "## Overview", - "", + '', + '## Overview', + '', spec.description, - "", + '', `- **Base URL pattern:** \`${spec.baseUrlTemplate}\``, - "", - "## Authentication", - "", + '', + '## Authentication', + '', spec.authentication.description, - "", + '', ...spec.authentication.headers.map( (header) => - `- **${header.name}** (${header.required ? "required" : "optional"}): ${header.description}`, + `- **${header.name}** (${header.required ? 'required' : 'optional'}): ${header.description}` ), - "", - "## Rate limiting", - "", + '', + '## Rate limiting', + '', spec.rateLimiting.description, - "", + '', `- Default: **${spec.rateLimiting.defaultPerMinute}** requests/minute per key`, `- Configure: \`${spec.rateLimiting.envVar}\``, - "", - "## Endpoints", - "", + '', + '## Endpoints', + '', ...spec.endpoints.map(renderEndpoint), ]; return `${lines - .join("\n") - .replace(/\n{3,}/g, "\n\n") + .join('\n') + .replace(/\n{3,}/g, '\n\n') .trimEnd()}\n`; } @@ -105,15 +127,19 @@ async function main(): Promise { ]); await Promise.all([ - writeFile(publicOutputPath, `${JSON.stringify(spec, null, 2)}\n`, "utf8"), - writeFile(markdownOutputPath, renderMarkdown(spec), "utf8"), + writeFile(publicOutputPath, `${JSON.stringify(spec, null, 2)}\n`, 'utf8'), + writeFile(markdownOutputPath, renderMarkdown(spec), 'utf8'), ]); - console.log("[generate-developer-api-docs] Wrote public/developer-api-docs.json"); - console.log("[generate-developer-api-docs] Wrote docs/developer-api.generated.md"); + console.log( + '[generate-developer-api-docs] Wrote public/developer-api-docs.json' + ); + console.log( + '[generate-developer-api-docs] Wrote docs/developer-api.generated.md' + ); } main().catch((error: unknown) => { console.error(error); process.exit(1); -}); \ No newline at end of file +}); diff --git a/scripts/install-hooks.mjs b/scripts/install-hooks.mjs index 3884fbb2..558fc8b8 100644 --- a/scripts/install-hooks.mjs +++ b/scripts/install-hooks.mjs @@ -21,7 +21,7 @@ try { const hooksDir = join(root, '.git', 'hooks'); mkdirSync(hooksDir, { recursive: true }); const hookScript = - '#!/bin/sh\nnode -e "require(\'child_process\').execSync(\'npm run precommit\', {stdio:\'inherit\', shell:true})"\n'; + "#!/bin/sh\nnode -e \"require('child_process').execSync('npm run precommit', {stdio:'inherit', shell:true})\"\n"; writeFileSync(join(hooksDir, 'pre-commit'), hookScript); chmodSync(join(hooksDir, 'pre-commit'), '755'); console.log('[hooks] pre-commit installed'); diff --git a/server/db/admin.ts b/server/db/admin.ts index 399ab117..a762aca5 100644 --- a/server/db/admin.ts +++ b/server/db/admin.ts @@ -1,11 +1,11 @@ -import { mainDb } from "./connection.js"; -import { cleanupOldStatistics } from "./statistics.js"; -import { sql } from "kysely"; -import { redisConnection } from "./connection.js"; -import { decrypt, hashIp } from "../utils/encryption.js"; -import { getAdminIds, isAdmin } from "../middleware/admin.js"; -import { getActiveUsersForSession } from "../websockets/sessionUsersWebsocket.js"; -import { getUserRoles } from "./roles.js"; +import { mainDb } from './connection.js'; +import { cleanupOldStatistics } from './statistics.js'; +import { sql } from 'kysely'; +import { redisConnection } from './connection.js'; +import { decrypt, hashIp } from '../utils/encryption.js'; +import { getAdminIds, isAdmin } from '../middleware/admin.js'; +import { getActiveUsersForSession } from '../websockets/sessionUsersWebsocket.js'; +import { getUserRoles } from './roles.js'; type RawUser = { id: string; @@ -36,18 +36,18 @@ type ProcessedUser = RawUser & { async function calculateDirectStatistics() { try { const usersResult = await mainDb - .selectFrom("users") - .select(({ fn }) => fn.countAll().as("count")) + .selectFrom('users') + .select(({ fn }) => fn.countAll().as('count')) .executeTakeFirst(); const sessionsResult = await mainDb - .selectFrom("sessions") - .select(({ fn }) => fn.countAll().as("count")) + .selectFrom('sessions') + .select(({ fn }) => fn.countAll().as('count')) .executeTakeFirst(); const flightCountResult = await mainDb - .selectFrom("flights") - .select(({ fn }) => fn.countAll().as("count")) + .selectFrom('flights') + .select(({ fn }) => fn.countAll().as('count')) .executeTakeFirst(); const totalFlights = Number(flightCountResult?.count) || 0; @@ -58,7 +58,7 @@ async function calculateDirectStatistics() { total_users: Number(usersResult?.count) || 0, }; } catch (error) { - console.error("Error calculating direct statistics:", error); + console.error('Error calculating direct statistics:', error); return { total_logins: 0, total_sessions: 0, @@ -75,7 +75,7 @@ async function backfillStatistics() { const today = new Date(); await mainDb - .insertInto("daily_statistics") + .insertInto('daily_statistics') .values({ id: sql`DEFAULT`, date: today, @@ -85,16 +85,16 @@ async function backfillStatistics() { new_users_count: directStats.total_users, }) .onConflict((oc) => - oc.column("date").doUpdateSet({ + oc.column('date').doUpdateSet({ new_sessions_count: directStats.total_sessions, new_flights_count: directStats.total_flights, new_users_count: directStats.total_users, - updated_at: mainDb.fn("NOW"), - }), + updated_at: mainDb.fn('NOW'), + }) ) .execute(); } catch (error) { - console.error("Error backfilling statistics:", error); + console.error('Error backfilling statistics:', error); } } @@ -108,7 +108,10 @@ export async function getDailyStatistics(days = 30) { } } catch (error) { if (error instanceof Error) { - console.warn(`[Redis] Failed to read cache for daily stats (${days} days):`, error.message); + console.warn( + `[Redis] Failed to read cache for daily stats (${days} days):`, + error.message + ); } } @@ -116,16 +119,18 @@ export async function getDailyStatistics(days = 30) { await cleanupOldStatistics(); const result = await mainDb - .selectFrom("daily_statistics") + .selectFrom('daily_statistics') .select([ - "date", - mainDb.fn.coalesce("logins_count", sql`0`).as("logins_count"), - mainDb.fn.coalesce("new_sessions_count", sql`0`).as("new_sessions_count"), - mainDb.fn.coalesce("new_flights_count", sql`0`).as("new_flights_count"), - mainDb.fn.coalesce("new_users_count", sql`0`).as("new_users_count"), + 'date', + mainDb.fn.coalesce('logins_count', sql`0`).as('logins_count'), + mainDb.fn + .coalesce('new_sessions_count', sql`0`) + .as('new_sessions_count'), + mainDb.fn.coalesce('new_flights_count', sql`0`).as('new_flights_count'), + mainDb.fn.coalesce('new_users_count', sql`0`).as('new_users_count'), ]) - .where("date", ">=", new Date(Date.now() - days * 24 * 60 * 60 * 1000)) - .orderBy("date", "asc") + .where('date', '>=', new Date(Date.now() - days * 24 * 60 * 60 * 1000)) + .orderBy('date', 'asc') .execute(); if (result.length === 0) { @@ -134,22 +139,25 @@ export async function getDailyStatistics(days = 30) { } try { - await redisConnection.set(cacheKey, JSON.stringify(result), "EX", 300); + await redisConnection.set(cacheKey, JSON.stringify(result), 'EX', 300); } catch (error) { if (error instanceof Error) { - console.warn(`[Redis] Failed to set cache for daily stats (${days} days):`, error.message); + console.warn( + `[Redis] Failed to set cache for daily stats (${days} days):`, + error.message + ); } } return result; } catch (error) { - console.error("Error fetching daily statistics:", error); + console.error('Error fetching daily statistics:', error); return []; } } export async function getTotalStatistics() { - const cacheKey = "admin:total_stats"; + const cacheKey = 'admin:total_stats'; try { const cached = await redisConnection.get(cacheKey); @@ -158,7 +166,10 @@ export async function getTotalStatistics() { } } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to read cache for total stats:", error.message); + console.warn( + '[Redis] Failed to read cache for total stats:', + error.message + ); } } @@ -166,12 +177,12 @@ export async function getTotalStatistics() { const directStats = await calculateDirectStatistics(); const dailyStatsResult = await mainDb - .selectFrom("daily_statistics") + .selectFrom('daily_statistics') .select(({ fn }) => [ - fn.coalesce(fn.sum("logins_count"), sql`0`).as("total_logins"), - fn.coalesce(fn.sum("new_sessions_count"), sql`0`).as("total_sessions"), - fn.coalesce(fn.sum("new_flights_count"), sql`0`).as("total_flights"), - fn.coalesce(fn.sum("new_users_count"), sql`0`).as("total_users"), + fn.coalesce(fn.sum('logins_count'), sql`0`).as('total_logins'), + fn.coalesce(fn.sum('new_sessions_count'), sql`0`).as('total_sessions'), + fn.coalesce(fn.sum('new_flights_count'), sql`0`).as('total_flights'), + fn.coalesce(fn.sum('new_users_count'), sql`0`).as('total_users'), ]) .executeTakeFirst(); @@ -183,16 +194,19 @@ export async function getTotalStatistics() { }; try { - await redisConnection.set(cacheKey, JSON.stringify(result), "EX", 300); + await redisConnection.set(cacheKey, JSON.stringify(result), 'EX', 300); } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to set cache for total stats:", error.message); + console.warn( + '[Redis] Failed to set cache for total stats:', + error.message + ); } } return result; } catch (error) { - console.error("Error fetching total statistics:", error); + console.error('Error fetching total statistics:', error); return { total_logins: 0, total_sessions: 0, @@ -202,7 +216,12 @@ export async function getTotalStatistics() { } } -export async function getAllUsers(page = 1, limit = 50, search = "", filterAdmin = "all") { +export async function getAllUsers( + page = 1, + limit = 50, + search = '', + filterAdmin = 'all' +) { try { const offset = (page - 1) * limit; const cacheKey = `allUsers:${page}:${limit}:${search}:${filterAdmin}`; @@ -217,50 +236,50 @@ export async function getAllUsers(page = 1, limit = 50, search = "", filterAdmin totalUsers = parsed.total; } else { let query = mainDb - .selectFrom("users as u") - .leftJoin("roles as r", "u.role_id", "r.id") + .selectFrom('users as u') + .leftJoin('roles as r', 'u.role_id', 'r.id') .select([ - "u.id", - "u.username", - "u.discriminator", - "u.avatar", - "u.last_login", - "u.ip_address", - "u.is_vpn", - "u.total_sessions_created", - "u.total_minutes", - "u.created_at", - "u.settings", - "u.roblox_username", - "u.role_id", - "r.name as role_name", - "r.permissions as role_permissions", + 'u.id', + 'u.username', + 'u.discriminator', + 'u.avatar', + 'u.last_login', + 'u.ip_address', + 'u.is_vpn', + 'u.total_sessions_created', + 'u.total_minutes', + 'u.created_at', + 'u.settings', + 'u.roblox_username', + 'u.role_id', + 'r.name as role_name', + 'r.permissions as role_permissions', ]) - .orderBy("u.last_login", "desc"); + .orderBy('u.last_login', 'desc'); - const trimmedSearch = search && search.trim() ? search.trim() : ""; + const trimmedSearch = search && search.trim() ? search.trim() : ''; const isIpSearch = Boolean(trimmedSearch && /[.:]/.test(trimmedSearch)); if (!isIpSearch && trimmedSearch) { query = query.where((eb) => eb.or([ - eb("u.username", "ilike", `%${trimmedSearch}%`), - eb("u.id", "ilike", `%${trimmedSearch}%`), - ]), + eb('u.username', 'ilike', `%${trimmedSearch}%`), + eb('u.id', 'ilike', `%${trimmedSearch}%`), + ]) ); } else if (isIpSearch) { const ipHash = hashIp(trimmedSearch); - query = query.where("u.ip_hash", "=", ipHash); + query = query.where('u.ip_hash', '=', ipHash); } - if (filterAdmin === "admin" || filterAdmin === "non-admin") { + if (filterAdmin === 'admin' || filterAdmin === 'non-admin') { const adminIds = getAdminIds(); if (adminIds.length > 0) { - if (filterAdmin === "admin") { - query = query.where("u.id", "in", adminIds); + if (filterAdmin === 'admin') { + query = query.where('u.id', 'in', adminIds); } else { - query = query.where("u.id", "not in", adminIds); + query = query.where('u.id', 'not in', adminIds); } } else { return { @@ -271,27 +290,29 @@ export async function getAllUsers(page = 1, limit = 50, search = "", filterAdmin } let rows; - if (filterAdmin === "cached") { + if (filterAdmin === 'cached') { try { - let cursor = "0"; + let cursor = '0'; const cachedUserIds: string[] = []; do { const [newCursor, keys] = await redisConnection.scan( cursor, - "MATCH", - "user:*", - "COUNT", - 1000, + 'MATCH', + 'user:*', + 'COUNT', + 1000 ); cursor = newCursor; const userIds = keys - .filter((key) => key.startsWith("user:") && !key.includes(":username:")) - .map((key) => key.replace("user:", "")); + .filter( + (key) => key.startsWith('user:') && !key.includes(':username:') + ) + .map((key) => key.replace('user:', '')); cachedUserIds.push(...userIds); - } while (cursor !== "0"); + } while (cursor !== '0'); if (cachedUserIds.length === 0) { return { @@ -300,17 +321,17 @@ export async function getAllUsers(page = 1, limit = 50, search = "", filterAdmin }; } - query = query.where("u.id", "in", cachedUserIds); + query = query.where('u.id', 'in', cachedUserIds); rows = await query.execute(); } catch (error) { - console.error("Error getting cached user IDs from Redis:", error); + console.error('Error getting cached user IDs from Redis:', error); rows = await query.execute(); } } else { const countQuery = query .clearSelect() .clearOrderBy() - .select(({ fn }) => fn.countAll().as("count")); + .select(({ fn }) => fn.countAll().as('count')); const countResult = await countQuery.executeTakeFirst(); totalUsers = Number(countResult?.count) || 0; @@ -336,14 +357,18 @@ export async function getAllUsers(page = 1, limit = 50, search = "", filterAdmin })); const userIds = rawUsers.map((u) => u.id); - const allUserRoles = await Promise.all(userIds.map((userId) => getUserRoles(userId))); + const allUserRoles = await Promise.all( + userIds.map((userId) => getUserRoles(userId)) + ); const usersWithAdminStatus = rawUsers.map((user, index) => { let decryptedSettings = null; try { if (user.settings) { const settingsObj = - typeof user.settings === "string" ? JSON.parse(user.settings) : user.settings; + typeof user.settings === 'string' + ? JSON.parse(user.settings) + : user.settings; decryptedSettings = decrypt(settingsObj); } } catch { @@ -354,30 +379,36 @@ export async function getAllUsers(page = 1, limit = 50, search = "", filterAdmin try { if (user.role_permissions) { rolePermissions = - typeof user.role_permissions === "string" + typeof user.role_permissions === 'string' ? JSON.parse(user.role_permissions) : user.role_permissions; } } catch (error) { - console.warn(`Failed to parse role permissions for user ${user.id}:`, error); + console.warn( + `Failed to parse role permissions for user ${user.id}:`, + error + ); } user.role_permissions = rolePermissions; let decryptedIP = user.ip_address; if (user.ip_address) { try { - if (typeof user.ip_address === "string" && user.ip_address.trim().startsWith("{")) { + if ( + typeof user.ip_address === 'string' && + user.ip_address.trim().startsWith('{') + ) { decryptedIP = decrypt(JSON.parse(user.ip_address)); } else { const isEncryptedObject = ( - val: unknown, + val: unknown ): val is { iv: string; data: string; authTag: string } => { - if (typeof val !== "object" || val === null) return false; + if (typeof val !== 'object' || val === null) return false; const obj = val as Record; return ( - typeof obj.iv === "string" && - typeof obj.data === "string" && - typeof obj.authTag === "string" + typeof obj.iv === 'string' && + typeof obj.data === 'string' && + typeof obj.authTag === 'string' ); }; @@ -401,18 +432,21 @@ export async function getAllUsers(page = 1, limit = 50, search = "", filterAdmin }); let usersWithCacheStatus; - if (filterAdmin === "cached") { - usersWithCacheStatus = usersWithAdminStatus.map((user) => ({ ...user, cached: true })); + if (filterAdmin === 'cached') { + usersWithCacheStatus = usersWithAdminStatus.map((user) => ({ + ...user, + cached: true, + })); } else { usersWithCacheStatus = await Promise.all( usersWithAdminStatus.map(async (user) => { const isCached = await redisConnection.exists(`user:${user.id}`); return { ...user, cached: isCached === 1 }; - }), + }) ); } - if (filterAdmin === "cached") { + if (filterAdmin === 'cached') { filteredUsers = usersWithCacheStatus; totalUsers = filteredUsers.length; filteredUsers = filteredUsers.slice(offset, offset + limit); @@ -424,12 +458,15 @@ export async function getAllUsers(page = 1, limit = 50, search = "", filterAdmin await redisConnection.set( cacheKey, JSON.stringify({ users: filteredUsers, total: totalUsers }), - "EX", - 300, + 'EX', + 300 ); } catch (error) { if (error instanceof Error) { - console.warn(`[Redis] Failed to set cache for allUsers (${cacheKey}):`, error.message); + console.warn( + `[Redis] Failed to set cache for allUsers (${cacheKey}):`, + error.message + ); } } } @@ -444,49 +481,49 @@ export async function getAllUsers(page = 1, limit = 50, search = "", filterAdmin }, }; } catch (error) { - console.error("Error fetching users:", error); + console.error('Error fetching users:', error); throw error; } } -export async function getAdminSessions(page = 1, limit = 100, search = "") { +export async function getAdminSessions(page = 1, limit = 100, search = '') { try { const offset = (page - 1) * limit; let query = mainDb - .selectFrom("sessions as s") - .leftJoin("users as u", "s.created_by", "u.id") + .selectFrom('sessions as s') + .leftJoin('users as u', 's.created_by', 'u.id') .select([ - "s.session_id", - "s.access_id", - "s.airport_icao", - "s.active_runway", - sql`(s.created_at AT TIME ZONE 'UTC')`.as("created_at"), - "s.created_by", - "s.is_pfatc", - "s.is_advanced_atc", - "u.username", - "u.discriminator", - "u.avatar", + 's.session_id', + 's.access_id', + 's.airport_icao', + 's.active_runway', + sql`(s.created_at AT TIME ZONE 'UTC')`.as('created_at'), + 's.created_by', + 's.is_pfatc', + 's.is_advanced_atc', + 'u.username', + 'u.discriminator', + 'u.avatar', ]) - .orderBy("s.created_at", "desc"); + .orderBy('s.created_at', 'desc'); if (search && search.trim()) { const searchTerm = `%${search.trim()}%`; query = query.where((eb) => eb.or([ - eb("s.session_id", "ilike", searchTerm), - eb("s.airport_icao", "ilike", searchTerm), - eb("u.username", "ilike", searchTerm), - eb("s.created_by", "ilike", searchTerm), - ]), + eb('s.session_id', 'ilike', searchTerm), + eb('s.airport_icao', 'ilike', searchTerm), + eb('u.username', 'ilike', searchTerm), + eb('s.created_by', 'ilike', searchTerm), + ]) ); } const countQuery = query .clearSelect() .clearOrderBy() - .select(({ fn }) => fn.countAll().as("count")); + .select(({ fn }) => fn.countAll().as('count')); const countResult = await countQuery.executeTakeFirst(); const total = Number(countResult?.count) || 0; const pages = Math.ceil(total / limit); @@ -498,26 +535,30 @@ export async function getAdminSessions(page = 1, limit = 100, search = "") { const flightCounts = sessionIds.length > 0 ? await mainDb - .selectFrom("flights") - .select(["session_id", mainDb.fn.countAll().as("count")]) - .where("session_id", "in", sessionIds) - .groupBy("session_id") + .selectFrom('flights') + .select(['session_id', mainDb.fn.countAll().as('count')]) + .where('session_id', 'in', sessionIds) + .groupBy('session_id') .execute() : []; - const flightCountMap = new Map(flightCounts.map((r) => [r.session_id, Number(r.count)])); + const flightCountMap = new Map( + flightCounts.map((r) => [r.session_id, Number(r.count)]) + ); const sessionsWithDetails = await Promise.all( sessions.map(async (session) => { const flight_count = flightCountMap.get(session.session_id) || 0; - const activeSessionUsers = await getActiveUsersForSession(session.session_id); + const activeSessionUsers = await getActiveUsersForSession( + session.session_id + ); return { ...session, flight_count, active_users: activeSessionUsers, active_user_count: activeSessionUsers.length, }; - }), + }) ); return { @@ -530,7 +571,7 @@ export async function getAdminSessions(page = 1, limit = 100, search = "") { }, }; } catch (error) { - console.error("Error fetching admin sessions:", error); + console.error('Error fetching admin sessions:', error); throw error; } } @@ -538,41 +579,41 @@ export async function getAdminSessions(page = 1, limit = 100, search = "") { export async function syncUserSessionCounts() { try { const sessionCounts = await mainDb - .selectFrom("sessions") - .select(["created_by", mainDb.fn.countAll().as("session_count")]) - .groupBy("created_by") + .selectFrom('sessions') + .select(['created_by', mainDb.fn.countAll().as('session_count')]) + .groupBy('created_by') .execute(); for (const row of sessionCounts) { await mainDb - .updateTable("users") + .updateTable('users') .set({ total_sessions_created: Number(row.session_count) }) - .where("id", "=", row.created_by) + .where('id', '=', row.created_by) .execute(); } await mainDb - .updateTable("users") + .updateTable('users') .set({ total_sessions_created: 0 }) .where( - "id", - "not in", - sessionCounts.map((r) => r.created_by), + 'id', + 'not in', + sessionCounts.map((r) => r.created_by) ) .execute(); return { - message: "Session counts synced successfully", + message: 'Session counts synced successfully', updatedUsers: sessionCounts.length, }; } catch (error) { - console.error("Error syncing user session counts:", error); + console.error('Error syncing user session counts:', error); throw error; } } export async function getControllerRatingStats() { - const cacheKey = "admin:controller_rating_stats"; + const cacheKey = 'admin:controller_rating_stats'; try { const cached = await redisConnection.get(cacheKey); @@ -581,41 +622,47 @@ export async function getControllerRatingStats() { } } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to read cache for controller rating stats:", error.message); + console.warn( + '[Redis] Failed to read cache for controller rating stats:', + error.message + ); } } try { const topRatedControllers = await mainDb - .selectFrom("controller_ratings") + .selectFrom('controller_ratings') .select([ - "controller_id", - (eb) => eb.fn.avg("rating").as("avg_rating"), - (eb) => eb.fn.count("id").as("rating_count"), + 'controller_id', + (eb) => eb.fn.avg('rating').as('avg_rating'), + (eb) => eb.fn.count('id').as('rating_count'), ]) - .groupBy("controller_id") - .having((eb) => eb.fn.count("id"), ">=", 3) - .orderBy("avg_rating", "desc") + .groupBy('controller_id') + .having((eb) => eb.fn.count('id'), '>=', 3) + .orderBy('avg_rating', 'desc') .limit(10) .execute(); const mostRatedControllers = await mainDb - .selectFrom("controller_ratings") + .selectFrom('controller_ratings') .select([ - "controller_id", - (eb) => eb.fn.count("id").as("rating_count"), - (eb) => eb.fn.avg("rating").as("avg_rating"), + 'controller_id', + (eb) => eb.fn.count('id').as('rating_count'), + (eb) => eb.fn.avg('rating').as('avg_rating'), ]) - .groupBy("controller_id") - .orderBy("rating_count", "desc") + .groupBy('controller_id') + .orderBy('rating_count', 'desc') .limit(10) .execute(); const topRatingPilots = await mainDb - .selectFrom("controller_ratings") - .select(["pilot_id", (eb) => eb.fn.count("id").as("rating_count")]) - .groupBy("pilot_id") - .orderBy("rating_count", "desc") + .selectFrom('controller_ratings') + .select([ + 'pilot_id', + (eb) => eb.fn.count('id').as('rating_count'), + ]) + .groupBy('pilot_id') + .orderBy('rating_count', 'desc') .limit(9) .execute(); @@ -628,42 +675,47 @@ export async function getControllerRatingStats() { const pilotIds = topRatingPilots.map((p) => p.pilot_id); const users = await mainDb - .selectFrom("users") - .select(["id", "username", "avatar"]) - .where("id", "in", [...controllerIds, ...pilotIds]) + .selectFrom('users') + .select(['id', 'username', 'avatar']) + .where('id', 'in', [...controllerIds, ...pilotIds]) .execute(); - const userMap = new Map(users.map((u) => [u.id, { username: u.username, avatar: u.avatar }])); + const userMap = new Map( + users.map((u) => [u.id, { username: u.username, avatar: u.avatar }]) + ); const result = { topRated: topRatedControllers.map((c) => ({ ...c, - username: userMap.get(c.controller_id)?.username || "Unknown", + username: userMap.get(c.controller_id)?.username || 'Unknown', avatar: userMap.get(c.controller_id)?.avatar || null, })), mostRated: mostRatedControllers.map((c) => ({ ...c, - username: userMap.get(c.controller_id)?.username || "Unknown", + username: userMap.get(c.controller_id)?.username || 'Unknown', avatar: userMap.get(c.controller_id)?.avatar || null, })), topPilots: topRatingPilots.map((p) => ({ ...p, - username: userMap.get(p.pilot_id)?.username || "Unknown", + username: userMap.get(p.pilot_id)?.username || 'Unknown', avatar: userMap.get(p.pilot_id)?.avatar || null, })), }; try { - await redisConnection.set(cacheKey, JSON.stringify(result), "EX", 300); + await redisConnection.set(cacheKey, JSON.stringify(result), 'EX', 300); } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to set cache for controller rating stats:", error.message); + console.warn( + '[Redis] Failed to set cache for controller rating stats:', + error.message + ); } } return result; } catch (error) { - console.error("Error fetching controller rating stats:", error); + console.error('Error fetching controller rating stats:', error); throw error; } } @@ -680,62 +732,71 @@ export async function getControllerRatingsDailyStats(days: number = 30) { if (error instanceof Error) { console.warn( `[Redis] Failed to read cache for controller rating daily stats (${days} days):`, - error.message, + error.message ); } } try { const dailyStats = await mainDb - .selectFrom("controller_ratings") + .selectFrom('controller_ratings') .select([ - sql`DATE(created_at)`.as("date"), - (eb) => eb.fn.count("id").as("count"), - (eb) => eb.fn.avg("rating").as("avg_rating"), + sql`DATE(created_at)`.as('date'), + (eb) => eb.fn.count('id').as('count'), + (eb) => eb.fn.avg('rating').as('avg_rating'), ]) - .where("created_at", ">=", sql`NOW() - INTERVAL '${sql.raw(days.toString())} days'`) + .where( + 'created_at', + '>=', + sql`NOW() - INTERVAL '${sql.raw(days.toString())} days'` + ) .groupBy(sql`DATE(created_at)`) - .orderBy(sql`DATE(created_at)`, "asc") + .orderBy(sql`DATE(created_at)`, 'asc') .execute(); try { - await redisConnection.set(cacheKey, JSON.stringify(dailyStats), "EX", 300); + await redisConnection.set( + cacheKey, + JSON.stringify(dailyStats), + 'EX', + 300 + ); } catch (error) { if (error instanceof Error) { console.warn( `[Redis] Failed to set cache for controller rating daily stats (${days} days):`, - error.message, + error.message ); } } return dailyStats; } catch (error) { - console.error("Error fetching daily controller rating stats:", error); + console.error('Error fetching daily controller rating stats:', error); throw error; } } export async function invalidateAllUsersCache() { try { - let cursor = "0"; + let cursor = '0'; const keysToDelete: string[] = []; do { const [newCursor, keys] = await redisConnection.scan( cursor, - "MATCH", - "allUsers:*", - "COUNT", - 100, + 'MATCH', + 'allUsers:*', + 'COUNT', + 100 ); cursor = newCursor; keysToDelete.push(...keys); - } while (cursor !== "0"); + } while (cursor !== '0'); if (keysToDelete.length > 0) { await redisConnection.del(...keysToDelete); } } catch (error) { - console.warn("[Redis] Failed to invalidate allUsers cache:", error); + console.warn('[Redis] Failed to invalidate allUsers cache:', error); } -} \ No newline at end of file +} diff --git a/server/db/audit.ts b/server/db/audit.ts index 5f23df36..b4c8c009 100644 --- a/server/db/audit.ts +++ b/server/db/audit.ts @@ -1,7 +1,7 @@ -import { mainDb } from "./connection.js"; -import { recordTableDeletes } from "./databaseMetrics.js"; -import { encrypt, decrypt } from "../utils/encryption.js"; -import { sql } from "kysely"; +import { mainDb } from './connection.js'; +import { recordTableDeletes } from './databaseMetrics.js'; +import { encrypt, decrypt } from '../utils/encryption.js'; +import { sql } from 'kysely'; export interface AdminActionData { adminId: number | string; @@ -30,7 +30,7 @@ export async function logAdminAction(actionData: AdminActionData) { const encryptedIP = ipAddress ? encrypt(ipAddress) : null; const result = await mainDb - .insertInto("audit_log") + .insertInto('audit_log') .values({ id: sql`DEFAULT`, admin_id: String(adminId), @@ -49,12 +49,12 @@ export async function logAdminAction(actionData: AdminActionData) { user_agent: userAgent !== null && userAgent !== undefined ? userAgent : undefined, }) - .returning(["id", "created_at"]) + .returning(['id', 'created_at']) .executeTakeFirst(); return result?.id; } catch (error) { - console.error("Error logging admin action:", error); + console.error('Error logging admin action:', error); throw error; } } @@ -76,52 +76,52 @@ export async function getAuditLogs( const offset = (page - 1) * limit; let query = mainDb - .selectFrom("audit_log") + .selectFrom('audit_log') .select([ - "id", - "admin_id", - "admin_username", - "action_type", - "target_user_id", - "target_username", - "details", - "ip_address", - "user_agent", - "created_at", + 'id', + 'admin_id', + 'admin_username', + 'action_type', + 'target_user_id', + 'target_username', + 'details', + 'ip_address', + 'user_agent', + 'created_at', ]); if (filters.adminId) { query = query.where((q) => q.or([ - q("admin_id", "ilike", `%${filters.adminId}%`), - q("admin_username", "ilike", `%${filters.adminId}%`), + q('admin_id', 'ilike', `%${filters.adminId}%`), + q('admin_username', 'ilike', `%${filters.adminId}%`), ]) ); } if (filters.actionType) { - query = query.where("action_type", "=", filters.actionType); + query = query.where('action_type', '=', filters.actionType); } if (filters.targetUserId) { query = query.where((q) => q.or([ - q("target_user_id", "ilike", `%${filters.targetUserId}%`), - q("target_username", "ilike", `%${filters.targetUserId}%`), + q('target_user_id', 'ilike', `%${filters.targetUserId}%`), + q('target_username', 'ilike', `%${filters.targetUserId}%`), ]) ); } if (filters.dateFrom) { - query = query.where("created_at", ">=", new Date(filters.dateFrom)); + query = query.where('created_at', '>=', new Date(filters.dateFrom)); } if (filters.dateTo) { - query = query.where("created_at", "<=", new Date(filters.dateTo)); + query = query.where('created_at', '<=', new Date(filters.dateTo)); } const logsResult = await query - .orderBy("created_at", "desc") + .orderBy('created_at', 'desc') .limit(limit) .offset(offset) .execute(); @@ -131,7 +131,7 @@ export async function getAuditLogs( if (log.ip_address) { try { const parsed = - typeof log.ip_address === "string" + typeof log.ip_address === 'string' ? JSON.parse(log.ip_address) : log.ip_address; decryptedIP = decrypt(parsed); @@ -143,7 +143,7 @@ export async function getAuditLogs( ...log, ip_address: decryptedIP, details: - typeof log.details === "string" + typeof log.details === 'string' ? JSON.parse(log.details) : log.details, }; @@ -151,38 +151,38 @@ export async function getAuditLogs( // Count query for pagination let countQuery = mainDb - .selectFrom("audit_log") - .select(sql`count(*)`.as("count")); + .selectFrom('audit_log') + .select(sql`count(*)`.as('count')); if (filters.adminId) { countQuery = countQuery.where((q) => q.or([ - q("admin_id", "ilike", `%${filters.adminId}%`), - q("admin_username", "ilike", `%${filters.adminId}%`), + q('admin_id', 'ilike', `%${filters.adminId}%`), + q('admin_username', 'ilike', `%${filters.adminId}%`), ]) ); } if (filters.actionType) { - countQuery = countQuery.where("action_type", "=", filters.actionType); + countQuery = countQuery.where('action_type', '=', filters.actionType); } if (filters.targetUserId) { countQuery = countQuery.where((q) => q.or([ - q("target_user_id", "ilike", `%${filters.targetUserId}%`), - q("target_username", "ilike", `%${filters.targetUserId}%`), + q('target_user_id', 'ilike', `%${filters.targetUserId}%`), + q('target_username', 'ilike', `%${filters.targetUserId}%`), ]) ); } if (filters.dateFrom) { countQuery = countQuery.where( - "created_at", - ">=", + 'created_at', + '>=', new Date(filters.dateFrom) ); } if (filters.dateTo) { countQuery = countQuery.where( - "created_at", - "<=", + 'created_at', + '<=', new Date(filters.dateTo) ); } @@ -200,7 +200,7 @@ export async function getAuditLogs( }, }; } catch (error) { - console.error("Error fetching audit logs:", error); + console.error('Error fetching audit logs:', error); throw error; } } @@ -208,20 +208,20 @@ export async function getAuditLogs( export async function getAuditLogById(logId: number | string) { try { const result = await mainDb - .selectFrom("audit_log") + .selectFrom('audit_log') .select([ - "id", - "admin_id", - "admin_username", - "action_type", - "target_user_id", - "target_username", - "details", - "ip_address", - "user_agent", - "created_at", + 'id', + 'admin_id', + 'admin_username', + 'action_type', + 'target_user_id', + 'target_username', + 'details', + 'ip_address', + 'user_agent', + 'created_at', ]) - .where("id", "=", typeof logId === "string" ? Number(logId) : logId) + .where('id', '=', typeof logId === 'string' ? Number(logId) : logId) .executeTakeFirst(); if (!result) { @@ -232,7 +232,7 @@ export async function getAuditLogById(logId: number | string) { if (result.ip_address) { try { const parsed = - typeof result.ip_address === "string" + typeof result.ip_address === 'string' ? JSON.parse(result.ip_address) : result.ip_address; decryptedIP = decrypt(parsed); @@ -245,12 +245,12 @@ export async function getAuditLogById(logId: number | string) { ...result, ip_address: decryptedIP, details: - typeof result.details === "string" + typeof result.details === 'string' ? JSON.parse(result.details) : result.details, }; } catch (error) { - console.error("Error fetching audit log by ID:", error); + console.error('Error fetching audit log by ID:', error); throw error; } } @@ -258,19 +258,19 @@ export async function getAuditLogById(logId: number | string) { export async function cleanupOldAuditLogs(daysToKeep = 14) { try { const result = await mainDb - .deleteFrom("audit_log") + .deleteFrom('audit_log') .where( - "created_at", - "<", + 'created_at', + '<', new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000) ) .executeTakeFirst(); const deleted = Number(result?.numDeletedRows ?? 0); - await recordTableDeletes("audit_log", deleted); + await recordTableDeletes('audit_log', deleted); return deleted; } catch (error) { - console.error("Error cleaning up audit logs:", error); + console.error('Error cleaning up audit logs:', error); throw error; } } @@ -284,7 +284,7 @@ function startAutomaticCleanup() { try { await cleanupOldAuditLogs(14); } catch (error) { - console.error("Initial audit log cleanup failed:", error); + console.error('Initial audit log cleanup failed:', error); } }, 60 * 1000); @@ -292,9 +292,9 @@ function startAutomaticCleanup() { try { await cleanupOldAuditLogs(14); } catch (error) { - console.error("Scheduled audit log cleanup failed:", error); + console.error('Scheduled audit log cleanup failed:', error); } }, CLEANUP_INTERVAL); } -startAutomaticCleanup(); \ No newline at end of file +startAutomaticCleanup(); diff --git a/server/db/ban.ts b/server/db/ban.ts index 573218c3..d99d432c 100644 --- a/server/db/ban.ts +++ b/server/db/ban.ts @@ -51,7 +51,9 @@ export async function banUser({ } } -export async function isFingerprintBanned(fingerprintId: string): Promise { +export async function isFingerprintBanned( + fingerprintId: string +): Promise { const cacheKey = `ban:fp:${fingerprintId}`; const cached = await redisConnection.get(cacheKey); if (cached === '1') return true; diff --git a/server/db/chats.ts b/server/db/chats.ts index a06bb2ec..fabe4213 100644 --- a/server/db/chats.ts +++ b/server/db/chats.ts @@ -1,12 +1,12 @@ -import { mainDb } from "./connection.js"; -import { encrypt, decrypt } from "../utils/encryption.js"; -import { validateSessionId } from "../utils/validation.js"; -import { incrementStat } from "../utils/statisticsCache.js"; +import { mainDb } from './connection.js'; +import { encrypt, decrypt } from '../utils/encryption.js'; +import { validateSessionId } from '../utils/validation.js'; +import { incrementStat } from '../utils/statisticsCache.js'; import { containsHateSpeech, getHateSpeechReason, -} from "../utils/hateSpeechFilter.js"; -import { sql } from "kysely"; +} from '../utils/hateSpeechFilter.js'; +import { sql } from 'kysely'; export async function addChatMessage( sessionId: string, @@ -26,17 +26,17 @@ export async function addChatMessage( ) { const validSessionId = validateSessionId(sessionId); const encryptedMsg = encrypt(message); - if (!encryptedMsg) throw new Error("Encryption failed for chat message."); + if (!encryptedMsg) throw new Error('Encryption failed for chat message.'); if ( !Array.isArray(mentions) || - !mentions.every((m) => typeof m === "string") + !mentions.every((m) => typeof m === 'string') ) { - throw new Error("Invalid mentions format: must be an array of strings"); + throw new Error('Invalid mentions format: must be an array of strings'); } const result = await mainDb - .insertInto("session_chat") + .insertInto('session_chat') .values({ id: sql`DEFAULT`, session_id: validSessionId, @@ -49,7 +49,7 @@ export async function addChatMessage( .returningAll() .executeTakeFirst(); - incrementStat(userId, "total_chat_messages_sent"); + incrementStat(userId, 'total_chat_messages_sent'); let automodded = false; let automodReason: string | undefined; @@ -58,19 +58,19 @@ export async function addChatMessage( automodded = true; automodReason = getHateSpeechReason(message); await mainDb - .insertInto("chat_report") + .insertInto('chat_report') .values({ id: sql`DEFAULT`, session_id: validSessionId, message_id: result!.id, - reporter_user_id: "automod", - reporter_username: "Automod", + reporter_user_id: 'automod', + reporter_username: 'Automod', reported_user_id: userId, reported_username: username, - reported_avatar: avatar || "/assets/app/default/avatar.webp", + reported_avatar: avatar || '/assets/app/default/avatar.webp', message, reason: `${automodReason} (automod)`, - reporter_avatar: "/assets/images/automod.webp", + reporter_avatar: '/assets/images/automod.webp', }) .execute(); } @@ -88,13 +88,13 @@ export async function addChatMessage( }; void (async () => { - const { pushSessionChatMessage } = await import("../realtime/chatCache.js"); - const { onChatMessage } = await import("../realtime/invalidate.js"); + const { pushSessionChatMessage } = await import('../realtime/chatCache.js'); + const { onChatMessage } = await import('../realtime/invalidate.js'); await pushSessionChatMessage(validSessionId, { id: formatted.id, userId: formatted.userId, - username: formatted.username ?? "", - avatar: formatted.avatar ?? "", + username: formatted.username ?? '', + avatar: formatted.avatar ?? '', message: formatted.message, mentions: formatted.mentions, sent_at: formatted.sent_at, @@ -107,7 +107,7 @@ export async function addChatMessage( export async function getChatMessages(sessionId: string, limit = 50) { const { getCachedSessionChatMessages } = - await import("../realtime/chatCache.js"); + await import('../realtime/chatCache.js'); return getCachedSessionChatMessages(sessionId, limit); } @@ -115,24 +115,24 @@ export async function getChatMessagesFromDb(sessionId: string, limit = 50) { const validSessionId = validateSessionId(sessionId); const rows = await mainDb - .selectFrom("session_chat") + .selectFrom('session_chat') .selectAll() - .where("session_id", "=", validSessionId) - .orderBy("sent_at", "asc") + .where('session_id', '=', validSessionId) + .orderBy('sent_at', 'asc') .limit(limit) .execute(); return rows.map((row) => { - let decryptedMsg = ""; + let decryptedMsg = ''; try { - if (row.message) decryptedMsg = decrypt(JSON.parse(row.message)) ?? ""; + if (row.message) decryptedMsg = decrypt(JSON.parse(row.message)) ?? ''; } catch { - decryptedMsg = ""; + decryptedMsg = ''; } let parsedMentions: string[] = []; if (row.mentions) { - if (typeof row.mentions === "string") { + if (typeof row.mentions === 'string') { try { const parsed = JSON.parse(row.mentions); parsedMentions = Array.isArray(parsed) ? parsed : []; @@ -163,15 +163,15 @@ export async function deleteChatMessage( ) { const validSessionId = validateSessionId(sessionId); const result = await mainDb - .deleteFrom("session_chat") - .where("session_id", "=", validSessionId) - .where("id", "=", messageId) - .where("user_id", "=", userId) + .deleteFrom('session_chat') + .where('session_id', '=', validSessionId) + .where('id', '=', messageId) + .where('user_id', '=', userId) .executeTakeFirst(); void (async () => { const { invalidateSessionChatCache } = - await import("../realtime/chatCache.js"); + await import('../realtime/chatCache.js'); await invalidateSessionChatCache(validSessionId); })(); return (result?.numDeletedRows ?? 0) > 0; @@ -186,52 +186,52 @@ export async function reportChatMessage( const validSessionId = validateSessionId(sessionId); const messageRow = await mainDb - .selectFrom("session_chat") - .select(["user_id", "message"]) - .where("session_id", "=", validSessionId) - .where("id", "=", messageId) + .selectFrom('session_chat') + .select(['user_id', 'message']) + .where('session_id', '=', validSessionId) + .where('id', '=', messageId) .executeTakeFirst(); - if (!messageRow) throw new Error("Message not found"); + if (!messageRow) throw new Error('Message not found'); - let plainMessage = ""; + let plainMessage = ''; try { plainMessage = decrypt(JSON.parse(messageRow.message)); } catch { - plainMessage = ""; + plainMessage = ''; } - let reporterUsername = ""; - let reporterAvatar = "/assets/images/automod.webp"; - if (reporterUserId !== "automod") { + let reporterUsername = ''; + let reporterAvatar = '/assets/images/automod.webp'; + if (reporterUserId !== 'automod') { const reporter = await mainDb - .selectFrom("users") - .select(["username", "avatar"]) - .where("id", "=", reporterUserId) + .selectFrom('users') + .select(['username', 'avatar']) + .where('id', '=', reporterUserId) .executeTakeFirst(); - reporterUsername = reporter?.username || ""; + reporterUsername = reporter?.username || ''; reporterAvatar = reporter?.avatar - ? reporter.avatar.startsWith("http") + ? reporter.avatar.startsWith('http') ? reporter.avatar : `https://cdn.discordapp.com/avatars/${reporterUserId}/${reporter.avatar}.png` - : "/assets/app/default/avatar.webp"; + : '/assets/app/default/avatar.webp'; } const reportedUser = await mainDb - .selectFrom("users") - .select(["username", "avatar"]) - .where("id", "=", messageRow.user_id) + .selectFrom('users') + .select(['username', 'avatar']) + .where('id', '=', messageRow.user_id) .executeTakeFirst(); - const reportedUsername = reportedUser?.username || ""; + const reportedUsername = reportedUser?.username || ''; const reportedAvatar = reportedUser?.avatar - ? reportedUser.avatar.startsWith("http") + ? reportedUser.avatar.startsWith('http') ? reportedUser.avatar : `https://cdn.discordapp.com/avatars/${messageRow.user_id}/${reportedUser.avatar}.png` - : "/assets/app/default/avatar.webp"; + : '/assets/app/default/avatar.webp'; await mainDb - .insertInto("chat_report") + .insertInto('chat_report') .values({ id: sql`DEFAULT`, session_id: validSessionId, @@ -254,60 +254,60 @@ export async function reportGlobalChatMessage( reason: string ) { const messageRow = await mainDb - .selectFrom("global_chat") - .select(["user_id", "message"]) - .where("id", "=", messageId) + .selectFrom('global_chat') + .select(['user_id', 'message']) + .where('id', '=', messageId) .executeTakeFirst(); - if (!messageRow) throw new Error("Message not found"); + if (!messageRow) throw new Error('Message not found'); - let plainMessage = ""; + let plainMessage = ''; try { if (messageRow.message) { const encryptedData = - typeof messageRow.message === "string" + typeof messageRow.message === 'string' ? JSON.parse(messageRow.message) : messageRow.message; - plainMessage = decrypt(encryptedData) || ""; + plainMessage = decrypt(encryptedData) || ''; } } catch (e) { - console.error("[Report Global Chat] Error decrypting message:", e); + console.error('[Report Global Chat] Error decrypting message:', e); } - let reporterUsername = ""; - let reporterAvatar = "/assets/images/automod.webp"; - if (reporterUserId !== "automod") { + let reporterUsername = ''; + let reporterAvatar = '/assets/images/automod.webp'; + if (reporterUserId !== 'automod') { const reporter = await mainDb - .selectFrom("users") - .select(["username", "avatar"]) - .where("id", "=", reporterUserId) + .selectFrom('users') + .select(['username', 'avatar']) + .where('id', '=', reporterUserId) .executeTakeFirst(); - reporterUsername = reporter?.username || ""; + reporterUsername = reporter?.username || ''; reporterAvatar = reporter?.avatar - ? reporter.avatar.startsWith("http") + ? reporter.avatar.startsWith('http') ? reporter.avatar : `https://cdn.discordapp.com/avatars/${reporterUserId}/${reporter.avatar}.png` - : "/assets/app/default/avatar.webp"; + : '/assets/app/default/avatar.webp'; } const reportedUser = await mainDb - .selectFrom("users") - .select(["username", "avatar"]) - .where("id", "=", messageRow.user_id) + .selectFrom('users') + .select(['username', 'avatar']) + .where('id', '=', messageRow.user_id) .executeTakeFirst(); - const reportedUsername = reportedUser?.username || ""; + const reportedUsername = reportedUser?.username || ''; const reportedAvatar = reportedUser?.avatar - ? reportedUser.avatar.startsWith("http") + ? reportedUser.avatar.startsWith('http') ? reportedUser.avatar : `https://cdn.discordapp.com/avatars/${messageRow.user_id}/${reportedUser.avatar}.png` - : "/assets/app/default/avatar.webp"; + : '/assets/app/default/avatar.webp'; await mainDb - .insertInto("chat_report") + .insertInto('chat_report') .values({ id: sql`DEFAULT`, - session_id: "global-chat", + session_id: 'global-chat', message_id: messageId, reporter_user_id: reporterUserId, reporter_username: reporterUsername, @@ -319,4 +319,4 @@ export async function reportGlobalChatMessage( reporter_avatar: reporterAvatar, }) .execute(); -} \ No newline at end of file +} diff --git a/server/db/connection.ts b/server/db/connection.ts index 8acc74a7..01813c65 100644 --- a/server/db/connection.ts +++ b/server/db/connection.ts @@ -1,4 +1,4 @@ -import { Kysely, PostgresDialect } from "kysely"; +import { Kysely, PostgresDialect } from 'kysely'; import { createMainTables, ensureSessionsAdvancedAtcColumn, @@ -13,33 +13,33 @@ import { ensureWebsocketSnapshotsTable, ensurePerformanceIndexes, syncVersionFromEnv, -} from "./schemas.js"; -import pg from "pg"; -import Redis from "ioredis"; -import dotenv from "dotenv"; +} from './schemas.js'; +import pg from 'pg'; +import Redis from 'ioredis'; +import dotenv from 'dotenv'; dotenv.config({ path: - process.env.NODE_ENV === "production" - ? ".env.production" - : process.env.NODE_ENV === "canary" - ? ".env.canary" - : ".env.development", + process.env.NODE_ENV === 'production' + ? '.env.production' + : process.env.NODE_ENV === 'canary' + ? '.env.canary' + : '.env.development', }); -import type { MainDatabase } from "./types/connection/MainDatabase.js"; +import type { MainDatabase } from './types/connection/MainDatabase.js'; function getSSLConfig(connectionString: string) { const url = new URL(connectionString); const isLocalhost = - url.hostname === "localhost" || - url.hostname === "127.0.0.1" || - url.hostname === "postgres"; + url.hostname === 'localhost' || + url.hostname === '127.0.0.1' || + url.hostname === 'postgres'; return isLocalhost ? false : { rejectUnauthorized: false }; } const dbUrl = process.env.POSTGRES_DB_URL; -if (!dbUrl) throw new Error("POSTGRES_DB_URL is not defined"); +if (!dbUrl) throw new Error('POSTGRES_DB_URL is not defined'); export const mainDb = new Kysely({ dialect: new PostgresDialect({ @@ -51,16 +51,16 @@ export const mainDb = new Kysely({ }); if (!process.env.REDIS_URL) { - throw new Error("REDIS_URL is not defined in environment variables"); + throw new Error('REDIS_URL is not defined in environment variables'); } export const redisConnection = new Redis(process.env.REDIS_URL as string); -redisConnection.on("error", (err) => { - console.error("[Redis] Connection error:", err.message); +redisConnection.on('error', (err) => { + console.error('[Redis] Connection error:', err.message); }); -redisConnection.on("connect", () => { - console.log("[Redis] Connected successfully"); +redisConnection.on('connect', () => { + console.log('[Redis] Connected successfully'); }); try { @@ -77,8 +77,8 @@ try { await ensureWebsocketSnapshotsTable(); await ensurePerformanceIndexes(); await syncVersionFromEnv(redisConnection); - console.log("[Database] Tables initialized successfully"); + console.log('[Database] Tables initialized successfully'); } catch (err) { - console.error("Failed to create tables:", err); + console.error('Failed to create tables:', err); process.exit(1); -} \ No newline at end of file +} diff --git a/server/db/databaseMetrics.ts b/server/db/databaseMetrics.ts index dba85a3a..9fa62c2a 100644 --- a/server/db/databaseMetrics.ts +++ b/server/db/databaseMetrics.ts @@ -1,38 +1,38 @@ -import { sql } from "kysely"; -import { mainDb } from "./connection.js"; +import { sql } from 'kysely'; +import { mainDb } from './connection.js'; export const TRACKED_ACTIVITY_TABLES = [ - "users", - "sessions", - "flights", - "flight_logs", - "api_logs", - "audit_log", - "developer_api_usage", - "websocket_snapshots", - "daily_statistics", - "session_chat", - "global_chat", - "feedback", - "chat_report", + 'users', + 'sessions', + 'flights', + 'flight_logs', + 'api_logs', + 'audit_log', + 'developer_api_usage', + 'websocket_snapshots', + 'daily_statistics', + 'session_chat', + 'global_chat', + 'feedback', + 'chat_report', ] as const; export type TrackedActivityTable = (typeof TRACKED_ACTIVITY_TABLES)[number]; const TABLE_INSERT_TIME_COLUMN: Record = { - users: "created_at", - sessions: "created_at", - flights: "created_at", - flight_logs: "created_at", - api_logs: "created_at", - audit_log: "created_at", - developer_api_usage: "created_at", - websocket_snapshots: "sampled_at", - daily_statistics: "created_at", - session_chat: "sent_at", - global_chat: "sent_at", - feedback: "created_at", - chat_report: "created_at", + users: 'created_at', + sessions: 'created_at', + flights: 'created_at', + flight_logs: 'created_at', + api_logs: 'created_at', + audit_log: 'created_at', + developer_api_usage: 'created_at', + websocket_snapshots: 'sampled_at', + daily_statistics: 'created_at', + session_chat: 'sent_at', + global_chat: 'sent_at', + feedback: 'created_at', + chat_report: 'created_at', }; const trackedSet = new Set(TRACKED_ACTIVITY_TABLES); @@ -157,19 +157,19 @@ async function getActivityRow( row_count: number; } | null> { const row = await mainDb - .selectFrom("daily_table_activity") - .select(["rows_inserted", "rows_deleted", "table_bytes", "row_count"]) - .where("activity_date", "=", activityDate) - .where("table_name", "=", tableName) + .selectFrom('daily_table_activity') + .select(['rows_inserted', 'rows_deleted', 'table_bytes', 'row_count']) + .where('activity_date', '=', activityDate) + .where('table_name', '=', tableName) .executeTakeFirst(); return row ?? null; } export async function hasDailyTotals(activityDate: Date): Promise { const row = await mainDb - .selectFrom("daily_database_totals") - .select("activity_date") - .where("activity_date", "=", activityDate) + .selectFrom('daily_database_totals') + .select('activity_date') + .where('activity_date', '=', activityDate) .executeTakeFirst(); return !!row; } @@ -240,13 +240,13 @@ export async function captureDailyMetrics( if (finalize) { await mainDb - .insertInto("daily_database_totals") + .insertInto('daily_database_totals') .values({ activity_date: activityDate, total_bytes: totalBytes, }) .onConflict((oc) => - oc.column("activity_date").doUpdateSet({ total_bytes: totalBytes }) + oc.column('activity_date').doUpdateSet({ total_bytes: totalBytes }) ) .execute(); } @@ -264,8 +264,8 @@ export async function finalizeYesterdayIfNeeded(): Promise { export async function backfillRecentActivity(days = 7): Promise { const count = await mainDb - .selectFrom("daily_database_totals") - .select(sql`COUNT(*)::int`.as("cnt")) + .selectFrom('daily_database_totals') + .select(sql`COUNT(*)::int`.as('cnt')) .executeTakeFirst(); if (Number(count?.cnt ?? 0) > 0) return; @@ -304,16 +304,16 @@ export async function getActivitySummary(): Promise<{ }; const rows = await mainDb - .selectFrom("daily_table_activity") + .selectFrom('daily_table_activity') .select([ - "activity_date", - "table_name", - "rows_inserted", - "rows_deleted", - "table_bytes", - "row_count", + 'activity_date', + 'table_name', + 'rows_inserted', + 'rows_deleted', + 'table_bytes', + 'row_count', ]) - .where("activity_date", "in", [today, yesterday]) + .where('activity_date', 'in', [today, yesterday]) .execute(); const byDateTable = new Map(); @@ -348,10 +348,10 @@ export async function getDailyTotalsHistory( ): Promise> { const since = addUtcDays(utcDateOnly(new Date()), -days); const rows = await mainDb - .selectFrom("daily_database_totals") - .select(["activity_date", "total_bytes"]) - .where("activity_date", ">=", since) - .orderBy("activity_date", "asc") + .selectFrom('daily_database_totals') + .select(['activity_date', 'total_bytes']) + .where('activity_date', '>=', since) + .orderBy('activity_date', 'asc') .execute(); return rows.map((r) => ({ @@ -371,16 +371,16 @@ export async function getTableActivityHistory(days: number): Promise< > { const since = addUtcDays(utcDateOnly(new Date()), -days); const rows = await mainDb - .selectFrom("daily_table_activity") + .selectFrom('daily_table_activity') .select([ - "activity_date", - "table_name", - "rows_inserted", - "rows_deleted", - "table_bytes", + 'activity_date', + 'table_name', + 'rows_inserted', + 'rows_deleted', + 'table_bytes', ]) - .where("activity_date", ">=", since) - .orderBy("activity_date", "asc") + .where('activity_date', '>=', since) + .orderBy('activity_date', 'asc') .execute(); return rows.map((r) => ({ @@ -405,7 +405,7 @@ export function startDatabaseMetricsCapture(): void { await finalizeYesterdayIfNeeded(); await refreshTodayMetrics(); } catch (error) { - console.error("[databaseMetrics] initial capture failed:", error); + console.error('[databaseMetrics] initial capture failed:', error); } })(); }, 90_000); @@ -413,7 +413,7 @@ export function startDatabaseMetricsCapture(): void { metricsInterval = setInterval( () => { void refreshTodayMetrics().catch((error) => { - console.error("[databaseMetrics] refresh today failed:", error); + console.error('[databaseMetrics] refresh today failed:', error); }); }, 6 * 60 * 60 * 1000 @@ -422,7 +422,7 @@ export function startDatabaseMetricsCapture(): void { metricsFinalizeInterval = setInterval( () => { void finalizeYesterdayIfNeeded().catch((error) => { - console.error("[databaseMetrics] finalize yesterday failed:", error); + console.error('[databaseMetrics] finalize yesterday failed:', error); }); }, 24 * 60 * 60 * 1000 @@ -438,4 +438,4 @@ export function stopDatabaseMetricsCapture(): void { clearInterval(metricsFinalizeInterval); metricsFinalizeInterval = null; } -} \ No newline at end of file +} diff --git a/server/db/databaseProjection.ts b/server/db/databaseProjection.ts index 73eeba5d..554cda7b 100644 --- a/server/db/databaseProjection.ts +++ b/server/db/databaseProjection.ts @@ -1,15 +1,15 @@ -import { sql } from "kysely"; -import { mainDb } from "./connection.js"; +import { sql } from 'kysely'; +import { mainDb } from './connection.js'; import { DATABASE_RETENTION_POLICIES, RETENTION_DAYS_BY_TABLE, -} from "./databaseRetention.js"; +} from './databaseRetention.js'; import { getDailyTotalsHistory, getTableActivityHistory, type TrackedActivityTable, TRACKED_ACTIVITY_TABLES, -} from "./databaseMetrics.js"; +} from './databaseMetrics.js'; type TableSizeInput = { name: string; @@ -29,13 +29,13 @@ type DailyStatRow = { const STAT_TABLE_MAP: Array<{ stat: keyof Pick< DailyStatRow, - "new_users_count" | "new_sessions_count" | "new_flights_count" + 'new_users_count' | 'new_sessions_count' | 'new_flights_count' >; table: TrackedActivityTable; }> = [ - { stat: "new_users_count", table: "users" }, - { stat: "new_sessions_count", table: "sessions" }, - { stat: "new_flights_count", table: "flights" }, + { stat: 'new_users_count', table: 'users' }, + { stat: 'new_sessions_count', table: 'sessions' }, + { stat: 'new_flights_count', table: 'flights' }, ]; function avg(nums: number[]): number { @@ -51,16 +51,16 @@ function bytesPerRow(bytes: number, rows: number): number { async function getRecentDailyStatistics(days: number): Promise { const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); return mainDb - .selectFrom("daily_statistics") + .selectFrom('daily_statistics') .select([ - "date", - mainDb.fn.coalesce("logins_count", sql`0`).as("logins_count"), - mainDb.fn.coalesce("new_sessions_count", sql`0`).as("new_sessions_count"), - mainDb.fn.coalesce("new_flights_count", sql`0`).as("new_flights_count"), - mainDb.fn.coalesce("new_users_count", sql`0`).as("new_users_count"), + 'date', + mainDb.fn.coalesce('logins_count', sql`0`).as('logins_count'), + mainDb.fn.coalesce('new_sessions_count', sql`0`).as('new_sessions_count'), + mainDb.fn.coalesce('new_flights_count', sql`0`).as('new_flights_count'), + mainDb.fn.coalesce('new_users_count', sql`0`).as('new_users_count'), ]) - .where("date", ">=", since) - .orderBy("date", "asc") + .where('date', '>=', since) + .orderBy('date', 'asc') .execute(); } @@ -104,7 +104,7 @@ export async function buildDatabaseProjection( } let logsPerFlight = 8; - const flightLogActivity = activityByTable.get("flight_logs") ?? []; + const flightLogActivity = activityByTable.get('flight_logs') ?? []; const flightStatSum = dailyStats.reduce( (s, d) => s + Number(d.new_flights_count ?? 0), 0 @@ -141,7 +141,7 @@ export async function buildDatabaseProjection( continue; } - if (tableName === "flight_logs" && dailyStats.length > 0) { + if (tableName === 'flight_logs' && dailyStats.length > 0) { const flightsAvg = avg( dailyStats.map((d) => Number(d.new_flights_count ?? 0)) ); @@ -168,24 +168,24 @@ export async function buildDatabaseProjection( if (netFromTotals.length >= 7) { dailyNetGrowthBytes = measuredDailyNet; methodology = - "30-day forecast from measured daily database size changes (last 7+ days), blended with per-table insert/delete activity."; + '30-day forecast from measured daily database size changes (last 7+ days), blended with per-table insert/delete activity.'; } else if (activityNetSum !== 0 && activityHistory.length >= 14) { dailyNetGrowthBytes = activityNetSum; methodology = - "30-day forecast from per-table daily insert/delete history and row-size estimates."; + '30-day forecast from per-table daily insert/delete history and row-size estimates.'; } else { const statDriven = [...tableDailyNet.values()].reduce((s, v) => s + v, 0); dailyNetGrowthBytes = statDriven !== 0 ? statDriven : totalBytes * 0.001; methodology = statDriven !== 0 - ? "30-day forecast from admin daily statistics (users, sessions, flights) and table activity, with retention adjustments." - : "Limited history; using conservative 0.1% daily growth estimate until metrics accumulate."; + ? '30-day forecast from admin daily statistics (users, sessions, flights) and table activity, with retention adjustments.' + : 'Limited history; using conservative 0.1% daily growth estimate until metrics accumulate.'; } if (netFromTotals.length >= 3 && activityNetSum !== 0) { dailyNetGrowthBytes = measuredDailyNet * 0.6 + activityNetSum * 0.4; methodology = - "Blended forecast: 60% measured total DB delta, 40% per-table activity and statistics."; + 'Blended forecast: 60% measured total DB delta, 40% per-table activity and statistics.'; } dailyNetGrowthBytes = Math.max(0, dailyNetGrowthBytes); @@ -224,4 +224,4 @@ export async function buildDatabaseProjection( }; } -export { DATABASE_RETENTION_POLICIES }; \ No newline at end of file +export { DATABASE_RETENTION_POLICIES }; diff --git a/server/db/databaseRetention.ts b/server/db/databaseRetention.ts index f463e0d0..48b17dc0 100644 --- a/server/db/databaseRetention.ts +++ b/server/db/databaseRetention.ts @@ -3,22 +3,22 @@ export const DATABASE_RETENTION_POLICIES: Array<{ retentionDays: number; label: string; }> = [ - { table: "daily_statistics", retentionDays: 365, label: "Daily statistics" }, - { table: "api_logs", retentionDays: 1, label: "API logs" }, + { table: 'daily_statistics', retentionDays: 365, label: 'Daily statistics' }, + { table: 'api_logs', retentionDays: 1, label: 'API logs' }, { - table: "websocket_snapshots", + table: 'websocket_snapshots', retentionDays: 1, - label: "WebSocket snapshots", + label: 'WebSocket snapshots', }, - { table: "audit_log", retentionDays: 14, label: "Audit log" }, - { table: "flight_logs", retentionDays: 365, label: "Flight logs" }, + { table: 'audit_log', retentionDays: 14, label: 'Audit log' }, + { table: 'flight_logs', retentionDays: 365, label: 'Flight logs' }, { - table: "developer_api_usage", + table: 'developer_api_usage', retentionDays: 90, - label: "Developer API usage", + label: 'Developer API usage', }, ]; export const RETENTION_DAYS_BY_TABLE = new Map( DATABASE_RETENTION_POLICIES.map((p) => [p.table, p.retentionDays]) -); \ No newline at end of file +); diff --git a/server/db/developer.ts b/server/db/developer.ts index 1917dfb8..7a635384 100644 --- a/server/db/developer.ts +++ b/server/db/developer.ts @@ -1,26 +1,26 @@ -import { mainDb } from "./connection.js"; -import { sql } from "kysely"; -import { recordTableDeletes } from "./databaseMetrics.js"; +import { mainDb } from './connection.js'; +import { sql } from 'kysely'; +import { recordTableDeletes } from './databaseMetrics.js'; function parseStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; - return value.filter((x): x is string => typeof x === "string"); + return value.filter((x): x is string => typeof x === 'string'); } export async function getDeveloperProfile(userId: string) { return mainDb - .selectFrom("developer_profiles") + .selectFrom('developer_profiles') .selectAll() - .where("user_id", "=", userId) + .where('user_id', '=', userId) .executeTakeFirst(); } export async function getLatestDeveloperApplication(userId: string) { return mainDb - .selectFrom("developer_applications") + .selectFrom('developer_applications') .selectAll() - .where("user_id", "=", userId) - .orderBy("created_at", "desc") + .where('user_id', '=', userId) + .orderBy('created_at', 'desc') .executeTakeFirst(); } @@ -31,14 +31,14 @@ export async function createDeveloperApplication(input: { requestedScopes: string[]; }) { return mainDb - .insertInto("developer_applications") + .insertInto('developer_applications') .values({ id: sql`DEFAULT`, user_id: input.userId, who_text: input.whoText, why_text: input.whyText, requested_scopes: sql`CAST(${JSON.stringify(input.requestedScopes)} AS jsonb)`, - status: "pending", + status: 'pending', created_at: new Date(), updated_at: new Date(), }) @@ -48,13 +48,13 @@ export async function createDeveloperApplication(input: { export async function updateApplicationReview(input: { applicationId: number; - status: "approved" | "rejected"; + status: 'approved' | 'rejected'; reviewedBy: string; reviewerNote: string | null; approvedScopes: string[] | null; }) { return mainDb - .updateTable("developer_applications") + .updateTable('developer_applications') .set({ status: input.status, reviewed_by: input.reviewedBy, @@ -66,7 +66,7 @@ export async function updateApplicationReview(input: { : null, updated_at: new Date(), }) - .where("id", "=", input.applicationId) + .where('id', '=', input.applicationId) .returningAll() .executeTakeFirst(); } @@ -74,10 +74,10 @@ export async function updateApplicationReview(input: { export async function upsertDeveloperProfile(input: { userId: string; approvedScopes: string[]; - status?: "active" | "suspended"; + status?: 'active' | 'suspended'; defaultRateLimitPerMinute?: number | null; }) { - const status = input.status ?? "active"; + const status = input.status ?? 'active'; const insertRpm = input.defaultRateLimitPerMinute !== undefined ? input.defaultRateLimitPerMinute @@ -93,7 +93,7 @@ export async function upsertDeveloperProfile(input: { }; return mainDb - .insertInto("developer_profiles") + .insertInto('developer_profiles') .values({ user_id: input.userId, approved_scopes: sql`CAST(${JSON.stringify(input.approvedScopes)} AS jsonb)`, @@ -104,7 +104,7 @@ export async function upsertDeveloperProfile(input: { created_at: new Date(), updated_at: new Date(), }) - .onConflict((oc) => oc.column("user_id").doUpdateSet(updatePatch)) + .onConflict((oc) => oc.column('user_id').doUpdateSet(updatePatch)) .returningAll() .executeTakeFirst(); } @@ -123,7 +123,7 @@ export async function bumpDeveloperAdminNoticeSeq( const text = clipped.length > 0 ? clipped - : "An administrator updated your developer settings."; + : 'An administrator updated your developer settings.'; await sql` UPDATE developer_profiles SET @@ -147,24 +147,24 @@ export async function updateDeveloperProfileApprovedScopes( approvedScopes: string[] ) { return mainDb - .updateTable("developer_profiles") + .updateTable('developer_profiles') .set({ approved_scopes: sql`CAST(${JSON.stringify(approvedScopes)} AS jsonb)`, updated_at: new Date(), }) - .where("user_id", "=", userId) + .where('user_id', '=', userId) .returningAll() .executeTakeFirst(); } export async function setDeveloperProfileStatus( userId: string, - status: "active" | "suspended" + status: 'active' | 'suspended' ) { return mainDb - .updateTable("developer_profiles") + .updateTable('developer_profiles') .set({ status, updated_at: new Date() }) - .where("user_id", "=", userId) + .where('user_id', '=', userId) .returningAll() .executeTakeFirst(); } @@ -174,12 +174,12 @@ export async function updateDeveloperNotificationEmail( email: string | null ) { return mainDb - .updateTable("developer_profiles") + .updateTable('developer_profiles') .set({ notification_email: email, updated_at: new Date(), }) - .where("user_id", "=", userId) + .where('user_id', '=', userId) .returningAll() .executeTakeFirst(); } @@ -191,18 +191,18 @@ export async function listDeveloperApplications(filters: { }) { const offset = (filters.page - 1) * filters.limit; let q = mainDb - .selectFrom("developer_applications") + .selectFrom('developer_applications') .selectAll() - .orderBy("created_at", "desc"); + .orderBy('created_at', 'desc'); if (filters.status) { - q = q.where("status", "=", filters.status); + q = q.where('status', '=', filters.status); } const rows = await q.limit(filters.limit).offset(offset).execute(); let countQ = mainDb - .selectFrom("developer_applications") - .select(sql`count(*)::int`.as("c")); + .selectFrom('developer_applications') + .select(sql`count(*)::int`.as('c')); if (filters.status) { - countQ = countQ.where("status", "=", filters.status); + countQ = countQ.where('status', '=', filters.status); } const countRow = await countQ.executeTakeFirst(); return { applications: rows, total: Number(countRow?.c ?? 0) }; @@ -210,50 +210,50 @@ export async function listDeveloperApplications(filters: { export async function getDeveloperApplicationById(id: number) { return mainDb - .selectFrom("developer_applications") + .selectFrom('developer_applications') .selectAll() - .where("id", "=", id) + .where('id', '=', id) .executeTakeFirst(); } export async function listDeveloperKeysForUser(userId: string) { return mainDb - .selectFrom("developer_api_keys") + .selectFrom('developer_api_keys') .select([ - "id", - "user_id", - "name", - "prefix", - "scopes", - "status", - "requested_scopes", - "rate_limit_per_minute", - "reviewed_at", - "reviewer_note", - "created_at", - "last_used_at", - "revoked_at", + 'id', + 'user_id', + 'name', + 'prefix', + 'scopes', + 'status', + 'requested_scopes', + 'rate_limit_per_minute', + 'reviewed_at', + 'reviewer_note', + 'created_at', + 'last_used_at', + 'revoked_at', ]) - .where("user_id", "=", userId) - .orderBy("created_at", "desc") + .where('user_id', '=', userId) + .orderBy('created_at', 'desc') .execute(); } export async function listDeveloperApiKeysForAdmin(userId: string) { return mainDb - .selectFrom("developer_api_keys") + .selectFrom('developer_api_keys') .selectAll() - .where("user_id", "=", userId) - .orderBy("created_at", "desc") + .where('user_id', '=', userId) + .orderBy('created_at', 'desc') .execute(); } export async function getDeveloperApiKeyForUser(keyId: string, userId: string) { return mainDb - .selectFrom("developer_api_keys") + .selectFrom('developer_api_keys') .selectAll() - .where("id", "=", keyId) - .where("user_id", "=", userId) + .where('id', '=', keyId) + .where('user_id', '=', userId) .executeTakeFirst(); } @@ -266,14 +266,14 @@ export async function createDeveloperApiKey(input: { rateLimitPerMinute?: number | null; }) { return mainDb - .insertInto("developer_api_keys") + .insertInto('developer_api_keys') .values({ user_id: input.userId, name: input.name, prefix: input.prefix, secret_hash: input.secretHash, scopes: sql`CAST(${JSON.stringify(input.scopes)} AS jsonb)`, - status: "active", + status: 'active', requested_scopes: null, rate_limit_per_minute: input.rateLimitPerMinute ?? null, created_at: new Date(), @@ -289,14 +289,14 @@ export async function insertPendingDeveloperApiKey(input: { requestedScopes: string[]; }) { return mainDb - .insertInto("developer_api_keys") + .insertInto('developer_api_keys') .values({ user_id: input.userId, name: input.name, prefix: input.prefix, secret_hash: null, scopes: sql`'[]'::jsonb`, - status: "pending", + status: 'pending', requested_scopes: sql`CAST(${JSON.stringify(input.requestedScopes)} AS jsonb)`, created_at: new Date(), }) @@ -315,9 +315,9 @@ export async function approvePendingDeveloperApiKey(input: { reviewerNote: string | null; }) { return mainDb - .updateTable("developer_api_keys") + .updateTable('developer_api_keys') .set({ - status: "active", + status: 'active', scopes: sql`CAST(${JSON.stringify(input.approvedScopes)} AS jsonb)`, requested_scopes: null, prefix: input.prefix, @@ -327,10 +327,10 @@ export async function approvePendingDeveloperApiKey(input: { reviewed_at: new Date(), reviewer_note: input.reviewerNote, }) - .where("id", "=", input.keyId) - .where("user_id", "=", input.userId) - .where("status", "=", "pending") - .where("revoked_at", "is", null) + .where('id', '=', input.keyId) + .where('user_id', '=', input.userId) + .where('status', '=', 'pending') + .where('revoked_at', 'is', null) .returningAll() .executeTakeFirst(); } @@ -342,17 +342,17 @@ export async function rejectPendingDeveloperApiKey(input: { reviewerNote: string | null; }) { return mainDb - .updateTable("developer_api_keys") + .updateTable('developer_api_keys') .set({ - status: "rejected", + status: 'rejected', reviewed_by: input.reviewedBy, reviewed_at: new Date(), reviewer_note: input.reviewerNote, }) - .where("id", "=", input.keyId) - .where("user_id", "=", input.userId) - .where("status", "=", "pending") - .where("revoked_at", "is", null) + .where('id', '=', input.keyId) + .where('user_id', '=', input.userId) + .where('status', '=', 'pending') + .where('revoked_at', 'is', null) .returningAll() .executeTakeFirst(); } @@ -364,26 +364,26 @@ export async function updateDeveloperApiKeyScopesAndRate(input: { rateLimitPerMinute: number | null; }) { return mainDb - .updateTable("developer_api_keys") + .updateTable('developer_api_keys') .set({ scopes: sql`CAST(${JSON.stringify(input.scopes)} AS jsonb)`, rate_limit_per_minute: input.rateLimitPerMinute, }) - .where("id", "=", input.keyId) - .where("user_id", "=", input.userId) - .where("status", "=", "active") - .where("revoked_at", "is", null) + .where('id', '=', input.keyId) + .where('user_id', '=', input.userId) + .where('status', '=', 'active') + .where('revoked_at', 'is', null) .returningAll() .executeTakeFirst(); } export async function revokeDeveloperApiKey(keyId: string, userId: string) { return mainDb - .updateTable("developer_api_keys") + .updateTable('developer_api_keys') .set({ revoked_at: new Date() }) - .where("id", "=", keyId) - .where("user_id", "=", userId) - .where("revoked_at", "is", null) + .where('id', '=', keyId) + .where('user_id', '=', userId) + .where('revoked_at', 'is', null) .returningAll() .executeTakeFirst(); } @@ -393,10 +393,10 @@ export async function deleteRevokedDeveloperApiKey( userId: string ) { return mainDb - .deleteFrom("developer_api_keys") - .where("id", "=", keyId) - .where("user_id", "=", userId) - .where("revoked_at", "is not", null) + .deleteFrom('developer_api_keys') + .where('id', '=', keyId) + .where('user_id', '=', userId) + .where('revoked_at', 'is not', null) .returningAll() .executeTakeFirst(); } @@ -408,25 +408,25 @@ export async function rotateDeveloperApiKey(input: { secretHash: string; }) { return mainDb - .updateTable("developer_api_keys") + .updateTable('developer_api_keys') .set({ prefix: input.prefix, secret_hash: input.secretHash, }) - .where("id", "=", input.keyId) - .where("user_id", "=", input.userId) - .where("status", "=", "active") - .where("secret_hash", "is not", null) - .where("revoked_at", "is", null) + .where('id', '=', input.keyId) + .where('user_id', '=', input.userId) + .where('status', '=', 'active') + .where('secret_hash', 'is not', null) + .where('revoked_at', 'is', null) .returningAll() .executeTakeFirst(); } export async function touchDeveloperApiKeyLastUsed(keyId: string) { await mainDb - .updateTable("developer_api_keys") + .updateTable('developer_api_keys') .set({ last_used_at: new Date() }) - .where("id", "=", keyId) + .where('id', '=', keyId) .execute(); } @@ -458,19 +458,19 @@ export async function findActiveDeveloperKeyBySecretHash( secretHash: string ): Promise { const key = await mainDb - .selectFrom("developer_api_keys") + .selectFrom('developer_api_keys') .selectAll() - .where("secret_hash", "=", secretHash) - .where("status", "=", "active") - .where("revoked_at", "is", null) + .where('secret_hash', '=', secretHash) + .where('status', '=', 'active') + .where('revoked_at', 'is', null) .executeTakeFirst(); if (!key || key.secret_hash == null) return null; const profile = await mainDb - .selectFrom("developer_profiles") + .selectFrom('developer_profiles') .selectAll() - .where("user_id", "=", key.user_id) + .where('user_id', '=', key.user_id) .executeTakeFirst(); - if (!profile || profile.status !== "active") return null; + if (!profile || profile.status !== 'active') return null; const profileApprovedScopes = parseStringArray(profile.approved_scopes); const keyScopes = parseStringArray(key.scopes); const allowed = new Set(profileApprovedScopes); @@ -495,7 +495,7 @@ export async function insertDeveloperApiUsage(input: { }) { try { await mainDb - .insertInto("developer_api_usage") + .insertInto('developer_api_usage') .values({ key_id: input.keyId, user_id: input.userId, @@ -510,23 +510,23 @@ export async function insertDeveloperApiUsage(input: { }) .execute(); } catch (e) { - console.error("[developer_api_usage] insert failed:", e); + console.error('[developer_api_usage] insert failed:', e); } } export async function listApprovedDevelopersSummary() { const profiles = await mainDb - .selectFrom("developer_profiles") + .selectFrom('developer_profiles') .selectAll() .execute(); const keys = await mainDb - .selectFrom("developer_api_keys") - .select(["user_id", "id", "revoked_at", "status", "secret_hash"]) + .selectFrom('developer_api_keys') + .select(['user_id', 'id', 'revoked_at', 'status', 'secret_hash']) .execute(); const lastUsage = await mainDb - .selectFrom("developer_api_usage") - .select(["user_id", sql`max(created_at)`.as("last_at")]) - .groupBy("user_id") + .selectFrom('developer_api_usage') + .select(['user_id', sql`max(created_at)`.as('last_at')]) + .groupBy('user_id') .execute(); const lastByUser = new Map(lastUsage.map((r) => [r.user_id, r.last_at])); const keyCounts = new Map< @@ -537,8 +537,8 @@ export async function listApprovedDevelopersSummary() { const cur = keyCounts.get(k.user_id) ?? { usable: 0, total: 0, pending: 0 }; cur.total += 1; if (!k.revoked_at) { - if (k.status === "pending") cur.pending += 1; - if (k.status === "active" && k.secret_hash != null) cur.usable += 1; + if (k.status === 'pending') cur.pending += 1; + if (k.status === 'active' && k.secret_hash != null) cur.usable += 1; } keyCounts.set(k.user_id, cur); } @@ -563,15 +563,15 @@ export async function cleanupOldDeveloperUsage( cutoff.setDate(cutoff.getDate() - daysToKeep); try { const result = await mainDb - .deleteFrom("developer_api_usage") - .where("created_at", "<", cutoff) + .deleteFrom('developer_api_usage') + .where('created_at', '<', cutoff) .executeTakeFirst(); await recordTableDeletes( - "developer_api_usage", + 'developer_api_usage', Number(result?.numDeletedRows ?? 0) ); } catch (e) { - console.error("[cleanupOldDeveloperUsage]", e); + console.error('[cleanupOldDeveloperUsage]', e); } } @@ -582,17 +582,17 @@ export async function deleteDeveloperAllDataForUser( if (!profile) return false; await mainDb.transaction().execute(async (trx) => { await trx - .deleteFrom("developer_api_keys") - .where("user_id", "=", userId) + .deleteFrom('developer_api_keys') + .where('user_id', '=', userId) .execute(); await trx - .deleteFrom("developer_applications") - .where("user_id", "=", userId) + .deleteFrom('developer_applications') + .where('user_id', '=', userId) .execute(); await trx - .deleteFrom("developer_profiles") - .where("user_id", "=", userId) + .deleteFrom('developer_profiles') + .where('user_id', '=', userId) .execute(); }); return true; -} \ No newline at end of file +} diff --git a/server/db/developerDashboard.ts b/server/db/developerDashboard.ts index cb74475a..cd69fcaf 100644 --- a/server/db/developerDashboard.ts +++ b/server/db/developerDashboard.ts @@ -1,7 +1,10 @@ -import { mainDb } from "./connection.js"; -import { sql } from "kysely"; +import { mainDb } from './connection.js'; +import { sql } from 'kysely'; -export async function getDeveloperUsageDailyCounts(userId: string, since: Date) { +export async function getDeveloperUsageDailyCounts( + userId: string, + since: Date +) { const result = await sql<{ date: string; count: number }>` SELECT to_char(day_bucket, 'YYYY-MM-DD') AS date, @@ -27,7 +30,10 @@ export async function getDeveloperUsageDailyCounts(userId: string, since: Date) })); } -export async function getDeveloperUsageHourlyCounts(userId: string, since: Date) { +export async function getDeveloperUsageHourlyCounts( + userId: string, + since: Date +) { const result = await sql<{ date: string; count: number }>` SELECT to_char(hour_bucket, 'YYYY-MM-DD"T"HH24:00:00') AS date, @@ -55,22 +61,26 @@ export async function getDeveloperUsageHourlyCounts(userId: string, since: Date) export async function getDeveloperUsageByScope(userId: string, since: Date) { return mainDb - .selectFrom("developer_api_usage") - .select(["scope_id", sql`count(*)::int`.as("count")]) - .where("user_id", "=", userId) - .where("created_at", ">=", since) - .groupBy("scope_id") - .orderBy("count", "desc") + .selectFrom('developer_api_usage') + .select(['scope_id', sql`count(*)::int`.as('count')]) + .where('user_id', '=', userId) + .where('created_at', '>=', since) + .groupBy('scope_id') + .orderBy('count', 'desc') .execute(); } -export async function getDeveloperRecentUsage(userId: string, limit: number, offset: number) { +export async function getDeveloperRecentUsage( + userId: string, + limit: number, + offset: number +) { return mainDb - .selectFrom("developer_api_usage") + .selectFrom('developer_api_usage') .selectAll() - .where("user_id", "=", userId) - .orderBy("created_at", "desc") + .where('user_id', '=', userId) + .orderBy('created_at', 'desc') .limit(limit) .offset(offset) .execute(); -} \ No newline at end of file +} diff --git a/server/db/flightLogs.ts b/server/db/flightLogs.ts index 3f71b6cb..a624fedb 100644 --- a/server/db/flightLogs.ts +++ b/server/db/flightLogs.ts @@ -1,7 +1,7 @@ -import { mainDb } from "./connection.js"; -import { recordTableDeletes } from "./databaseMetrics.js"; -import { encrypt, decrypt } from "../utils/encryption.js"; -import { sql } from "kysely"; +import { mainDb } from './connection.js'; +import { recordTableDeletes } from './databaseMetrics.js'; +import { encrypt, decrypt } from '../utils/encryption.js'; +import { sql } from 'kysely'; const FLIGHT_LOG_RETENTION_DAYS = 365; @@ -9,7 +9,7 @@ export interface FlightLogData { userId: string; username: string; sessionId: string; - action: "add" | "update" | "delete"; + action: 'add' | 'update' | 'delete'; flightId: string; oldData?: object | null; newData?: object | null; @@ -19,12 +19,12 @@ export interface FlightLogData { export async function getFlightLogsCount(): Promise { try { const row = await mainDb - .selectFrom("flight_logs") - .select(({ fn }) => fn.countAll().as("count")) + .selectFrom('flight_logs') + .select(({ fn }) => fn.countAll().as('count')) .executeTakeFirst(); return Number(row?.count) || 0; } catch (error) { - console.error("Error counting flight logs:", error); + console.error('Error counting flight logs:', error); throw error; } } @@ -44,7 +44,7 @@ export async function logFlightAction(logData: FlightLogData) { try { const encryptedIP = ipAddress ? JSON.stringify(encrypt(ipAddress)) : null; await mainDb - .insertInto("flight_logs") + .insertInto('flight_logs') .values({ id: sql`DEFAULT`, user_id: userId, @@ -59,7 +59,7 @@ export async function logFlightAction(logData: FlightLogData) { }) .execute(); } catch (error) { - console.error("Error logging flight action:", error); + console.error('Error logging flight action:', error); } } @@ -68,19 +68,19 @@ export async function cleanupOldFlightLogs( ) { try { const result = await mainDb - .deleteFrom("flight_logs") + .deleteFrom('flight_logs') .where( - "created_at", - "<", + 'created_at', + '<', new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000) ) .executeTakeFirst(); const deleted = Number(result?.numDeletedRows ?? 0); - await recordTableDeletes("flight_logs", deleted); + await recordTableDeletes('flight_logs', deleted); return deleted; } catch (error) { - console.error("Error cleaning up flight logs:", error); + console.error('Error cleaning up flight logs:', error); throw error; } } @@ -94,7 +94,7 @@ export function startFlightLogsCleanup() { try { await cleanupOldFlightLogs(FLIGHT_LOG_RETENTION_DAYS); } catch (error) { - console.error("Initial flight logs cleanup failed:", error); + console.error('Initial flight logs cleanup failed:', error); } }, 60 * 1000); @@ -102,7 +102,7 @@ export function startFlightLogsCleanup() { try { await cleanupOldFlightLogs(FLIGHT_LOG_RETENTION_DAYS); } catch (error) { - console.error("Scheduled flight logs cleanup failed:", error); + console.error('Scheduled flight logs cleanup failed:', error); } }, CLEANUP_INTERVAL); } @@ -117,7 +117,7 @@ export function stopFlightLogsCleanup() { export interface FlightLogFilters { general?: string; user?: string; - action?: "add" | "update" | "delete"; + action?: 'add' | 'update' | 'delete'; session?: string; flightId?: string; date?: string; @@ -131,9 +131,9 @@ export async function getFlightLogs( ) { try { let query = mainDb - .selectFrom("flight_logs") + .selectFrom('flight_logs') .selectAll() - .orderBy("created_at", "desc") + .orderBy('created_at', 'desc') .limit(limit) .offset((page - 1) * limit); @@ -141,27 +141,27 @@ export async function getFlightLogs( const searchPattern = `%${filters.general}%`; query = query.where((eb) => eb.or([ - eb("username", "ilike", searchPattern), - eb("session_id", "ilike", searchPattern), - eb("flight_id", "ilike", searchPattern), - eb("user_id", "ilike", searchPattern), - eb(sql`old_data::text`, "ilike", searchPattern), - eb(sql`new_data::text`, "ilike", searchPattern), + eb('username', 'ilike', searchPattern), + eb('session_id', 'ilike', searchPattern), + eb('flight_id', 'ilike', searchPattern), + eb('user_id', 'ilike', searchPattern), + eb(sql`old_data::text`, 'ilike', searchPattern), + eb(sql`new_data::text`, 'ilike', searchPattern), ]) ); } if (filters.user) { - query = query.where("username", "ilike", `%${filters.user}%`); + query = query.where('username', 'ilike', `%${filters.user}%`); } if (filters.action) { - query = query.where("action", "=", filters.action); + query = query.where('action', '=', filters.action); } if (filters.session) { - query = query.where("session_id", "=", filters.session); + query = query.where('session_id', '=', filters.session); } if (filters.flightId) { - query = query.where("flight_id", "=", filters.flightId); + query = query.where('flight_id', '=', filters.flightId); } if (filters.date) { const startOfDay = new Date(filters.date); @@ -169,47 +169,47 @@ export async function getFlightLogs( const endOfDay = new Date(filters.date); endOfDay.setHours(23, 59, 59, 999); - query = query.where("created_at", ">=", startOfDay); - query = query.where("created_at", "<=", endOfDay); + query = query.where('created_at', '>=', startOfDay); + query = query.where('created_at', '<=', endOfDay); } if (filters.text) { const searchPattern = `%${filters.text}%`; query = query.where((eb) => eb.or([ - eb(sql`old_data::text`, "ilike", searchPattern), - eb(sql`new_data::text`, "ilike", searchPattern), + eb(sql`old_data::text`, 'ilike', searchPattern), + eb(sql`new_data::text`, 'ilike', searchPattern), ]) ); } let countQuery = mainDb - .selectFrom("flight_logs") - .select((eb) => eb.fn.count("id").as("count")); + .selectFrom('flight_logs') + .select((eb) => eb.fn.count('id').as('count')); if (filters.general) { const searchPattern = `%${filters.general}%`; countQuery = countQuery.where((eb) => eb.or([ - eb("username", "ilike", searchPattern), - eb("session_id", "ilike", searchPattern), - eb("flight_id", "ilike", searchPattern), - eb("user_id", "ilike", searchPattern), - eb(sql`old_data::text`, "ilike", searchPattern), - eb(sql`new_data::text`, "ilike", searchPattern), + eb('username', 'ilike', searchPattern), + eb('session_id', 'ilike', searchPattern), + eb('flight_id', 'ilike', searchPattern), + eb('user_id', 'ilike', searchPattern), + eb(sql`old_data::text`, 'ilike', searchPattern), + eb(sql`new_data::text`, 'ilike', searchPattern), ]) ); } if (filters.user) { - countQuery = countQuery.where("username", "ilike", `%${filters.user}%`); + countQuery = countQuery.where('username', 'ilike', `%${filters.user}%`); } if (filters.action) { - countQuery = countQuery.where("action", "=", filters.action); + countQuery = countQuery.where('action', '=', filters.action); } if (filters.session) { - countQuery = countQuery.where("session_id", "=", filters.session); + countQuery = countQuery.where('session_id', '=', filters.session); } if (filters.flightId) { - countQuery = countQuery.where("flight_id", "=", filters.flightId); + countQuery = countQuery.where('flight_id', '=', filters.flightId); } if (filters.date) { const startOfDay = new Date(filters.date); @@ -217,15 +217,15 @@ export async function getFlightLogs( const endOfDay = new Date(filters.date); endOfDay.setHours(23, 59, 59, 999); - countQuery = countQuery.where("created_at", ">=", startOfDay); - countQuery = countQuery.where("created_at", "<=", endOfDay); + countQuery = countQuery.where('created_at', '>=', startOfDay); + countQuery = countQuery.where('created_at', '<=', endOfDay); } if (filters.text) { const searchPattern = `%${filters.text}%`; countQuery = countQuery.where((eb) => eb.or([ - eb(sql`old_data::text`, "ilike", searchPattern), - eb(sql`new_data::text`, "ilike", searchPattern), + eb(sql`old_data::text`, 'ilike', searchPattern), + eb(sql`new_data::text`, 'ilike', searchPattern), ]) ); } @@ -238,7 +238,7 @@ export async function getFlightLogs( ...log, ip_address: log.ip_address ? decrypt( - typeof log.ip_address === "string" + typeof log.ip_address === 'string' ? JSON.parse(log.ip_address) : log.ip_address ) @@ -252,7 +252,7 @@ export async function getFlightLogs( }, }; } catch (error) { - console.error("Error fetching flight logs:", error); + console.error('Error fetching flight logs:', error); throw error; } } @@ -266,36 +266,36 @@ export async function listDeveloperFlightLogsMetadata( const offset = (page - 1) * limit; let base = mainDb - .selectFrom("flight_logs") - .innerJoin("sessions", "sessions.session_id", "flight_logs.session_id") - .where("sessions.created_by", "=", ownerUserId); + .selectFrom('flight_logs') + .innerJoin('sessions', 'sessions.session_id', 'flight_logs.session_id') + .where('sessions.created_by', '=', ownerUserId); if (opts.sessionId) { - base = base.where("flight_logs.session_id", "=", opts.sessionId); + base = base.where('flight_logs.session_id', '=', opts.sessionId); } const rows = await base .select([ - "flight_logs.id", - "flight_logs.session_id", - "flight_logs.flight_id", - "flight_logs.action", - "flight_logs.created_at", + 'flight_logs.id', + 'flight_logs.session_id', + 'flight_logs.flight_id', + 'flight_logs.action', + 'flight_logs.created_at', ]) - .orderBy("flight_logs.created_at", "desc") + .orderBy('flight_logs.created_at', 'desc') .limit(limit) .offset(offset) .execute(); let countQ = mainDb - .selectFrom("flight_logs") - .innerJoin("sessions", "sessions.session_id", "flight_logs.session_id") - .where("sessions.created_by", "=", ownerUserId); + .selectFrom('flight_logs') + .innerJoin('sessions', 'sessions.session_id', 'flight_logs.session_id') + .where('sessions.created_by', '=', ownerUserId); if (opts.sessionId) { - countQ = countQ.where("flight_logs.session_id", "=", opts.sessionId); + countQ = countQ.where('flight_logs.session_id', '=', opts.sessionId); } const totalRow = await countQ - .select((eb) => eb.fn.count("flight_logs.id").as("count")) + .select((eb) => eb.fn.count('flight_logs.id').as('count')) .executeTakeFirst(); return { @@ -318,9 +318,9 @@ export async function listDeveloperFlightLogsMetadata( export async function getFlightLogById(logId: string | number) { try { const log = await mainDb - .selectFrom("flight_logs") + .selectFrom('flight_logs') .selectAll() - .where("id", "=", Number(logId)) + .where('id', '=', Number(logId)) .executeTakeFirst(); if (!log) return null; @@ -329,14 +329,14 @@ export async function getFlightLogById(logId: string | number) { ...log, ip_address: log.ip_address ? decrypt( - typeof log.ip_address === "string" + typeof log.ip_address === 'string' ? JSON.parse(log.ip_address) : log.ip_address ) : null, }; } catch (error) { - console.error("Error fetching flight log by ID:", error); + console.error('Error fetching flight log by ID:', error); throw error; } -} \ No newline at end of file +} diff --git a/server/db/flights.ts b/server/db/flights.ts index 21b6da39..87968f3e 100644 --- a/server/db/flights.ts +++ b/server/db/flights.ts @@ -1,16 +1,16 @@ -import { mainDb } from "./connection.js"; -import { validateSessionId, validateFlightId } from "../utils/validation.js"; -import { getSessionById } from "./sessions.js"; +import { mainDb } from './connection.js'; +import { validateSessionId, validateFlightId } from '../utils/validation.js'; +import { getSessionById } from './sessions.js'; import { generateRandomId, generateSID, generateSquawk, getWakeTurbulence, -} from "../utils/flightUtils.js"; -import crypto from "crypto"; -import { sql } from "kysely"; -import { incrementStat } from "../utils/statisticsCache.js"; -import type { FlightsTable } from "./types/connection/main/FlightsTable.js"; +} from '../utils/flightUtils.js'; +import crypto from 'crypto'; +import { sql } from 'kysely'; +import { incrementStat } from '../utils/statisticsCache.js'; +import type { FlightsTable } from './types/connection/main/FlightsTable.js'; function createUTCDate(): Date { const now = new Date(); return new Date( @@ -105,31 +105,31 @@ function sanitizeFlightForOwner( } function validateFlightFields(updates: Partial) { - if (typeof updates.callsign === "string" && updates.callsign.length > 16) { - throw new Error("Callsign must be 16 characters or less"); + if (typeof updates.callsign === 'string' && updates.callsign.length > 16) { + throw new Error('Callsign must be 16 characters or less'); } - if (typeof updates.callsign === "string" && updates.callsign.length > 0) { + if (typeof updates.callsign === 'string' && updates.callsign.length > 0) { if (!/\d/.test(updates.callsign)) { - throw new Error("Callsign must contain at least one number"); + throw new Error('Callsign must contain at least one number'); } } - if (typeof updates.stand === "string" && updates.stand.length > 8) { - throw new Error("Stand must be 8 characters or less"); + if (typeof updates.stand === 'string' && updates.stand.length > 8) { + throw new Error('Stand must be 8 characters or less'); } - if (typeof updates.squawk === "string") { + if (typeof updates.squawk === 'string') { const squawk = updates.squawk; if (squawk.length > 0 && (squawk.length > 4 || !/^\d{1,4}$/.test(squawk))) { - throw new Error("Squawk must be up to 4 numeric digits"); + throw new Error('Squawk must be up to 4 numeric digits'); } } - if (typeof updates.remark === "string" && updates.remark.length > 50) { - throw new Error("Remark must be 50 characters or less"); + if (typeof updates.remark === 'string' && updates.remark.length > 50) { + throw new Error('Remark must be 50 characters or less'); } if (updates.cruisingfl !== undefined) { const fl = parseInt(String(updates.cruisingfl), 10); if (isNaN(fl) || fl < 0 || fl > 500 || fl % 5 !== 0) { throw new Error( - "Cruising FL must be between 0 and 500 in 5-step increments" + 'Cruising FL must be between 0 and 500 in 5-step increments' ); } } @@ -137,7 +137,7 @@ function validateFlightFields(updates: Partial) { const fl = parseInt(String(updates.clearedfl), 10); if (isNaN(fl) || fl < 0 || fl > 500 || fl % 5 !== 0) { throw new Error( - "Cleared FL must be between 0 and 500 in 5-step increments" + 'Cleared FL must be between 0 and 500 in 5-step increments' ); } } @@ -147,14 +147,14 @@ export async function getFlightsBySessionForDeveloperApi(sessionId: string) { const validSessionId = validateSessionId(sessionId); try { const flights = await mainDb - .selectFrom("flights") + .selectFrom('flights') .selectAll() - .where("session_id", "=", validSessionId) - .orderBy("created_at", "asc") + .where('session_id', '=', validSessionId) + .orderBy('created_at', 'asc') .execute(); return flights.map((flight) => sanitizeFlightForClient(flight)); } catch (error) { - console.error("Error fetching flights (developer API):", error); + console.error('Error fetching flights (developer API):', error); return []; } } @@ -164,17 +164,17 @@ export async function getFlightsBySession(sessionId: string) { try { const flights = await mainDb - .selectFrom("flights") + .selectFrom('flights') .selectAll() - .where("session_id", "=", validSessionId) - .orderBy("created_at", "asc") + .where('session_id', '=', validSessionId) + .orderBy('created_at', 'asc') .execute(); const userIds = [ ...new Set( flights .map((f) => f.user_id) - .filter((id): id is string => typeof id === "string") + .filter((id): id is string => typeof id === 'string') ), ]; @@ -190,13 +190,13 @@ export async function getFlightsBySession(sessionId: string) { if (userIds.length > 0) { try { const users = await mainDb - .selectFrom("users") + .selectFrom('users') .select([ - "id", - "username as discord_username", - "avatar as discord_avatar_url", + 'id', + 'username as discord_username', + 'avatar as discord_avatar_url', ]) - .where("id", "in", userIds) + .where('id', 'in', userIds) .execute(); users.forEach((user) => { @@ -209,7 +209,7 @@ export async function getFlightsBySession(sessionId: string) { }); }); } catch (userError) { - console.error("Error fetching user data:", userError); + console.error('Error fetching user data:', userError); } } @@ -218,7 +218,7 @@ export async function getFlightsBySession(sessionId: string) { user: flight.user_id ? usersMap.get(flight.user_id) : undefined, })); } catch (error) { - console.error("Error fetching flights:", error); + console.error('Error fetching flights:', error); return []; } } @@ -226,47 +226,47 @@ export async function getFlightsBySession(sessionId: string) { export async function getFlightsByUser(userId: string) { try { const flights = await mainDb - .selectFrom("flights") - .leftJoin("sessions", "sessions.session_id", "flights.session_id") + .selectFrom('flights') + .leftJoin('sessions', 'sessions.session_id', 'flights.session_id') .select([ - "flights.id", - "flights.session_id", - "flights.user_id", - "flights.ip_address", - "flights.callsign", - "flights.aircraft", - "flights.flight_type", - "flights.departure", - "flights.arrival", - "flights.alternate", - "flights.route", - "flights.sid", - "flights.star", - "flights.runway", - "flights.clearedfl", - "flights.cruisingfl", - "flights.stand", - "flights.gate", - "flights.remark", - "flights.flight_plan_time", - "flights.status", - "flights.clearance", - "flights.position", - "flights.squawk", - "flights.wtc", - "flights.hidden", - "flights.acars_token", - "flights.pdc_remarks", - "flights.notes", - "flights.snap_images", - "flights.featured_on_profile", - "flights.created_at", - "flights.updated_at", - "sessions.is_pfatc", - "sessions.is_advanced_atc", + 'flights.id', + 'flights.session_id', + 'flights.user_id', + 'flights.ip_address', + 'flights.callsign', + 'flights.aircraft', + 'flights.flight_type', + 'flights.departure', + 'flights.arrival', + 'flights.alternate', + 'flights.route', + 'flights.sid', + 'flights.star', + 'flights.runway', + 'flights.clearedfl', + 'flights.cruisingfl', + 'flights.stand', + 'flights.gate', + 'flights.remark', + 'flights.flight_plan_time', + 'flights.status', + 'flights.clearance', + 'flights.position', + 'flights.squawk', + 'flights.wtc', + 'flights.hidden', + 'flights.acars_token', + 'flights.pdc_remarks', + 'flights.notes', + 'flights.snap_images', + 'flights.featured_on_profile', + 'flights.created_at', + 'flights.updated_at', + 'sessions.is_pfatc', + 'sessions.is_advanced_atc', ]) - .where("flights.user_id", "=", userId) - .orderBy("flights.created_at", "desc") + .where('flights.user_id', '=', userId) + .orderBy('flights.created_at', 'desc') .execute(); return flights.map((flight) => ({ @@ -284,10 +284,10 @@ export async function getFlightByIdForUser(userId: string, flightId: string) { try { const validFlightId = validateFlightId(flightId); const flight = await mainDb - .selectFrom("flights") + .selectFrom('flights') .selectAll() - .where("id", "=", validFlightId) - .where("user_id", "=", userId) + .where('id', '=', validFlightId) + .where('user_id', '=', userId) .executeTakeFirst(); return flight ? sanitizeFlightForOwner(flight) : null; @@ -305,10 +305,10 @@ export async function getFlightLogsForUser(userId: string, flightId: string) { const validFlightId = validateFlightId(flightId); const ownedFlight = await mainDb - .selectFrom("flights") - .select(["id", "created_at", "user_id"]) - .where("id", "=", validFlightId) - .where("user_id", "=", userId) + .selectFrom('flights') + .select(['id', 'created_at', 'user_id']) + .where('id', '=', validFlightId) + .where('user_id', '=', userId) .executeTakeFirst(); if (!ownedFlight) { @@ -321,20 +321,20 @@ export async function getFlightLogsForUser(userId: string, flightId: string) { ); const logs = await mainDb - .selectFrom("flight_logs") - .leftJoin("users", "users.id", "flight_logs.user_id") + .selectFrom('flight_logs') + .leftJoin('users', 'users.id', 'flight_logs.user_id') .select([ - "flight_logs.id", - "flight_logs.action", - "flight_logs.old_data", - "flight_logs.new_data", - "flight_logs.created_at", - "flight_logs.username", - "flight_logs.user_id", - "users.avatar as user_avatar_hash", + 'flight_logs.id', + 'flight_logs.action', + 'flight_logs.old_data', + 'flight_logs.new_data', + 'flight_logs.created_at', + 'flight_logs.username', + 'flight_logs.user_id', + 'users.avatar as user_avatar_hash', ]) - .where("flight_logs.flight_id", "=", validFlightId) - .orderBy("flight_logs.created_at", "desc") + .where('flight_logs.flight_id', '=', validFlightId) + .orderBy('flight_logs.created_at', 'desc') .execute(); return { @@ -372,26 +372,26 @@ export async function claimFlightForUser( const validFlightId = validateFlightId(flightId); const flight = await mainDb - .selectFrom("flights") - .select(["id", "user_id", "acars_token"]) - .where("session_id", "=", validSessionId) - .where("id", "=", validFlightId) + .selectFrom('flights') + .select(['id', 'user_id', 'acars_token']) + .where('session_id', '=', validSessionId) + .where('id', '=', validFlightId) .executeTakeFirst(); - if (!flight) return { ok: false, reason: "not_found" as const }; + if (!flight) return { ok: false, reason: 'not_found' as const }; if (flight.acars_token !== acarsToken) - return { ok: false, reason: "invalid_token" as const }; + return { ok: false, reason: 'invalid_token' as const }; if (flight.user_id && flight.user_id !== userId) - return { ok: false, reason: "already_claimed" as const }; + return { ok: false, reason: 'already_claimed' as const }; await mainDb - .updateTable("flights") + .updateTable('flights') .set({ user_id: userId, updated_at: createUTCDate(), }) - .where("session_id", "=", validSessionId) - .where("id", "=", validFlightId) + .where('session_id', '=', validSessionId) + .where('id', '=', validFlightId) .execute(); return { ok: true as const }; @@ -407,10 +407,10 @@ export async function validateAcarsAccess( const validFlightId = validateFlightId(flightId); const result = await mainDb - .selectFrom("flights") - .select("acars_token") - .where("session_id", "=", validSessionId) - .where("id", "=", validFlightId) + .selectFrom('flights') + .select('acars_token') + .where('session_id', '=', validSessionId) + .where('id', '=', validFlightId) .executeTakeFirst(); if (!result || result.acars_token !== acarsToken) { @@ -420,7 +420,7 @@ export async function validateAcarsAccess( const session = await getSessionById(sessionId); return { valid: true, accessId: session?.access_id || null }; } catch (error) { - console.error("Error validating ACARS access:", error); + console.error('Error validating ACARS access:', error); return { valid: false }; } } @@ -437,28 +437,28 @@ export async function getFlightsBySessionWithTime( const sinceIso = sinceDateUTC.toISOString(); const flights = await mainDb - .selectFrom("flights") + .selectFrom('flights') .selectAll() - .where("session_id", "=", validSessionId) + .where('session_id', '=', validSessionId) .where((eb) => eb.or([ - eb("flight_plan_time", ">=", sinceIso), - eb("updated_at", ">=", sql`${sinceIso}`), - eb("created_at", ">=", sql`${sinceIso}`), + eb('flight_plan_time', '>=', sinceIso), + eb('updated_at', '>=', sql`${sinceIso}`), + eb('created_at', '>=', sql`${sinceIso}`), ]) ) .orderBy( sql`COALESCE(flight_plan_time::timestamp, created_at, updated_at)`, - "desc" + 'desc' ) - .orderBy("callsign", "asc") + .orderBy('callsign', 'asc') .execute(); const userIds = [ ...new Set( flights .map((f) => f.user_id) - .filter((id): id is string => typeof id === "string") + .filter((id): id is string => typeof id === 'string') ), ]; @@ -474,13 +474,13 @@ export async function getFlightsBySessionWithTime( if (userIds.length > 0) { try { const users = await mainDb - .selectFrom("users") + .selectFrom('users') .select([ - "id", - "username as discord_username", - "avatar as discord_avatar_url", + 'id', + 'username as discord_username', + 'avatar as discord_avatar_url', ]) - .where("id", "in", userIds) + .where('id', 'in', userIds) .execute(); users.forEach((user) => { @@ -493,7 +493,7 @@ export async function getFlightsBySessionWithTime( }); }); } catch (userError) { - console.error("Error fetching user data:", userError); + console.error('Error fetching user data:', userError); } } @@ -515,19 +515,19 @@ export interface ExternalArrivalFlight extends ClientFlight { export async function getExternalArrivalFlights( airportIcao: string, - networkKind: "pfatc" | "advanced_atc" | null + networkKind: 'pfatc' | 'advanced_atc' | null ): Promise { - const { perfAsync } = await import("../realtime/perf.js"); + const { perfAsync } = await import('../realtime/perf.js'); return perfAsync( - "getExternalArrivalFlights", + 'getExternalArrivalFlights', () => getExternalArrivalFlightsUncached(airportIcao, networkKind), - { icao: airportIcao, network: networkKind ?? "none" } + { icao: airportIcao, network: networkKind ?? 'none' } ); } async function getExternalArrivalFlightsUncached( airportIcao: string, - networkKind: "pfatc" | "advanced_atc" | null + networkKind: 'pfatc' | 'advanced_atc' | null ): Promise { if (!networkKind) return []; if ( @@ -535,7 +535,7 @@ async function getExternalArrivalFlightsUncached( airportIcao.length !== 4 || !/^[A-Z]{4}$/i.test(airportIcao) ) { - throw new Error("Invalid ICAO airport code"); + throw new Error('Invalid ICAO airport code'); } try { const sinceDateUTC = createUTCDate(); @@ -544,27 +544,27 @@ async function getExternalArrivalFlightsUncached( const icao = airportIcao.toUpperCase(); let query = mainDb - .selectFrom("flights as f") - .innerJoin("sessions as s", "s.session_id", "f.session_id") - .selectAll("f") + .selectFrom('flights as f') + .innerJoin('sessions as s', 's.session_id', 'f.session_id') + .selectAll('f') .select([ - sql`s.session_id`.as("source_session_id"), - sql`s.airport_icao`.as("source_airport"), + sql`s.session_id`.as('source_session_id'), + sql`s.airport_icao`.as('source_airport'), ]) - .where("f.arrival", "=", icao) - .where("s.airport_icao", "!=", icao) + .where('f.arrival', '=', icao) + .where('s.airport_icao', '!=', icao) .where((eb) => eb.or([ - eb("f.flight_plan_time", ">=", sinceIso), - eb("f.updated_at", ">=", sql`${sinceIso}`), - eb("f.created_at", ">=", sql`${sinceIso}`), + eb('f.flight_plan_time', '>=', sinceIso), + eb('f.updated_at', '>=', sql`${sinceIso}`), + eb('f.created_at', '>=', sql`${sinceIso}`), ]) ); - if (networkKind === "pfatc") { - query = query.where("s.is_pfatc", "=", true); + if (networkKind === 'pfatc') { + query = query.where('s.is_pfatc', '=', true); } else { - query = query.where("s.is_advanced_atc", "=", true); + query = query.where('s.is_advanced_atc', '=', true); } const flights = await query.execute(); @@ -573,7 +573,7 @@ async function getExternalArrivalFlightsUncached( ...new Set( flights .map((f) => f.user_id) - .filter((id): id is string => typeof id === "string") + .filter((id): id is string => typeof id === 'string') ), ]; @@ -589,13 +589,13 @@ async function getExternalArrivalFlightsUncached( if (userIds.length > 0) { try { const users = await mainDb - .selectFrom("users") + .selectFrom('users') .select([ - "id", - "username as discord_username", - "avatar as discord_avatar_url", + 'id', + 'username as discord_username', + 'avatar as discord_avatar_url', ]) - .where("id", "in", userIds) + .where('id', 'in', userIds) .execute(); users.forEach((user) => { @@ -609,7 +609,7 @@ async function getExternalArrivalFlightsUncached( }); } catch (userError) { console.error( - "Error fetching user data for external arrivals:", + 'Error fetching user data for external arrivals:', userError ); } @@ -625,7 +625,7 @@ async function getExternalArrivalFlightsUncached( }) ); } catch (error) { - console.error("Error fetching external arrival flights:", error); + console.error('Error fetching external arrival flights:', error); return []; } } @@ -655,12 +655,12 @@ export async function addFlight(sessionId: string, flightData: AddFlightData) { flightData.id = generateRandomId(); flightData.squawk = await generateSquawk(flightData); flightData.wtc = await getWakeTurbulence( - flightData.aircraft || flightData.aircraft_type || "" + flightData.aircraft || flightData.aircraft_type || '' ); if (!flightData.flight_plan_time) { flightData.flight_plan_time = new Date().toISOString(); } - flightData.acars_token = crypto.randomBytes(4).toString("hex"); + flightData.acars_token = crypto.randomBytes(4).toString('hex'); flightData.updated_at = createUTCDate(); if (flightData.aircraft_type) { @@ -675,7 +675,7 @@ export async function addFlight(sessionId: string, flightData: AddFlightData) { flightData.runway = session.active_runway; } } catch (error) { - console.error("Error fetching session for runway assignment:", error); + console.error('Error fetching session for runway assignment:', error); } } @@ -704,16 +704,16 @@ export async function addFlight(sessionId: string, flightData: AddFlightData) { } = flightData as Record; const result = await mainDb - .insertInto("flights") + .insertInto('flights') .values({ session_id: validSessionId, id: String(insertData.id ?? sql`gen_random_uuid()`), ...Object.fromEntries( Object.entries(insertData) - .filter(([k]) => k !== "id") + .filter(([k]) => k !== 'id') .map(([k, v]) => { if ( - (k === "cruisingfl" || k === "clearedfl") && + (k === 'cruisingfl' || k === 'clearedfl') && v !== undefined && v !== null ) { @@ -726,21 +726,21 @@ export async function addFlight(sessionId: string, flightData: AddFlightData) { .returningAll() .executeTakeFirst(); - if (!result) throw new Error("Failed to insert flight"); + if (!result) throw new Error('Failed to insert flight'); if (flightData.user_id) { incrementStat( String(flightData.user_id), - "total_flights_submitted", + 'total_flights_submitted', 1, - "total" + 'total' ); } const clientFlight = sanitizeFlightForClient(result); void (async () => { - const { getSessionMeta } = await import("../realtime/activeSessions.js"); - const { onFlightChanged } = await import("../realtime/invalidate.js"); + const { getSessionMeta } = await import('../realtime/activeSessions.js'); + const { onFlightChanged } = await import('../realtime/invalidate.js'); const meta = await getSessionMeta(validSessionId); void onFlightChanged({ sessionId: validSessionId, @@ -759,10 +759,10 @@ export async function getFlightById(sessionId: string, flightId: string) { return ( (await mainDb - .selectFrom("flights") + .selectFrom('flights') .selectAll() - .where("session_id", "=", validSessionId) - .where("id", "=", validFlightId) + .where('session_id', '=', validSessionId) + .where('id', '=', validFlightId) .executeTakeFirst()) ?? null ); } @@ -772,9 +772,9 @@ export async function getPublicFlightById(flightId: string) { const flight = (await mainDb - .selectFrom("flights") + .selectFrom('flights') .selectAll() - .where("id", "=", validFlightId) + .where('id', '=', validFlightId) .executeTakeFirst()) ?? null; // Only expose flights that have been submitted (have an acars_token) @@ -791,61 +791,61 @@ export async function updateFlight( const validFlightId = validateFlightId(flightId); const allowedColumns = [ - "callsign", - "aircraft", - "departure", - "arrival", - "flight_type", - "stand", - "gate", - "runway", - "sid", - "star", - "cruisingfl", - "clearedfl", - "squawk", - "wtc", - "status", - "remark", - "clearance", - "pdc_remarks", - "hidden", - "route", - "req_at", - "req_phase", + 'callsign', + 'aircraft', + 'departure', + 'arrival', + 'flight_type', + 'stand', + 'gate', + 'runway', + 'sid', + 'star', + 'cruisingfl', + 'clearedfl', + 'squawk', + 'wtc', + 'status', + 'remark', + 'clearance', + 'pdc_remarks', + 'hidden', + 'route', + 'req_at', + 'req_phase', ]; const dbUpdates: Record = {}; for (const [key, value] of Object.entries(updates)) { let dbKey = key; - if (key === "cruisingFL") dbKey = "cruisingfl"; - if (key === "clearedFL") dbKey = "clearedfl"; + if (key === 'cruisingFL') dbKey = 'cruisingfl'; + if (key === 'clearedFL') dbKey = 'clearedfl'; if (allowedColumns.includes(dbKey)) { - dbUpdates[dbKey] = dbKey === "clearance" ? String(value) : value; + dbUpdates[dbKey] = dbKey === 'clearance' ? String(value) : value; } } validateFlightFields(dbUpdates as Partial); if (Object.keys(dbUpdates).length === 0) { - throw new Error("No valid fields to update"); + throw new Error('No valid fields to update'); } dbUpdates.updated_at = createUTCDate(); const result = await mainDb - .updateTable("flights") + .updateTable('flights') .set(dbUpdates) - .where("session_id", "=", validSessionId) - .where("id", "=", validFlightId) + .where('session_id', '=', validSessionId) + .where('id', '=', validFlightId) .returningAll() .executeTakeFirst(); - if (!result) throw new Error("Flight not found or update failed"); + if (!result) throw new Error('Flight not found or update failed'); const clientFlight = sanitizeFlightForClient(result); void (async () => { - const { getSessionMeta } = await import("../realtime/activeSessions.js"); - const { onFlightChanged } = await import("../realtime/invalidate.js"); + const { getSessionMeta } = await import('../realtime/activeSessions.js'); + const { onFlightChanged } = await import('../realtime/invalidate.js'); const meta = await getSessionMeta(validSessionId); void onFlightChanged({ sessionId: validSessionId, @@ -865,15 +865,15 @@ export async function deleteFlight(sessionId: string, flightId: string) { const existing = await getFlightById(validSessionId, validFlightId); await mainDb - .deleteFrom("flights") - .where("session_id", "=", validSessionId) - .where("id", "=", validFlightId) + .deleteFrom('flights') + .where('session_id', '=', validSessionId) + .where('id', '=', validFlightId) .execute(); if (existing) { void (async () => { - const { getSessionMeta } = await import("../realtime/activeSessions.js"); - const { onFlightChanged } = await import("../realtime/invalidate.js"); + const { getSessionMeta } = await import('../realtime/activeSessions.js'); + const { onFlightChanged } = await import('../realtime/invalidate.js'); const meta = await getSessionMeta(validSessionId); void onFlightChanged({ sessionId: validSessionId, @@ -893,10 +893,10 @@ export async function updateFlightNotes( ) { const validFlightId = validateFlightId(flightId); await mainDb - .updateTable("flights") + .updateTable('flights') .set({ notes, updated_at: createUTCDate() }) - .where("id", "=", validFlightId) - .where("user_id", "=", userId) + .where('id', '=', validFlightId) + .where('user_id', '=', userId) .execute(); } @@ -912,10 +912,10 @@ export async function addSnapImage( const validFlightId = validateFlightId(flightId); const flight = await mainDb - .selectFrom("flights") - .select(["id", "user_id", "snap_images"]) - .where("id", "=", validFlightId) - .where("user_id", "=", userId) + .selectFrom('flights') + .select(['id', 'user_id', 'snap_images']) + .where('id', '=', validFlightId) + .where('user_id', '=', userId) .executeTakeFirst(); if (!flight) return { ok: false }; @@ -926,13 +926,13 @@ export async function addSnapImage( const updated = [...existing, { cephie_id: cephieId, url }]; await mainDb - .updateTable("flights") + .updateTable('flights') .set({ snap_images: sql`${JSON.stringify(updated)}::jsonb`, updated_at: createUTCDate(), }) - .where("id", "=", validFlightId) - .where("user_id", "=", userId) + .where('id', '=', validFlightId) + .where('user_id', '=', userId) .execute(); return { ok: true, snap_images: updated }; @@ -946,10 +946,10 @@ export async function deleteSnapImage( const validFlightId = validateFlightId(flightId); const flight = await mainDb - .selectFrom("flights") - .select(["id", "user_id", "snap_images"]) - .where("id", "=", validFlightId) - .where("user_id", "=", userId) + .selectFrom('flights') + .select(['id', 'user_id', 'snap_images']) + .where('id', '=', validFlightId) + .where('user_id', '=', userId) .executeTakeFirst(); if (!flight) return { ok: false }; @@ -963,13 +963,13 @@ export async function deleteSnapImage( const updated = existing.filter((img) => img.cephie_id !== cephieId); await mainDb - .updateTable("flights") + .updateTable('flights') .set({ snap_images: sql`${JSON.stringify(updated)}::jsonb`, updated_at: createUTCDate(), }) - .where("id", "=", validFlightId) - .where("user_id", "=", userId) + .where('id', '=', validFlightId) + .where('user_id', '=', userId) .execute(); return { ok: true, cephie_id: cephieId }; @@ -982,10 +982,10 @@ export async function toggleFeaturedOnProfile( const validFlightId = validateFlightId(flightId); const flight = await mainDb - .selectFrom("flights") - .select(["id", "user_id", "featured_on_profile"]) - .where("id", "=", validFlightId) - .where("user_id", "=", userId) + .selectFrom('flights') + .select(['id', 'user_id', 'featured_on_profile']) + .where('id', '=', validFlightId) + .where('user_id', '=', userId) .executeTakeFirst(); if (!flight) return { ok: false }; @@ -994,19 +994,19 @@ export async function toggleFeaturedOnProfile( if (newValue) { const countRow = await mainDb - .selectFrom("flights") - .select((eb) => eb.fn.count("id").as("count")) - .where("user_id", "=", userId) - .where("featured_on_profile", "=", true) + .selectFrom('flights') + .select((eb) => eb.fn.count('id').as('count')) + .where('user_id', '=', userId) + .where('featured_on_profile', '=', true) .executeTakeFirst(); if (Number(countRow?.count ?? 0) >= 3) return { ok: false, atCap: true }; } await mainDb - .updateTable("flights") + .updateTable('flights') .set({ featured_on_profile: newValue, updated_at: createUTCDate() }) - .where("id", "=", validFlightId) - .where("user_id", "=", userId) + .where('id', '=', validFlightId) + .where('user_id', '=', userId) .execute(); return { ok: true, featured: newValue }; @@ -1015,23 +1015,23 @@ export async function toggleFeaturedOnProfile( export async function getFeaturedFlightsByUser(userId: string) { try { const flights = await mainDb - .selectFrom("flights") + .selectFrom('flights') .select([ - "id", - "session_id", - "callsign", - "departure", - "arrival", - "aircraft", - "status", - "snap_images", - "featured_on_profile", - "created_at", - "acars_token", + 'id', + 'session_id', + 'callsign', + 'departure', + 'arrival', + 'aircraft', + 'status', + 'snap_images', + 'featured_on_profile', + 'created_at', + 'acars_token', ]) - .where("user_id", "=", userId) - .where("featured_on_profile", "=", true) - .orderBy("created_at", "desc") + .where('user_id', '=', userId) + .where('featured_on_profile', '=', true) + .orderBy('created_at', 'desc') .execute(); return flights.map((f) => ({ @@ -1050,4 +1050,4 @@ export async function getFeaturedFlightsByUser(userId: string) { console.error(`Error fetching featured flights for user ${userId}:`, error); return []; } -} \ No newline at end of file +} diff --git a/server/db/ratings.ts b/server/db/ratings.ts index 10f9730d..7674b664 100644 --- a/server/db/ratings.ts +++ b/server/db/ratings.ts @@ -28,7 +28,11 @@ export async function getControllerRatingStats(controllerId: string) { .executeTakeFirst(); return { - averageRating: result?.averageRating ? parseFloat(result.averageRating.toString()) : 0, - ratingCount: result?.ratingCount ? parseInt(result.ratingCount.toString()) : 0, + averageRating: result?.averageRating + ? parseFloat(result.averageRating.toString()) + : 0, + ratingCount: result?.ratingCount + ? parseInt(result.ratingCount.toString()) + : 0, }; } diff --git a/server/db/schemas.ts b/server/db/schemas.ts index a7bfeda6..00a133b4 100644 --- a/server/db/schemas.ts +++ b/server/db/schemas.ts @@ -1,9 +1,9 @@ -import { sql } from "kysely"; -import path from "path"; -import { fileURLToPath } from "url"; -import { mainDb } from "./connection.js"; -import type Redis from "ioredis"; -import { DEPLOYMENT, prefixKey } from "../utils/cacheTtl.js"; +import { sql } from 'kysely'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { mainDb } from './connection.js'; +import type Redis from 'ioredis'; +import { DEPLOYMENT, prefixKey } from '../utils/cacheTtl.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -11,569 +11,569 @@ const __dirname = path.dirname(__filename); export async function createMainTables() { // app_settings await mainDb.schema - .createTable("app_settings") + .createTable('app_settings') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("version", "varchar(50)", (col) => col.notNull()) - .addColumn("updated_at", "timestamptz", (col) => col.notNull()) - .addColumn("updated_by", "varchar(255)", (col) => col.notNull()) - .addColumn("channel", "varchar(50)", (col) => - col.notNull().defaultTo("production") + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('version', 'varchar(50)', (col) => col.notNull()) + .addColumn('updated_at', 'timestamptz', (col) => col.notNull()) + .addColumn('updated_by', 'varchar(255)', (col) => col.notNull()) + .addColumn('channel', 'varchar(50)', (col) => + col.notNull().defaultTo('production') ) .execute(); // roles (must be created before users) await mainDb.schema - .createTable("roles") + .createTable('roles') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("name", "varchar(255)", (col) => col.unique().notNull()) - .addColumn("description", "text") - .addColumn("permissions", "jsonb", (col) => col.notNull()) - .addColumn("color", "varchar(50)") - .addColumn("icon", "varchar(255)") - .addColumn("priority", "integer") - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("updated_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('name', 'varchar(255)', (col) => col.unique().notNull()) + .addColumn('description', 'text') + .addColumn('permissions', 'jsonb', (col) => col.notNull()) + .addColumn('color', 'varchar(50)') + .addColumn('icon', 'varchar(255)') + .addColumn('priority', 'integer') + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); // users await mainDb.schema - .createTable("users") - .ifNotExists() - .addColumn("id", "varchar(255)", (col) => col.primaryKey()) - .addColumn("username", "varchar(255)", (col) => col.notNull()) - .addColumn("discriminator", "varchar(10)") - .addColumn("avatar", "text") - .addColumn("access_token", "text") - .addColumn("refresh_token", "text") - .addColumn("last_login", "timestamptz") - .addColumn("ip_address", "text") - .addColumn("is_vpn", "boolean") - .addColumn("last_session_created", "timestamptz") - .addColumn("last_session_deleted", "timestamptz") - .addColumn("settings", "jsonb") - .addColumn("settings_updated_at", "timestamptz") - .addColumn("total_sessions_created", "integer", (col) => col.defaultTo(0)) - .addColumn("total_minutes", "integer", (col) => col.defaultTo(0)) - .addColumn("vatsim_cid", "varchar(50)") - .addColumn("vatsim_rating_id", "integer") - .addColumn("vatsim_rating_short", "varchar(10)") - .addColumn("vatsim_rating_long", "varchar(255)") - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("updated_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("roblox_user_id", "varchar(255)") - .addColumn("roblox_username", "varchar(255)") - .addColumn("roblox_access_token", "text") - .addColumn("roblox_refresh_token", "text") - .addColumn("role_id", "integer", (col) => - col.references("roles.id").onDelete("set null") + .createTable('users') + .ifNotExists() + .addColumn('id', 'varchar(255)', (col) => col.primaryKey()) + .addColumn('username', 'varchar(255)', (col) => col.notNull()) + .addColumn('discriminator', 'varchar(10)') + .addColumn('avatar', 'text') + .addColumn('access_token', 'text') + .addColumn('refresh_token', 'text') + .addColumn('last_login', 'timestamptz') + .addColumn('ip_address', 'text') + .addColumn('is_vpn', 'boolean') + .addColumn('last_session_created', 'timestamptz') + .addColumn('last_session_deleted', 'timestamptz') + .addColumn('settings', 'jsonb') + .addColumn('settings_updated_at', 'timestamptz') + .addColumn('total_sessions_created', 'integer', (col) => col.defaultTo(0)) + .addColumn('total_minutes', 'integer', (col) => col.defaultTo(0)) + .addColumn('vatsim_cid', 'varchar(50)') + .addColumn('vatsim_rating_id', 'integer') + .addColumn('vatsim_rating_short', 'varchar(10)') + .addColumn('vatsim_rating_long', 'varchar(255)') + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('roblox_user_id', 'varchar(255)') + .addColumn('roblox_username', 'varchar(255)') + .addColumn('roblox_access_token', 'text') + .addColumn('roblox_refresh_token', 'text') + .addColumn('role_id', 'integer', (col) => + col.references('roles.id').onDelete('set null') ) - .addColumn("tutorial_completed", "boolean", (col) => col.defaultTo(false)) - .addColumn("statistics", "jsonb") - .addColumn("fingerprint_id", "varchar(255)") - .addColumn("ip_hash", "varchar(64)") + .addColumn('tutorial_completed', 'boolean', (col) => col.defaultTo(false)) + .addColumn('statistics', 'jsonb') + .addColumn('fingerprint_id', 'varchar(255)') + .addColumn('ip_hash', 'varchar(64)') .execute(); await mainDb.schema - .createIndex("idx_users_fingerprint_id") + .createIndex('idx_users_fingerprint_id') .ifNotExists() - .on("users") - .column("fingerprint_id") + .on('users') + .column('fingerprint_id') .execute(); await mainDb.schema - .createIndex("idx_users_ip_hash") + .createIndex('idx_users_ip_hash') .ifNotExists() - .on("users") - .column("ip_hash") + .on('users') + .column('ip_hash') .execute(); // sessions await mainDb.schema - .createTable("sessions") + .createTable('sessions') .ifNotExists() - .addColumn("session_id", "varchar(255)", (col) => col.primaryKey()) - .addColumn("access_id", "varchar(255)", (col) => col.notNull()) - .addColumn("active_runway", "varchar(10)") - .addColumn("airport_icao", "varchar(10)", (col) => col.notNull()) - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("created_by", "varchar(255)", (col) => - col.notNull().references("users.id").onDelete("cascade") + .addColumn('session_id', 'varchar(255)', (col) => col.primaryKey()) + .addColumn('access_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('active_runway', 'varchar(10)') + .addColumn('airport_icao', 'varchar(10)', (col) => col.notNull()) + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('created_by', 'varchar(255)', (col) => + col.notNull().references('users.id').onDelete('cascade') ) - .addColumn("is_pfatc", "boolean", (col) => col.defaultTo(false)) - .addColumn("is_advanced_atc", "boolean", (col) => col.defaultTo(false)) - .addColumn("flight_strips", "jsonb") - .addColumn("atis", "jsonb") - .addColumn("custom_name", "varchar(255)") - .addColumn("refreshed_at", "timestamptz") + .addColumn('is_pfatc', 'boolean', (col) => col.defaultTo(false)) + .addColumn('is_advanced_atc', 'boolean', (col) => col.defaultTo(false)) + .addColumn('flight_strips', 'jsonb') + .addColumn('atis', 'jsonb') + .addColumn('custom_name', 'varchar(255)') + .addColumn('refreshed_at', 'timestamptz') .addCheckConstraint( - "sessions_pfatc_advanced_exclusive", + 'sessions_pfatc_advanced_exclusive', sql`NOT (is_pfatc AND is_advanced_atc)` ) .execute(); await mainDb.schema - .createIndex("idx_sessions_created_by") + .createIndex('idx_sessions_created_by') .ifNotExists() - .on("sessions") - .column("created_by") + .on('sessions') + .column('created_by') .execute(); await mainDb.schema - .createIndex("idx_sessions_airport_icao") + .createIndex('idx_sessions_airport_icao') .ifNotExists() - .on("sessions") - .column("airport_icao") + .on('sessions') + .column('airport_icao') .execute(); // user_roles await mainDb.schema - .createTable("user_roles") + .createTable('user_roles') .ifNotExists() - .addColumn("user_id", "varchar(255)", (col) => - col.references("users.id").onDelete("cascade") + .addColumn('user_id', 'varchar(255)', (col) => + col.references('users.id').onDelete('cascade') ) - .addColumn("role_id", "integer", (col) => - col.references("roles.id").onDelete("cascade") + .addColumn('role_id', 'integer', (col) => + col.references('roles.id').onDelete('cascade') ) - .addColumn("assigned_at", "timestamptz", (col) => col.defaultTo("now()")) - .addPrimaryKeyConstraint("user_roles_pkey", ["user_id", "role_id"]) + .addColumn('assigned_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addPrimaryKeyConstraint('user_roles_pkey', ['user_id', 'role_id']) .execute(); // audit_log await mainDb.schema - .createTable("audit_log") + .createTable('audit_log') .ifNotExists() - .addColumn("id", "bigserial", (col) => col.primaryKey()) - .addColumn("admin_id", "varchar(255)", (col) => col.notNull()) - .addColumn("admin_username", "varchar(255)", (col) => col.notNull()) - .addColumn("action_type", "varchar(100)", (col) => col.notNull()) - .addColumn("target_user_id", "varchar(255)") - .addColumn("target_username", "varchar(255)") - .addColumn("details", "jsonb") - .addColumn("ip_address", "text") - .addColumn("user_agent", "text") - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('id', 'bigserial', (col) => col.primaryKey()) + .addColumn('admin_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('admin_username', 'varchar(255)', (col) => col.notNull()) + .addColumn('action_type', 'varchar(100)', (col) => col.notNull()) + .addColumn('target_user_id', 'varchar(255)') + .addColumn('target_username', 'varchar(255)') + .addColumn('details', 'jsonb') + .addColumn('ip_address', 'text') + .addColumn('user_agent', 'text') + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); await mainDb.schema - .createIndex("idx_audit_log_created_at") + .createIndex('idx_audit_log_created_at') .ifNotExists() - .on("audit_log") - .column("created_at") + .on('audit_log') + .column('created_at') .execute(); // bans await mainDb.schema - .createTable("bans") + .createTable('bans') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("user_id", "varchar(255)") - .addColumn("ip_address", "text") - .addColumn("username", "varchar(255)") - .addColumn("reason", "text") - .addColumn("banned_by", "varchar(255)", (col) => col.notNull()) - .addColumn("banned_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("expires_at", "timestamptz") - .addColumn("active", "boolean", (col) => col.defaultTo(true)) - .addColumn("fingerprint_id", "varchar(255)") + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)') + .addColumn('ip_address', 'text') + .addColumn('username', 'varchar(255)') + .addColumn('reason', 'text') + .addColumn('banned_by', 'varchar(255)', (col) => col.notNull()) + .addColumn('banned_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('expires_at', 'timestamptz') + .addColumn('active', 'boolean', (col) => col.defaultTo(true)) + .addColumn('fingerprint_id', 'varchar(255)') .execute(); await mainDb.schema - .createIndex("idx_bans_user_id") + .createIndex('idx_bans_user_id') .ifNotExists() - .on("bans") - .column("user_id") + .on('bans') + .column('user_id') .execute(); // notifications await mainDb.schema - .createTable("notifications") + .createTable('notifications') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("type", "varchar(50)", (col) => col.notNull()) - .addColumn("text", "text", (col) => col.notNull()) - .addColumn("show", "boolean", (col) => col.defaultTo(true)) - .addColumn("custom_color", "varchar(50)") - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("updated_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('type', 'varchar(50)', (col) => col.notNull()) + .addColumn('text', 'text', (col) => col.notNull()) + .addColumn('show', 'boolean', (col) => col.defaultTo(true)) + .addColumn('custom_color', 'varchar(50)') + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); // user_notifications await mainDb.schema - .createTable("user_notifications") + .createTable('user_notifications') .ifNotExists() - .addColumn("id", "bigserial", (col) => col.primaryKey()) - .addColumn("user_id", "varchar(255)", (col) => - col.references("users.id").onDelete("cascade").notNull() + .addColumn('id', 'bigserial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => + col.references('users.id').onDelete('cascade').notNull() ) - .addColumn("type", "varchar(50)", (col) => col.notNull()) - .addColumn("title", "varchar(255)", (col) => col.notNull()) - .addColumn("message", "text", (col) => col.notNull()) - .addColumn("read", "boolean", (col) => col.defaultTo(false)) - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('type', 'varchar(50)', (col) => col.notNull()) + .addColumn('title', 'varchar(255)', (col) => col.notNull()) + .addColumn('message', 'text', (col) => col.notNull()) + .addColumn('read', 'boolean', (col) => col.defaultTo(false)) + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); await mainDb.schema - .createIndex("idx_user_notif_user_id") + .createIndex('idx_user_notif_user_id') .ifNotExists() - .on("user_notifications") - .column("user_id") + .on('user_notifications') + .column('user_id') .execute(); // testers await mainDb.schema - .createTable("testers") + .createTable('testers') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("user_id", "varchar(255)", (col) => - col.references("users.id").onDelete("cascade").unique().notNull() + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => + col.references('users.id').onDelete('cascade').unique().notNull() ) - .addColumn("username", "varchar(255)", (col) => col.notNull()) - .addColumn("added_by", "varchar(255)", (col) => col.notNull()) - .addColumn("added_by_username", "varchar(255)", (col) => col.notNull()) - .addColumn("notes", "text") - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("updated_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('username', 'varchar(255)', (col) => col.notNull()) + .addColumn('added_by', 'varchar(255)', (col) => col.notNull()) + .addColumn('added_by_username', 'varchar(255)', (col) => col.notNull()) + .addColumn('notes', 'text') + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); // tester_settings await mainDb.schema - .createTable("tester_settings") + .createTable('tester_settings') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("setting_key", "varchar(255)", (col) => col.unique().notNull()) - .addColumn("setting_value", "boolean", (col) => col.notNull()) - .addColumn("updated_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('setting_key', 'varchar(255)', (col) => col.unique().notNull()) + .addColumn('setting_value', 'boolean', (col) => col.notNull()) + .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); // daily_statistics await mainDb.schema - .createTable("daily_statistics") + .createTable('daily_statistics') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("date", "date", (col) => col.notNull().unique()) - .addColumn("logins_count", "integer", (col) => col.defaultTo(0)) - .addColumn("new_sessions_count", "integer", (col) => col.defaultTo(0)) - .addColumn("new_flights_count", "integer", (col) => col.defaultTo(0)) - .addColumn("new_users_count", "integer", (col) => col.defaultTo(0)) - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("updated_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('date', 'date', (col) => col.notNull().unique()) + .addColumn('logins_count', 'integer', (col) => col.defaultTo(0)) + .addColumn('new_sessions_count', 'integer', (col) => col.defaultTo(0)) + .addColumn('new_flights_count', 'integer', (col) => col.defaultTo(0)) + .addColumn('new_users_count', 'integer', (col) => col.defaultTo(0)) + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); // chat_report await mainDb.schema - .createTable("chat_report") + .createTable('chat_report') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("session_id", "varchar(255)", (col) => col.notNull()) - .addColumn("message_id", "integer", (col) => col.notNull()) - .addColumn("reporter_user_id", "varchar(255)", (col) => col.notNull()) - .addColumn("reporter_username", "varchar(255)") - .addColumn("reported_user_id", "varchar(255)", (col) => col.notNull()) - .addColumn("reported_username", "varchar(255)") - .addColumn("reported_avatar", "varchar(255)") - .addColumn("reporter_avatar", "varchar(255)") - .addColumn("message", "text", (col) => col.notNull()) - .addColumn("reason", "text", (col) => col.notNull()) - .addColumn("status", "varchar(50)", (col) => col.defaultTo("pending")) - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('session_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('message_id', 'integer', (col) => col.notNull()) + .addColumn('reporter_user_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('reporter_username', 'varchar(255)') + .addColumn('reported_user_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('reported_username', 'varchar(255)') + .addColumn('reported_avatar', 'varchar(255)') + .addColumn('reporter_avatar', 'varchar(255)') + .addColumn('message', 'text', (col) => col.notNull()) + .addColumn('reason', 'text', (col) => col.notNull()) + .addColumn('status', 'varchar(50)', (col) => col.defaultTo('pending')) + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); await mainDb.schema - .createIndex("idx_chat_report_status") + .createIndex('idx_chat_report_status') .ifNotExists() - .on("chat_report") - .column("status") + .on('chat_report') + .column('status') .execute(); // update_modals await mainDb.schema - .createTable("update_modals") + .createTable('update_modals') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("title", "varchar(255)", (col) => col.notNull()) - .addColumn("content", "text", (col) => col.notNull()) - .addColumn("banner_url", "text") - .addColumn("is_active", "boolean", (col) => col.defaultTo(false).notNull()) - .addColumn("published_at", "timestamptz") - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("updated_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('title', 'varchar(255)', (col) => col.notNull()) + .addColumn('content', 'text', (col) => col.notNull()) + .addColumn('banner_url', 'text') + .addColumn('is_active', 'boolean', (col) => col.defaultTo(false).notNull()) + .addColumn('published_at', 'timestamptz') + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); // flight_logs await mainDb.schema - .createTable("flight_logs") - .ifNotExists() - .addColumn("id", "bigserial", (col) => col.primaryKey()) - .addColumn("user_id", "varchar(255)", (col) => col.notNull()) - .addColumn("username", "varchar(255)", (col) => col.notNull()) - .addColumn("session_id", "varchar(255)", (col) => col.notNull()) - .addColumn("action", "varchar(50)", (col) => col.notNull()) - .addColumn("flight_id", "varchar(255)", (col) => col.notNull()) - .addColumn("old_data", "jsonb") - .addColumn("new_data", "jsonb") - .addColumn("ip_address", "varchar(255)") - .addColumn("created_at", "timestamptz", (col) => - col.notNull().defaultTo("now()") + .createTable('flight_logs') + .ifNotExists() + .addColumn('id', 'bigserial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('username', 'varchar(255)', (col) => col.notNull()) + .addColumn('session_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('action', 'varchar(50)', (col) => col.notNull()) + .addColumn('flight_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('old_data', 'jsonb') + .addColumn('new_data', 'jsonb') + .addColumn('ip_address', 'varchar(255)') + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo('now()') ) .execute(); await mainDb.schema - .createIndex("idx_flight_logs_session_id") + .createIndex('idx_flight_logs_session_id') .ifNotExists() - .on("flight_logs") - .column("session_id") + .on('flight_logs') + .column('session_id') .execute(); await mainDb.schema - .createIndex("idx_flight_logs_created_at") + .createIndex('idx_flight_logs_created_at') .ifNotExists() - .on("flight_logs") - .column("created_at") + .on('flight_logs') + .column('created_at') .execute(); // feedback await mainDb.schema - .createTable("feedback") + .createTable('feedback') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("user_id", "varchar(255)", (col) => col.notNull()) - .addColumn("username", "varchar(255)", (col) => col.notNull()) - .addColumn("rating", "integer", (col) => col.notNull()) - .addColumn("comment", "text") - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("updated_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('username', 'varchar(255)', (col) => col.notNull()) + .addColumn('rating', 'integer', (col) => col.notNull()) + .addColumn('comment', 'text') + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); // api_logs await mainDb.schema - .createTable("api_logs") - .ifNotExists() - .addColumn("id", "bigserial", (col) => col.primaryKey()) - .addColumn("user_id", "varchar(255)") - .addColumn("username", "varchar(255)") - .addColumn("method", "varchar(10)", (col) => col.notNull()) - .addColumn("path", "text", (col) => col.notNull()) - .addColumn("status_code", "integer", (col) => col.notNull()) - .addColumn("response_time", "integer", (col) => col.notNull()) - .addColumn("ip_address", "text", (col) => col.notNull()) - .addColumn("user_agent", "text") - .addColumn("request_body", "text") - .addColumn("response_body", "text") - .addColumn("error_message", "text") - .addColumn("created_at", "timestamptz", (col) => - col.notNull().defaultTo("now()") + .createTable('api_logs') + .ifNotExists() + .addColumn('id', 'bigserial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)') + .addColumn('username', 'varchar(255)') + .addColumn('method', 'varchar(10)', (col) => col.notNull()) + .addColumn('path', 'text', (col) => col.notNull()) + .addColumn('status_code', 'integer', (col) => col.notNull()) + .addColumn('response_time', 'integer', (col) => col.notNull()) + .addColumn('ip_address', 'text', (col) => col.notNull()) + .addColumn('user_agent', 'text') + .addColumn('request_body', 'text') + .addColumn('response_body', 'text') + .addColumn('error_message', 'text') + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo('now()') ) .execute(); await mainDb.schema - .createIndex("idx_api_logs_created_at") + .createIndex('idx_api_logs_created_at') .ifNotExists() - .on("api_logs") - .column("created_at") + .on('api_logs') + .column('created_at') .execute(); await mainDb.schema - .createIndex("idx_api_logs_user_id") + .createIndex('idx_api_logs_user_id') .ifNotExists() - .on("api_logs") - .column("user_id") + .on('api_logs') + .column('user_id') .execute(); await mainDb.schema - .createIndex("api_logs_path_idx") + .createIndex('api_logs_path_idx') .ifNotExists() - .on("api_logs") - .column("path") + .on('api_logs') + .column('path') .execute(); await mainDb.schema - .createIndex("api_logs_status_code_idx") + .createIndex('api_logs_status_code_idx') .ifNotExists() - .on("api_logs") - .column("status_code") + .on('api_logs') + .column('status_code') .execute(); // controller_ratings await mainDb.schema - .createTable("controller_ratings") + .createTable('controller_ratings') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("controller_id", "varchar(255)", (col) => col.notNull()) - .addColumn("pilot_id", "varchar(255)", (col) => col.notNull()) - .addColumn("rating", "integer", (col) => col.notNull()) - .addColumn("flight_id", "varchar(255)") - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('controller_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('pilot_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('rating', 'integer', (col) => col.notNull()) + .addColumn('flight_id', 'varchar(255)') + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); await mainDb.schema - .createIndex("idx_ctrl_ratings_controller") + .createIndex('idx_ctrl_ratings_controller') .ifNotExists() - .on("controller_ratings") - .column("controller_id") + .on('controller_ratings') + .column('controller_id') .execute(); await mainDb.schema - .createTable("flights") + .createTable('flights') .ifNotExists() - .addColumn("id", "varchar(36)", (col) => col.primaryKey()) - .addColumn("session_id", "varchar(255)", (col) => col.notNull()) - .addColumn("user_id", "varchar(36)") - .addColumn("ip_address", "varchar(45)") - .addColumn("callsign", "varchar(16)") - .addColumn("aircraft", "varchar(16)") - .addColumn("flight_type", "varchar(16)") - .addColumn("departure", "varchar(4)") - .addColumn("arrival", "varchar(4)") - .addColumn("alternate", "varchar(4)") - .addColumn("route", "text") - .addColumn("sid", "varchar(16)") - .addColumn("star", "varchar(16)") - .addColumn("runway", "varchar(10)") - .addColumn("clearedfl", "varchar(8)") - .addColumn("cruisingfl", "varchar(8)") - .addColumn("stand", "varchar(8)") - .addColumn("gate", "varchar(8)") - .addColumn("remark", "text") - .addColumn("flight_plan_time", "varchar(32)") - .addColumn("status", "varchar(16)") - .addColumn("clearance", "text") - .addColumn("position", "jsonb") - .addColumn("squawk", "varchar(8)") - .addColumn("wtc", "varchar(4)") - .addColumn("hidden", "boolean", (col) => col.defaultTo(false)) - .addColumn("acars_token", "varchar(64)") - .addColumn("pdc_remarks", "text") - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("updated_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('id', 'varchar(36)', (col) => col.primaryKey()) + .addColumn('session_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('user_id', 'varchar(36)') + .addColumn('ip_address', 'varchar(45)') + .addColumn('callsign', 'varchar(16)') + .addColumn('aircraft', 'varchar(16)') + .addColumn('flight_type', 'varchar(16)') + .addColumn('departure', 'varchar(4)') + .addColumn('arrival', 'varchar(4)') + .addColumn('alternate', 'varchar(4)') + .addColumn('route', 'text') + .addColumn('sid', 'varchar(16)') + .addColumn('star', 'varchar(16)') + .addColumn('runway', 'varchar(10)') + .addColumn('clearedfl', 'varchar(8)') + .addColumn('cruisingfl', 'varchar(8)') + .addColumn('stand', 'varchar(8)') + .addColumn('gate', 'varchar(8)') + .addColumn('remark', 'text') + .addColumn('flight_plan_time', 'varchar(32)') + .addColumn('status', 'varchar(16)') + .addColumn('clearance', 'text') + .addColumn('position', 'jsonb') + .addColumn('squawk', 'varchar(8)') + .addColumn('wtc', 'varchar(4)') + .addColumn('hidden', 'boolean', (col) => col.defaultTo(false)) + .addColumn('acars_token', 'varchar(64)') + .addColumn('pdc_remarks', 'text') + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); await mainDb.schema - .createIndex("idx_flights_session_id") + .createIndex('idx_flights_session_id') .ifNotExists() - .on("flights") - .column("session_id") + .on('flights') + .column('session_id') .execute(); await mainDb.schema - .createIndex("idx_flights_callsign") + .createIndex('idx_flights_callsign') .ifNotExists() - .on("flights") - .column("callsign") + .on('flights') + .column('callsign') .execute(); await mainDb.schema - .createTable("session_chat") + .createTable('session_chat') .ifNotExists() - .addColumn("id", "bigserial", (col) => col.primaryKey()) - .addColumn("session_id", "varchar(255)", (col) => col.notNull()) - .addColumn("user_id", "varchar(36)", (col) => col.notNull()) - .addColumn("username", "varchar(255)") - .addColumn("avatar", "varchar(255)") - .addColumn("message", "text", (col) => col.notNull()) - .addColumn("mentions", "jsonb") - .addColumn("sent_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('id', 'bigserial', (col) => col.primaryKey()) + .addColumn('session_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('user_id', 'varchar(36)', (col) => col.notNull()) + .addColumn('username', 'varchar(255)') + .addColumn('avatar', 'varchar(255)') + .addColumn('message', 'text', (col) => col.notNull()) + .addColumn('mentions', 'jsonb') + .addColumn('sent_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); await mainDb.schema - .createIndex("idx_session_chat_session_sent") + .createIndex('idx_session_chat_session_sent') .ifNotExists() - .on("session_chat") - .columns(["session_id", "sent_at"]) + .on('session_chat') + .columns(['session_id', 'sent_at']) .execute(); await mainDb.schema - .createTable("global_chat") + .createTable('global_chat') .ifNotExists() - .addColumn("id", "bigserial", (col) => col.primaryKey()) - .addColumn("user_id", "varchar(255)", (col) => col.notNull()) - .addColumn("username", "varchar(255)") - .addColumn("avatar", "varchar(255)") - .addColumn("station", "varchar(50)") - .addColumn("position", "varchar(50)") - .addColumn("message", "jsonb", (col) => col.notNull()) - .addColumn("airport_mentions", "jsonb") - .addColumn("user_mentions", "jsonb") - .addColumn("sent_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("deleted_at", "timestamptz") - .addColumn("network_kind", "varchar(20)", (col) => - col.defaultTo("pfatc").notNull() + .addColumn('id', 'bigserial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('username', 'varchar(255)') + .addColumn('avatar', 'varchar(255)') + .addColumn('station', 'varchar(50)') + .addColumn('position', 'varchar(50)') + .addColumn('message', 'jsonb', (col) => col.notNull()) + .addColumn('airport_mentions', 'jsonb') + .addColumn('user_mentions', 'jsonb') + .addColumn('sent_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('deleted_at', 'timestamptz') + .addColumn('network_kind', 'varchar(20)', (col) => + col.defaultTo('pfatc').notNull() ) .execute(); await mainDb.schema - .createIndex("global_chat_sent_at_idx") + .createIndex('global_chat_sent_at_idx') .ifNotExists() - .on("global_chat") - .column("sent_at") + .on('global_chat') + .column('sent_at') .execute(); // vpn_exceptions await mainDb.schema - .createTable("vpn_exceptions") + .createTable('vpn_exceptions') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("user_id", "varchar(255)", (col) => - col.references("users.id").onDelete("cascade").unique().notNull() + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => + col.references('users.id').onDelete('cascade').unique().notNull() ) - .addColumn("username", "varchar(255)", (col) => col.notNull()) - .addColumn("added_by", "varchar(255)", (col) => col.notNull()) - .addColumn("added_by_username", "varchar(255)", (col) => col.notNull()) - .addColumn("notes", "text") - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("updated_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('username', 'varchar(255)', (col) => col.notNull()) + .addColumn('added_by', 'varchar(255)', (col) => col.notNull()) + .addColumn('added_by_username', 'varchar(255)', (col) => col.notNull()) + .addColumn('notes', 'text') + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); // vpn_gate_settings await mainDb.schema - .createTable("vpn_gate_settings") + .createTable('vpn_gate_settings') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("setting_key", "varchar(255)", (col) => col.unique().notNull()) - .addColumn("setting_value", "boolean", (col) => col.notNull()) - .addColumn("updated_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('setting_key', 'varchar(255)', (col) => col.unique().notNull()) + .addColumn('setting_value', 'boolean', (col) => col.notNull()) + .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); // developer_applications await mainDb.schema - .createTable("developer_applications") + .createTable('developer_applications') .ifNotExists() - .addColumn("id", "serial", (col) => col.primaryKey()) - .addColumn("user_id", "varchar(255)", (col) => - col.notNull().references("users.id").onDelete("cascade") + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => + col.notNull().references('users.id').onDelete('cascade') ) - .addColumn("who_text", "text", (col) => col.notNull()) - .addColumn("why_text", "text", (col) => col.notNull()) - .addColumn("requested_scopes", "jsonb", (col) => col.notNull()) - .addColumn("status", "varchar(32)", (col) => - col.notNull().defaultTo("pending") + .addColumn('who_text', 'text', (col) => col.notNull()) + .addColumn('why_text', 'text', (col) => col.notNull()) + .addColumn('requested_scopes', 'jsonb', (col) => col.notNull()) + .addColumn('status', 'varchar(32)', (col) => + col.notNull().defaultTo('pending') ) - .addColumn("reviewed_by", "varchar(255)") - .addColumn("reviewed_at", "timestamptz") - .addColumn("reviewer_note", "text") - .addColumn("approved_scopes", "jsonb") - .addColumn("created_at", "timestamptz", (col) => - col.notNull().defaultTo("now()") + .addColumn('reviewed_by', 'varchar(255)') + .addColumn('reviewed_at', 'timestamptz') + .addColumn('reviewer_note', 'text') + .addColumn('approved_scopes', 'jsonb') + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo('now()') ) - .addColumn("updated_at", "timestamptz", (col) => - col.notNull().defaultTo("now()") + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo('now()') ) .execute(); await mainDb.schema - .createIndex("idx_developer_applications_user_id") + .createIndex('idx_developer_applications_user_id') .ifNotExists() - .on("developer_applications") - .column("user_id") + .on('developer_applications') + .column('user_id') .execute(); await mainDb.schema - .createIndex("idx_developer_applications_status") + .createIndex('idx_developer_applications_status') .ifNotExists() - .on("developer_applications") - .column("status") + .on('developer_applications') + .column('status') .execute(); await sql` @@ -584,91 +584,91 @@ export async function createMainTables() { // developer_profiles await mainDb.schema - .createTable("developer_profiles") + .createTable('developer_profiles') .ifNotExists() - .addColumn("user_id", "varchar(255)", (col) => - col.primaryKey().references("users.id").onDelete("cascade") + .addColumn('user_id', 'varchar(255)', (col) => + col.primaryKey().references('users.id').onDelete('cascade') ) - .addColumn("approved_scopes", "jsonb", (col) => col.notNull()) - .addColumn("status", "varchar(32)", (col) => - col.notNull().defaultTo("active") + .addColumn('approved_scopes', 'jsonb', (col) => col.notNull()) + .addColumn('status', 'varchar(32)', (col) => + col.notNull().defaultTo('active') ) - .addColumn("created_at", "timestamptz", (col) => - col.notNull().defaultTo("now()") + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo('now()') ) - .addColumn("updated_at", "timestamptz", (col) => - col.notNull().defaultTo("now()") + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo('now()') ) .execute(); // developer_api_keys await mainDb.schema - .createTable("developer_api_keys") + .createTable('developer_api_keys') .ifNotExists() - .addColumn("id", "bigserial", (col) => col.primaryKey()) - .addColumn("user_id", "varchar(255)", (col) => - col.notNull().references("users.id").onDelete("cascade") + .addColumn('id', 'bigserial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => + col.notNull().references('users.id').onDelete('cascade') ) - .addColumn("name", "varchar(255)", (col) => col.notNull()) - .addColumn("prefix", "varchar(64)", (col) => col.notNull()) - .addColumn("secret_hash", "varchar(64)", (col) => col.notNull()) - .addColumn("scopes", "jsonb", (col) => col.notNull()) - .addColumn("created_at", "timestamptz", (col) => - col.notNull().defaultTo("now()") + .addColumn('name', 'varchar(255)', (col) => col.notNull()) + .addColumn('prefix', 'varchar(64)', (col) => col.notNull()) + .addColumn('secret_hash', 'varchar(64)', (col) => col.notNull()) + .addColumn('scopes', 'jsonb', (col) => col.notNull()) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo('now()') ) - .addColumn("last_used_at", "timestamptz") - .addColumn("revoked_at", "timestamptz") + .addColumn('last_used_at', 'timestamptz') + .addColumn('revoked_at', 'timestamptz') .execute(); await mainDb.schema - .createIndex("idx_developer_api_keys_user_id") + .createIndex('idx_developer_api_keys_user_id') .ifNotExists() - .on("developer_api_keys") - .column("user_id") + .on('developer_api_keys') + .column('user_id') .execute(); await mainDb.schema - .createIndex("idx_developer_api_keys_secret_hash") + .createIndex('idx_developer_api_keys_secret_hash') .ifNotExists() - .on("developer_api_keys") - .column("secret_hash") + .on('developer_api_keys') + .column('secret_hash') .execute(); // developer_api_usage await mainDb.schema - .createTable("developer_api_usage") + .createTable('developer_api_usage') .ifNotExists() - .addColumn("id", "bigserial", (col) => col.primaryKey()) - .addColumn("key_id", "bigint", (col) => - col.notNull().references("developer_api_keys.id").onDelete("cascade") + .addColumn('id', 'bigserial', (col) => col.primaryKey()) + .addColumn('key_id', 'bigint', (col) => + col.notNull().references('developer_api_keys.id').onDelete('cascade') ) - .addColumn("user_id", "varchar(255)", (col) => - col.notNull().references("users.id").onDelete("cascade") + .addColumn('user_id', 'varchar(255)', (col) => + col.notNull().references('users.id').onDelete('cascade') ) - .addColumn("scope_id", "varchar(128)", (col) => col.notNull()) - .addColumn("method", "varchar(10)", (col) => col.notNull()) - .addColumn("path", "text", (col) => col.notNull()) - .addColumn("status_code", "integer", (col) => col.notNull()) - .addColumn("duration_ms", "integer", (col) => col.notNull()) - .addColumn("ip_hash", "varchar(64)") - .addColumn("client_ip", "varchar(128)") - .addColumn("created_at", "timestamptz", (col) => - col.notNull().defaultTo("now()") + .addColumn('scope_id', 'varchar(128)', (col) => col.notNull()) + .addColumn('method', 'varchar(10)', (col) => col.notNull()) + .addColumn('path', 'text', (col) => col.notNull()) + .addColumn('status_code', 'integer', (col) => col.notNull()) + .addColumn('duration_ms', 'integer', (col) => col.notNull()) + .addColumn('ip_hash', 'varchar(64)') + .addColumn('client_ip', 'varchar(128)') + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo('now()') ) .execute(); await mainDb.schema - .createIndex("idx_developer_api_usage_key_created") + .createIndex('idx_developer_api_usage_key_created') .ifNotExists() - .on("developer_api_usage") - .columns(["key_id", "created_at"]) + .on('developer_api_usage') + .columns(['key_id', 'created_at']) .execute(); await mainDb.schema - .createIndex("idx_developer_api_usage_user_created") + .createIndex('idx_developer_api_usage_user_created') .ifNotExists() - .on("developer_api_usage") - .columns(["user_id", "created_at"]) + .on('developer_api_usage') + .columns(['user_id', 'created_at']) .execute(); } @@ -716,10 +716,10 @@ export async function ensureSessionsDeveloperApiKeyColumn() { `.execute(mainDb); await mainDb.schema - .createIndex("idx_sessions_developer_api_key_id") + .createIndex('idx_sessions_developer_api_key_id') .ifNotExists() - .on("sessions") - .column("developer_api_key_id") + .on('sessions') + .column('developer_api_key_id') .execute(); } @@ -831,35 +831,35 @@ export async function ensureFlightReqColumns() { export async function ensureDailyDatabaseMetricsTables() { await mainDb.schema - .createTable("daily_table_activity") - .ifNotExists() - .addColumn("activity_date", "date", (col) => col.notNull()) - .addColumn("table_name", "varchar(128)", (col) => col.notNull()) - .addColumn("rows_inserted", "integer", (col) => col.notNull().defaultTo(0)) - .addColumn("rows_deleted", "integer", (col) => col.notNull().defaultTo(0)) - .addColumn("table_bytes", "bigint", (col) => col.notNull().defaultTo(0)) - .addColumn("row_count", "bigint", (col) => col.notNull().defaultTo(0)) - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) - .addColumn("updated_at", "timestamptz", (col) => col.defaultTo("now()")) - .addPrimaryKeyConstraint("daily_table_activity_pkey", [ - "activity_date", - "table_name", + .createTable('daily_table_activity') + .ifNotExists() + .addColumn('activity_date', 'date', (col) => col.notNull()) + .addColumn('table_name', 'varchar(128)', (col) => col.notNull()) + .addColumn('rows_inserted', 'integer', (col) => col.notNull().defaultTo(0)) + .addColumn('rows_deleted', 'integer', (col) => col.notNull().defaultTo(0)) + .addColumn('table_bytes', 'bigint', (col) => col.notNull().defaultTo(0)) + .addColumn('row_count', 'bigint', (col) => col.notNull().defaultTo(0)) + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo('now()')) + .addPrimaryKeyConstraint('daily_table_activity_pkey', [ + 'activity_date', + 'table_name', ]) .execute(); await mainDb.schema - .createIndex("idx_daily_table_activity_date") + .createIndex('idx_daily_table_activity_date') .ifNotExists() - .on("daily_table_activity") - .column("activity_date") + .on('daily_table_activity') + .column('activity_date') .execute(); await mainDb.schema - .createTable("daily_database_totals") + .createTable('daily_database_totals') .ifNotExists() - .addColumn("activity_date", "date", (col) => col.primaryKey()) - .addColumn("total_bytes", "bigint", (col) => col.notNull()) - .addColumn("created_at", "timestamptz", (col) => col.defaultTo("now()")) + .addColumn('activity_date', 'date', (col) => col.primaryKey()) + .addColumn('total_bytes', 'bigint', (col) => col.notNull()) + .addColumn('created_at', 'timestamptz', (col) => col.defaultTo('now()')) .execute(); await ensureDailyTableActivityBigintColumns(); @@ -877,28 +877,28 @@ export async function ensureDailyTableActivityBigintColumns() { export async function ensureWebsocketSnapshotsTable() { await mainDb.schema - .createTable("websocket_snapshots") + .createTable('websocket_snapshots') .ifNotExists() - .addColumn("id", "bigserial", (col) => col.primaryKey()) - .addColumn("namespace_id", "varchar(64)", (col) => col.notNull()) - .addColumn("connected_count", "integer", (col) => col.notNull()) - .addColumn("sampled_at", "timestamptz", (col) => - col.notNull().defaultTo("now()") + .addColumn('id', 'bigserial', (col) => col.primaryKey()) + .addColumn('namespace_id', 'varchar(64)', (col) => col.notNull()) + .addColumn('connected_count', 'integer', (col) => col.notNull()) + .addColumn('sampled_at', 'timestamptz', (col) => + col.notNull().defaultTo('now()') ) .execute(); await mainDb.schema - .createIndex("idx_websocket_snapshots_sampled_at") + .createIndex('idx_websocket_snapshots_sampled_at') .ifNotExists() - .on("websocket_snapshots") - .column("sampled_at") + .on('websocket_snapshots') + .column('sampled_at') .execute(); await mainDb.schema - .createIndex("idx_websocket_snapshots_namespace_sampled") + .createIndex('idx_websocket_snapshots_namespace_sampled') .ifNotExists() - .on("websocket_snapshots") - .columns(["namespace_id", "sampled_at"]) + .on('websocket_snapshots') + .columns(['namespace_id', 'sampled_at']) .execute(); } @@ -933,29 +933,29 @@ export async function syncVersionFromEnv(redis?: Redis) { } await mainDb - .insertInto("app_settings") + .insertInto('app_settings') .values({ version: envVersion, channel: DEPLOYMENT, updated_at: new Date(), - updated_by: "system", + updated_by: 'system', }) .onConflict((oc) => - oc.column("channel").doUpdateSet({ + oc.column('channel').doUpdateSet({ version: envVersion, updated_at: new Date(), - updated_by: "system", + updated_by: 'system', }) ) .execute(); if (redis) { try { - await redis.del(prefixKey("app:version")); + await redis.del(prefixKey('app:version')); } catch (error) { - console.warn("[Version] Failed to invalidate version cache:", error); + console.warn('[Version] Failed to invalidate version cache:', error); } } console.log(`[Version] Synced channel '${DEPLOYMENT}' to ${envVersion}`); -} \ No newline at end of file +} diff --git a/server/db/sessions.ts b/server/db/sessions.ts index 8730a837..3032dcf7 100644 --- a/server/db/sessions.ts +++ b/server/db/sessions.ts @@ -1,13 +1,13 @@ -import { sql } from "kysely"; -import { mainDb } from "./connection.js"; -import { addFlight } from "./flights.js"; -import { validateSessionId } from "../utils/validation.js"; -import { encrypt } from "../utils/encryption.js"; +import { sql } from 'kysely'; +import { mainDb } from './connection.js'; +import { addFlight } from './flights.js'; +import { validateSessionId } from '../utils/validation.js'; +import { encrypt } from '../utils/encryption.js'; import { assertExclusiveSessionNetworkFlags, ExclusiveSessionNetworkFlagsError, isPostgresCheckViolation, -} from "../utils/sessionNetworkFlags.js"; +} from '../utils/sessionNetworkFlags.js'; interface CreateSessionParams { sessionId: string; accessId: string; @@ -33,8 +33,8 @@ export async function createSession({ const validSessionId = validateSessionId(sessionId); const encryptedAtis = encrypt({ - letter: "A", - text: "", + letter: 'A', + text: '', timestamp: new Date().toISOString(), }); @@ -54,9 +54,9 @@ export async function createSession({ atis: JSON.stringify(encryptedAtis), ...(developerApiKeyId ? { developer_api_key_id: developerApiKeyId } : {}), }; - await mainDb.insertInto("sessions").values(baseValues).execute(); + await mainDb.insertInto('sessions').values(baseValues).execute(); const { setSessionMetaFromRow } = - await import("../realtime/activeSessions.js"); + await import('../realtime/activeSessions.js'); await setSessionMetaFromRow({ session_id: validSessionId, airport_icao: airportIcao.toUpperCase(), @@ -76,20 +76,20 @@ export async function createSession({ if (isTutorial) { await addFlight(sessionId, { - callsign: "DLH123", - aircraft: "A320", - flight_type: "IFR", + callsign: 'DLH123', + aircraft: 'A320', + flight_type: 'IFR', departure: airportIcao, - arrival: "EGKK", - stand: "EXAMPLE", - runway: activeRunway || "", - sid: "RADAR VECTORS", + arrival: 'EGKK', + stand: 'EXAMPLE', + runway: activeRunway || '', + sid: 'RADAR VECTORS', cruisingFL: 340, clearedFL: 140, - squawk: "1234", - wtc: "M", - status: "PENDING", - remark: "Example", + squawk: '1234', + wtc: 'M', + status: 'PENDING', + remark: 'Example', hidden: false, }); } @@ -98,59 +98,59 @@ export async function createSession({ export async function getSessionById(sessionId: string) { return ( (await mainDb - .selectFrom("sessions") + .selectFrom('sessions') .selectAll() - .where("session_id", "=", sessionId) + .where('session_id', '=', sessionId) .executeTakeFirst()) || null ); } export async function getSessionsByUser(userId: string) { return await mainDb - .selectFrom("sessions") + .selectFrom('sessions') .select([ - "session_id", - "access_id", - "active_runway", - "airport_icao", - "created_at", - "created_by", - "is_pfatc", - "is_advanced_atc", - "custom_name", - "refreshed_at", + 'session_id', + 'access_id', + 'active_runway', + 'airport_icao', + 'created_at', + 'created_by', + 'is_pfatc', + 'is_advanced_atc', + 'custom_name', + 'refreshed_at', ]) - .where("created_by", "=", userId) - .orderBy("created_at", "desc") + .where('created_by', '=', userId) + .orderBy('created_at', 'desc') .execute(); } export async function listDeveloperSessionSummariesForUser(userId: string) { return mainDb - .selectFrom("sessions") + .selectFrom('sessions') .select([ - "session_id", - "active_runway", - "airport_icao", - "created_at", - "created_by", - "is_pfatc", - "is_advanced_atc", - "custom_name", - "refreshed_at", - "developer_api_key_id", + 'session_id', + 'active_runway', + 'airport_icao', + 'created_at', + 'created_by', + 'is_pfatc', + 'is_advanced_atc', + 'custom_name', + 'refreshed_at', + 'developer_api_key_id', ]) - .where("created_by", "=", userId) - .orderBy("created_at", "desc") + .where('created_by', '=', userId) + .orderBy('created_at', 'desc') .execute(); } export async function getSessionsByUserDetailed(userId: string) { return await mainDb - .selectFrom("sessions") + .selectFrom('sessions') .selectAll() - .where("created_by", "=", userId) - .orderBy("created_at", "desc") + .where('created_by', '=', userId) + .orderBy('created_at', 'desc') .execute(); } @@ -203,7 +203,7 @@ export async function updateSession( try { const result = await mainDb - .updateTable("sessions") + .updateTable('sessions') .set({ ...patch, airport_icao: patch.airport_icao?.toUpperCase(), @@ -211,16 +211,16 @@ export async function updateSession( ? new Date(patch.refreshed_at) : undefined, }) - .where("session_id", "=", sessionId) + .where('session_id', '=', sessionId) .returningAll() .executeTakeFirst(); if (result) { const { setSessionMetaFromRow } = - await import("../realtime/activeSessions.js"); + await import('../realtime/activeSessions.js'); await setSessionMetaFromRow(result); if (patch.atis !== undefined) { - const { onAtisChanged } = await import("../realtime/invalidate.js"); + const { onAtisChanged } = await import('../realtime/invalidate.js'); void onAtisChanged(sessionId); } } @@ -233,13 +233,13 @@ export async function updateSession( } } -export { ExclusiveSessionNetworkFlagsError } from "../utils/sessionNetworkFlags.js"; +export { ExclusiveSessionNetworkFlagsError } from '../utils/sessionNetworkFlags.js'; export async function updateSessionName(sessionId: string, customName: string) { return await mainDb - .updateTable("sessions") + .updateTable('sessions') .set({ custom_name: customName }) - .where("session_id", "=", sessionId) + .where('session_id', '=', sessionId) .returningAll() .executeTakeFirst(); } @@ -248,46 +248,46 @@ export async function deleteSession(sessionId: string) { const validSessionId = validateSessionId(sessionId); await mainDb - .deleteFrom("flights") - .where("session_id", "=", validSessionId) + .deleteFrom('flights') + .where('session_id', '=', validSessionId) .execute(); await mainDb - .deleteFrom("session_chat") - .where("session_id", "=", validSessionId) + .deleteFrom('session_chat') + .where('session_id', '=', validSessionId) .execute(); await mainDb - .deleteFrom("sessions") - .where("session_id", "=", validSessionId) + .deleteFrom('sessions') + .where('session_id', '=', validSessionId) .execute(); } export async function getAllSessions() { return await mainDb - .selectFrom("sessions") + .selectFrom('sessions') .selectAll() - .orderBy("created_at", "desc") + .orderBy('created_at', 'desc') .execute(); } export async function getSessionsByAirportAndNetwork( airportIcao: string, - networkKind: "pfatc" | "advanced_atc" + networkKind: 'pfatc' | 'advanced_atc' ) { const query = mainDb - .selectFrom("sessions") + .selectFrom('sessions') .selectAll() - .where("airport_icao", "=", airportIcao.toUpperCase()); + .where('airport_icao', '=', airportIcao.toUpperCase()); - if (networkKind === "pfatc") { - return query.where("is_pfatc", "=", true).execute(); + if (networkKind === 'pfatc') { + return query.where('is_pfatc', '=', true).execute(); } else { - return query.where("is_advanced_atc", "=", true).execute(); + return query.where('is_advanced_atc', '=', true).execute(); } } -export type DeveloperPublicNetworkKind = "pfatc" | "aatc"; +export type DeveloperPublicNetworkKind = 'pfatc' | 'aatc'; export type PublicNetworkSessionDeveloperRow = { session_id: string; @@ -311,40 +311,40 @@ export async function listPublicNetworkSessionsForDeveloperApi(opts: { const limit = Math.min(100, Math.max(1, opts.limit)); const offset = Math.max(0, opts.offset); const icao = - opts.airportIcao && typeof opts.airportIcao === "string" + opts.airportIcao && typeof opts.airportIcao === 'string' ? opts.airportIcao.trim().toUpperCase() : null; let q = mainDb - .selectFrom("sessions as s") - .innerJoin("users as u", "u.id", "s.created_by") + .selectFrom('sessions as s') + .innerJoin('users as u', 'u.id', 's.created_by') .select([ - "s.session_id", - "s.airport_icao", - "s.active_runway", - "s.custom_name", - "s.created_at", - "s.refreshed_at", - "s.created_by", + 's.session_id', + 's.airport_icao', + 's.active_runway', + 's.custom_name', + 's.created_at', + 's.refreshed_at', + 's.created_by', sql`coalesce((select count(*)::int from flights f where f.session_id = s.session_id), 0)`.as( - "flight_count" + 'flight_count' ), - "u.username", - "u.avatar", + 'u.username', + 'u.avatar', ]); - if (opts.kind === "pfatc") { - q = q.where("s.is_pfatc", "=", true); + if (opts.kind === 'pfatc') { + q = q.where('s.is_pfatc', '=', true); } else { - q = q.where("s.is_advanced_atc", "=", true); + q = q.where('s.is_advanced_atc', '=', true); } if (icao && /^[A-Z]{4}$/.test(icao)) { - q = q.where("s.airport_icao", "=", icao); + q = q.where('s.airport_icao', '=', icao); } return await q .orderBy(sql`s.refreshed_at desc nulls last`) - .orderBy("s.created_at", "desc") + .orderBy('s.created_at', 'desc') .limit(limit) .offset(offset) .execute(); @@ -356,29 +356,29 @@ export async function getPublicNetworkSessionForDeveloperApi( ): Promise { const valid = validateSessionId(sessionId); let q = mainDb - .selectFrom("sessions as s") - .innerJoin("users as u", "u.id", "s.created_by") + .selectFrom('sessions as s') + .innerJoin('users as u', 'u.id', 's.created_by') .select([ - "s.session_id", - "s.airport_icao", - "s.active_runway", - "s.custom_name", - "s.created_at", - "s.refreshed_at", - "s.created_by", + 's.session_id', + 's.airport_icao', + 's.active_runway', + 's.custom_name', + 's.created_at', + 's.refreshed_at', + 's.created_by', sql`coalesce((select count(*)::int from flights f where f.session_id = s.session_id), 0)`.as( - "flight_count" + 'flight_count' ), - "u.username", - "u.avatar", + 'u.username', + 'u.avatar', ]) - .where("s.session_id", "=", valid); - if (kind === "pfatc") { - q = q.where("s.is_pfatc", "=", true); + .where('s.session_id', '=', valid); + if (kind === 'pfatc') { + q = q.where('s.is_pfatc', '=', true); } else { - q = q.where("s.is_advanced_atc", "=", true); + q = q.where('s.is_advanced_atc', '=', true); } const row = await q.executeTakeFirst(); return row ?? null; -} \ No newline at end of file +} diff --git a/server/db/sitemapProfiles.ts b/server/db/sitemapProfiles.ts index 6659cb22..e81bc115 100644 --- a/server/db/sitemapProfiles.ts +++ b/server/db/sitemapProfiles.ts @@ -58,4 +58,4 @@ export async function querySitemapProfileUsernames( } finally { await db.destroy(); } -} \ No newline at end of file +} diff --git a/server/db/statistics.ts b/server/db/statistics.ts index 29fa04b8..30f0b4e0 100644 --- a/server/db/statistics.ts +++ b/server/db/statistics.ts @@ -1,22 +1,22 @@ -import { mainDb } from "./connection.js"; -import { sql } from "kysely"; -import { recordTableDeletes } from "./databaseMetrics.js"; +import { mainDb } from './connection.js'; +import { sql } from 'kysely'; +import { recordTableDeletes } from './databaseMetrics.js'; export async function recordLogin() { try { const today = new Date(); await mainDb - .insertInto("daily_statistics") + .insertInto('daily_statistics') .values({ id: sql`DEFAULT`, date: today, logins_count: 1 }) .onConflict((oc) => - oc.column("date").doUpdateSet({ + oc.column('date').doUpdateSet({ logins_count: sql`daily_statistics.logins_count + 1`, updated_at: sql`NOW()`, }) ) .execute(); } catch (error) { - console.error("Error recording login:", error); + console.error('Error recording login:', error); } } @@ -24,17 +24,17 @@ export async function recordNewSession() { try { const today = new Date(); await mainDb - .insertInto("daily_statistics") + .insertInto('daily_statistics') .values({ id: sql`DEFAULT`, date: today, new_sessions_count: 1 }) .onConflict((oc) => - oc.column("date").doUpdateSet({ + oc.column('date').doUpdateSet({ new_sessions_count: sql`daily_statistics.new_sessions_count + 1`, updated_at: sql`NOW()`, }) ) .execute(); } catch (error) { - console.error("Error recording new session:", error); + console.error('Error recording new session:', error); } } @@ -42,17 +42,17 @@ export async function recordNewFlight() { try { const today = new Date(); await mainDb - .insertInto("daily_statistics") + .insertInto('daily_statistics') .values({ id: sql`DEFAULT`, date: today, new_flights_count: 1 }) .onConflict((oc) => - oc.column("date").doUpdateSet({ + oc.column('date').doUpdateSet({ new_flights_count: sql`daily_statistics.new_flights_count + 1`, updated_at: sql`NOW()`, }) ) .execute(); } catch (error) { - console.error("Error recording new flight:", error); + console.error('Error recording new flight:', error); } } @@ -60,17 +60,17 @@ export async function recordNewUser() { try { const today = new Date(); await mainDb - .insertInto("daily_statistics") + .insertInto('daily_statistics') .values({ id: sql`DEFAULT`, date: today, new_users_count: 1 }) .onConflict((oc) => - oc.column("date").doUpdateSet({ + oc.column('date').doUpdateSet({ new_users_count: sql`daily_statistics.new_users_count + 1`, updated_at: sql`NOW()`, }) ) .execute(); } catch (error) { - console.error("Error recording new user:", error); + console.error('Error recording new user:', error); } } @@ -89,16 +89,16 @@ export async function cleanupOldStatistics() { Date.now() - 365 * 24 * 60 * 60 * 1000 ); const result = await mainDb - .deleteFrom("daily_statistics") - .where("date", "<", threeSixtyFiveDaysAgo) + .deleteFrom('daily_statistics') + .where('date', '<', threeSixtyFiveDaysAgo) .executeTakeFirst(); await recordTableDeletes( - "daily_statistics", + 'daily_statistics', Number(result?.numDeletedRows ?? 0) ); lastCleanupTime = now; } catch (error) { - console.error("Error cleaning up old statistics:", error); + console.error('Error cleaning up old statistics:', error); } -} \ No newline at end of file +} diff --git a/server/db/types/connection/MainDatabase.ts b/server/db/types/connection/MainDatabase.ts index daefb05a..b1cc0cfc 100644 --- a/server/db/types/connection/MainDatabase.ts +++ b/server/db/types/connection/MainDatabase.ts @@ -1,35 +1,35 @@ -import { AppSettingsTable } from "./main/AppSettingsTable"; -import { UsersTable } from "./main/UsersTable"; -import { SessionsTable } from "./main/SessionsTable"; -import { RolesTable } from "./main/RolesTable"; -import { UserRolesTable } from "./main/UserRolesTable"; -import { AuditLogTable } from "./main/AuditLogTable"; -import { BansTable } from "./main/BansTable"; -import { NotificationsTable } from "./main/NotificationsTable"; -import { UserNotificationsTable } from "./main/UserNotificationsTable"; -import { TestersTable } from "./main/TestersTable"; -import { TesterSettingsTable } from "./main/TesterSettingsTable"; -import { DailyStatisticsTable } from "./main/DailyStatisticsTable"; -import { ChatReportsTable } from "./main/ChatReportsTable"; -import { UpdateModalsTable } from "./main/UpdateModalsTable"; -import { FlightLogsTable } from "./main/FlightLogsTable"; -import { FeedbackTable } from "./main/FeedbackTable"; -import { ApiLogsTable } from "./main/ApiLogsTable"; -import { ControllerRatingsTable } from "./main/ControllerRatingsTable"; -import { FlightsTable } from "./main/FlightsTable"; -import { SessionChatTable } from "./main/SessionChatTable"; -import { GlobalChatTable } from "./main/GlobalChatTable"; -import { VpnExceptionsTable } from "./main/VpnExceptionsTable"; -import { VpnGateSettingsTable } from "./main/VpnGateSettingsTable"; -import { DeveloperApplicationsTable } from "./main/DeveloperApplicationsTable"; -import { DeveloperProfilesTable } from "./main/DeveloperProfilesTable"; -import { DeveloperApiKeysTable } from "./main/DeveloperApiKeysTable"; -import { DeveloperApiUsageTable } from "./main/DeveloperApiUsageTable"; +import { AppSettingsTable } from './main/AppSettingsTable'; +import { UsersTable } from './main/UsersTable'; +import { SessionsTable } from './main/SessionsTable'; +import { RolesTable } from './main/RolesTable'; +import { UserRolesTable } from './main/UserRolesTable'; +import { AuditLogTable } from './main/AuditLogTable'; +import { BansTable } from './main/BansTable'; +import { NotificationsTable } from './main/NotificationsTable'; +import { UserNotificationsTable } from './main/UserNotificationsTable'; +import { TestersTable } from './main/TestersTable'; +import { TesterSettingsTable } from './main/TesterSettingsTable'; +import { DailyStatisticsTable } from './main/DailyStatisticsTable'; +import { ChatReportsTable } from './main/ChatReportsTable'; +import { UpdateModalsTable } from './main/UpdateModalsTable'; +import { FlightLogsTable } from './main/FlightLogsTable'; +import { FeedbackTable } from './main/FeedbackTable'; +import { ApiLogsTable } from './main/ApiLogsTable'; +import { ControllerRatingsTable } from './main/ControllerRatingsTable'; +import { FlightsTable } from './main/FlightsTable'; +import { SessionChatTable } from './main/SessionChatTable'; +import { GlobalChatTable } from './main/GlobalChatTable'; +import { VpnExceptionsTable } from './main/VpnExceptionsTable'; +import { VpnGateSettingsTable } from './main/VpnGateSettingsTable'; +import { DeveloperApplicationsTable } from './main/DeveloperApplicationsTable'; +import { DeveloperProfilesTable } from './main/DeveloperProfilesTable'; +import { DeveloperApiKeysTable } from './main/DeveloperApiKeysTable'; +import { DeveloperApiUsageTable } from './main/DeveloperApiUsageTable'; import { DailyDatabaseTotalsTable, DailyTableActivityTable, -} from "./main/DailyTableActivityTable"; -import { WebsocketSnapshotsTable } from "./main/WebsocketSnapshotsTable"; +} from './main/DailyTableActivityTable'; +import { WebsocketSnapshotsTable } from './main/WebsocketSnapshotsTable'; export interface MainDatabase { app_settings: AppSettingsTable; @@ -62,4 +62,4 @@ export interface MainDatabase { daily_table_activity: DailyTableActivityTable; daily_database_totals: DailyDatabaseTotalsTable; websocket_snapshots: WebsocketSnapshotsTable; -} \ No newline at end of file +} diff --git a/server/db/types/connection/main/AppSettingsTable.ts b/server/db/types/connection/main/AppSettingsTable.ts index ac132aea..06f063d6 100644 --- a/server/db/types/connection/main/AppSettingsTable.ts +++ b/server/db/types/connection/main/AppSettingsTable.ts @@ -1,4 +1,4 @@ -import { Generated } from "kysely"; +import { Generated } from 'kysely'; export interface AppSettingsTable { id: Generated; @@ -8,4 +8,4 @@ export interface AppSettingsTable { channel: string; pfatc_event_mode: boolean | null; aatc_event_mode: boolean | null; -} \ No newline at end of file +} diff --git a/server/db/types/connection/main/ControllerRatingsTable.ts b/server/db/types/connection/main/ControllerRatingsTable.ts index cbb4bfae..9c31a73b 100644 --- a/server/db/types/connection/main/ControllerRatingsTable.ts +++ b/server/db/types/connection/main/ControllerRatingsTable.ts @@ -7,4 +7,4 @@ export interface ControllerRatingsTable { rating: number; flight_id: string | null; created_at: Generated; -} \ No newline at end of file +} diff --git a/server/db/types/connection/main/DailyTableActivityTable.ts b/server/db/types/connection/main/DailyTableActivityTable.ts index 6adea35b..5f6d28ff 100644 --- a/server/db/types/connection/main/DailyTableActivityTable.ts +++ b/server/db/types/connection/main/DailyTableActivityTable.ts @@ -13,4 +13,4 @@ export interface DailyDatabaseTotalsTable { activity_date: Date; total_bytes: number; created_at?: Date; -} \ No newline at end of file +} diff --git a/server/db/types/connection/main/DeveloperApiKeysTable.ts b/server/db/types/connection/main/DeveloperApiKeysTable.ts index c06fbe62..067a9c46 100644 --- a/server/db/types/connection/main/DeveloperApiKeysTable.ts +++ b/server/db/types/connection/main/DeveloperApiKeysTable.ts @@ -1,4 +1,4 @@ -import type { Generated } from "kysely"; +import type { Generated } from 'kysely'; export interface DeveloperApiKeysTable { id: Generated; @@ -16,4 +16,4 @@ export interface DeveloperApiKeysTable { created_at: Date; last_used_at: Date | null; revoked_at: Date | null; -} \ No newline at end of file +} diff --git a/server/db/types/connection/main/DeveloperApiUsageTable.ts b/server/db/types/connection/main/DeveloperApiUsageTable.ts index 1de4c4e2..43fe80b6 100644 --- a/server/db/types/connection/main/DeveloperApiUsageTable.ts +++ b/server/db/types/connection/main/DeveloperApiUsageTable.ts @@ -1,4 +1,4 @@ -import type { Generated } from "kysely"; +import type { Generated } from 'kysely'; export interface DeveloperApiUsageTable { id: Generated; @@ -12,4 +12,4 @@ export interface DeveloperApiUsageTable { ip_hash: string | null; client_ip: string | null; created_at: Date; -} \ No newline at end of file +} diff --git a/server/db/types/connection/main/DeveloperApplicationsTable.ts b/server/db/types/connection/main/DeveloperApplicationsTable.ts index e43fa718..8217f1c6 100644 --- a/server/db/types/connection/main/DeveloperApplicationsTable.ts +++ b/server/db/types/connection/main/DeveloperApplicationsTable.ts @@ -11,4 +11,4 @@ export interface DeveloperApplicationsTable { approved_scopes: unknown | null; created_at: Date; updated_at: Date; -} \ No newline at end of file +} diff --git a/server/db/types/connection/main/DeveloperProfilesTable.ts b/server/db/types/connection/main/DeveloperProfilesTable.ts index 31667d40..8913ade2 100644 --- a/server/db/types/connection/main/DeveloperProfilesTable.ts +++ b/server/db/types/connection/main/DeveloperProfilesTable.ts @@ -9,4 +9,4 @@ export interface DeveloperProfilesTable { notification_email: string | null; created_at: Date; updated_at: Date; -} \ No newline at end of file +} diff --git a/server/db/types/connection/main/GlobalChatTable.ts b/server/db/types/connection/main/GlobalChatTable.ts index 151ca40d..e70db40f 100644 --- a/server/db/types/connection/main/GlobalChatTable.ts +++ b/server/db/types/connection/main/GlobalChatTable.ts @@ -11,4 +11,4 @@ export interface GlobalChatTable { sent_at?: Date; deleted_at?: Date | null; network_kind?: string; -} \ No newline at end of file +} diff --git a/server/db/types/connection/main/SessionsTable.ts b/server/db/types/connection/main/SessionsTable.ts index 44a16bdf..45fc0d21 100644 --- a/server/db/types/connection/main/SessionsTable.ts +++ b/server/db/types/connection/main/SessionsTable.ts @@ -12,4 +12,4 @@ export interface SessionsTable { custom_name?: string; refreshed_at?: Date; developer_api_key_id?: string | null; -} \ No newline at end of file +} diff --git a/server/db/types/connection/main/WebsocketSnapshotsTable.ts b/server/db/types/connection/main/WebsocketSnapshotsTable.ts index 265cac94..58c62fd3 100644 --- a/server/db/types/connection/main/WebsocketSnapshotsTable.ts +++ b/server/db/types/connection/main/WebsocketSnapshotsTable.ts @@ -3,4 +3,4 @@ export interface WebsocketSnapshotsTable { namespace_id: string; connected_count: number; sampled_at: Date; -} \ No newline at end of file +} diff --git a/server/db/users.ts b/server/db/users.ts index 49c2e7c7..8dc296bf 100644 --- a/server/db/users.ts +++ b/server/db/users.ts @@ -85,10 +85,7 @@ export async function getUserById(userId: string) { Object.assign(mergedPermissions, user.role_permissions); } - const safeDecrypt = ( - encryptedData: unknown, - fieldName: string - ) => { + const safeDecrypt = (encryptedData: unknown, fieldName: string) => { if (!encryptedData) return null; try { const parsed = @@ -193,7 +190,11 @@ export async function getUserByUsername(username: string) { ) : null, settings: user.settings - ? decrypt(typeof user.settings === 'string' ? JSON.parse(user.settings) : user.settings) + ? decrypt( + typeof user.settings === 'string' + ? JSON.parse(user.settings) + : user.settings + ) : null, ip_address: user.ip_address ? decrypt( @@ -403,7 +404,10 @@ export async function addSessionToUser(userId: string, _sessionId: string) { return await getUserById(userId); } -export async function removeSessionFromUser(userId: string, _sessionId: string) { +export async function removeSessionFromUser( + userId: string, + _sessionId: string +) { await mainDb .updateTable('users') .set({ @@ -581,7 +585,10 @@ export async function updateUserStatistics( await invalidateUserCache(userId); } -export async function updateUserFingerprint(userId: string, fingerprintId: string) { +export async function updateUserFingerprint( + userId: string, + fingerprintId: string +) { await mainDb .updateTable('users') .set({ fingerprint_id: fingerprintId, updated_at: sql`NOW()` }) diff --git a/server/db/version.ts b/server/db/version.ts index 1f96d25e..37a3852b 100644 --- a/server/db/version.ts +++ b/server/db/version.ts @@ -1,18 +1,22 @@ -import { mainDb } from "./connection.js"; -import { APP_VERSION_REDIS_SEC, DEPLOYMENT, prefixKey } from "../utils/cacheTtl.js"; +import { mainDb } from './connection.js'; +import { + APP_VERSION_REDIS_SEC, + DEPLOYMENT, + prefixKey, +} from '../utils/cacheTtl.js'; export async function getAppVersion() { const result = await mainDb - .selectFrom("app_settings") - .select(["version", "updated_at", "updated_by"]) - .where("channel", "=", DEPLOYMENT) + .selectFrom('app_settings') + .select(['version', 'updated_at', 'updated_by']) + .where('channel', '=', DEPLOYMENT) .executeTakeFirst(); if (!result) { return { - version: "2.0.0", + version: '2.0.0', updated_at: new Date().toISOString(), - updated_by: "system", + updated_by: 'system', }; } @@ -22,15 +26,15 @@ export async function getAppVersion() { }; try { - const { redisConnection } = await import("./connection.js"); + const { redisConnection } = await import('./connection.js'); await redisConnection.set( - prefixKey("app:version"), + prefixKey('app:version'), JSON.stringify(versionData), - "EX", - APP_VERSION_REDIS_SEC, + 'EX', + APP_VERSION_REDIS_SEC ); } catch (error) { - console.warn("[Redis] Failed to set version cache:", error); + console.warn('[Redis] Failed to set version cache:', error); } return versionData; @@ -38,7 +42,7 @@ export async function getAppVersion() { export async function updateAppVersion(version: string, updatedBy: string) { const result = await mainDb - .insertInto("app_settings") + .insertInto('app_settings') .values({ version, channel: DEPLOYMENT, @@ -46,24 +50,24 @@ export async function updateAppVersion(version: string, updatedBy: string) { updated_by: updatedBy, }) .onConflict((oc) => - oc.column("channel").doUpdateSet({ + oc.column('channel').doUpdateSet({ version, updated_at: new Date(), updated_by: updatedBy, - }), + }) ) - .returning(["version", "updated_at", "updated_by"]) + .returning(['version', 'updated_at', 'updated_by']) .executeTakeFirst(); try { - const { redisConnection } = await import("./connection.js"); - await redisConnection.del(prefixKey("app:version")); + const { redisConnection } = await import('./connection.js'); + await redisConnection.del(prefixKey('app:version')); } catch (error) { - console.warn("[Redis] Failed to invalidate version cache:", error); + console.warn('[Redis] Failed to invalidate version cache:', error); } return { ...result, updated_at: result?.updated_at?.toISOString() ?? null, }; -} \ No newline at end of file +} diff --git a/server/db/vpnExceptions.ts b/server/db/vpnExceptions.ts index 19c88ba8..2a10cd4b 100644 --- a/server/db/vpnExceptions.ts +++ b/server/db/vpnExceptions.ts @@ -60,7 +60,11 @@ export async function isVpnException(userId: string): Promise { .executeTakeFirst(); const isException = !!result; - await redisConnection.setex(`vpn_exception:${userId}`, CACHE_TTL, isException ? '1' : '0'); + await redisConnection.setex( + `vpn_exception:${userId}`, + CACHE_TTL, + isException ? '1' : '0' + ); return isException; } @@ -169,6 +173,10 @@ export async function isVpnGateEnabled(): Promise { const settings = await getVpnGateSettings(); const enabled = settings['vpn_gate_enabled'] ?? false; - await redisConnection.setex('vpn_gate_enabled', GATE_CACHE_TTL, enabled ? '1' : '0'); + await redisConnection.setex( + 'vpn_gate_enabled', + GATE_CACHE_TTL, + enabled ? '1' : '0' + ); return enabled; } diff --git a/server/db/websocketSnapshots.ts b/server/db/websocketSnapshots.ts index 72b27823..19a73b02 100644 --- a/server/db/websocketSnapshots.ts +++ b/server/db/websocketSnapshots.ts @@ -1,6 +1,6 @@ -import { sql } from "kysely"; -import { mainDb } from "./connection.js"; -import { recordTableDeletes } from "./databaseMetrics.js"; +import { sql } from 'kysely'; +import { mainDb } from './connection.js'; +import { recordTableDeletes } from './databaseMetrics.js'; export const WEBSOCKET_SNAPSHOT_RETENTION_DAYS = 1; @@ -12,7 +12,7 @@ export async function persistWebsocketSnapshots( const sampledAt = new Date(); try { await mainDb - .insertInto("websocket_snapshots") + .insertInto('websocket_snapshots') .values( samples.map((s) => ({ namespace_id: s.namespaceId, @@ -22,7 +22,7 @@ export async function persistWebsocketSnapshots( ) .execute(); } catch (error) { - console.error("[websocketSnapshots] persist failed:", error); + console.error('[websocketSnapshots] persist failed:', error); } } @@ -32,16 +32,16 @@ export async function cleanupOldWebsocketSnapshots( const cutoff = new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000); try { const result = await mainDb - .deleteFrom("websocket_snapshots") - .where("sampled_at", "<", cutoff) + .deleteFrom('websocket_snapshots') + .where('sampled_at', '<', cutoff) .executeTakeFirst(); await recordTableDeletes( - "websocket_snapshots", + 'websocket_snapshots', Number(result?.numDeletedRows ?? 0) ); } catch (error) { - console.error("[websocketSnapshots] cleanup failed:", error); + console.error('[websocketSnapshots] cleanup failed:', error); } } @@ -72,7 +72,7 @@ export async function getWebsocketHourlyHistory(): Promise< return result; } catch (error) { - console.error("[websocketSnapshots] hourly history failed:", error); + console.error('[websocketSnapshots] hourly history failed:', error); return new Map(); } -} \ No newline at end of file +} diff --git a/server/developer/apiDocumentation.ts b/server/developer/apiDocumentation.ts index 811163cd..6ff963fd 100644 --- a/server/developer/apiDocumentation.ts +++ b/server/developer/apiDocumentation.ts @@ -1,9 +1,9 @@ -import { DEVELOPER_SCOPE_CATALOG } from "./scopeRegistry.js"; +import { DEVELOPER_SCOPE_CATALOG } from './scopeRegistry.js'; import { DEVELOPER_EXT_ROUTES, pathTemplateForRoute, type DeveloperExtRouteDefinition, -} from "./extRoutes.js"; +} from './extRoutes.js'; export interface DeveloperApiDocHeader { name: string; @@ -22,7 +22,12 @@ export interface DeveloperApiDocEndpoint { pathTemplate: string; fullUrlExample: string; pathParams?: { name: string; description: string; example?: string }[]; - queryParams?: { name: string; required: boolean; description: string; example?: string }[]; + queryParams?: { + name: string; + required: boolean; + description: string; + example?: string; + }[]; requestBodySummary?: string; requestBodyExampleJson?: string; requestHeaders: DeveloperApiDocHeader[]; @@ -49,7 +54,7 @@ export interface DeveloperApiPublicSpec { endpoints: DeveloperApiDocEndpoint[]; } -const DEFAULT_BASE = "https://pfcontrol.com/api/ext/v1"; +const DEFAULT_BASE = 'https://pfcontrol.com/api/ext/v1'; function catalogTitle(scopeId: string): string { const c = DEVELOPER_SCOPE_CATALOG.find((x) => x.id === scopeId); @@ -58,17 +63,22 @@ function catalogTitle(scopeId: string): string { function catalogSummary(scopeId: string): string { const c = DEVELOPER_SCOPE_CATALOG.find((x) => x.id === scopeId); - return c?.description ?? ""; + return c?.description ?? ''; } function escapeShellSingleQuotes(s: string): string { return `'${s.replace(/'/g, `'"'"'`)}'`; } -function buildExampleCurl(method: string, pathWithQuery: string, bodyJson?: string): string { - const headers = '-H "Authorization: Bearer YOUR_PFC_LIVE_KEY" -H "Accept: application/json"'; +function buildExampleCurl( + method: string, + pathWithQuery: string, + bodyJson?: string +): string { + const headers = + '-H "Authorization: Bearer YOUR_PFC_LIVE_KEY" -H "Accept: application/json"'; const m = method.toUpperCase(); - if (m === "GET" || m === "HEAD") { + if (m === 'GET' || m === 'HEAD') { return `curl -sS ${headers} "${DEFAULT_BASE}${pathWithQuery}"`; } if (bodyJson) { @@ -77,39 +87,46 @@ function buildExampleCurl(method: string, pathWithQuery: string, bodyJson?: stri return `curl -sS -X ${m} ${headers} "${DEFAULT_BASE}${pathWithQuery}"`; } -function endpointFromRoute(r: DeveloperExtRouteDefinition): DeveloperApiDocEndpoint { +function endpointFromRoute( + r: DeveloperExtRouteDefinition +): DeveloperApiDocEndpoint { const pathTemplate = pathTemplateForRoute(r); let examplePath = pathTemplate; if (r.pathParams?.length) { for (const p of r.pathParams) { - examplePath = examplePath.replace(`{${p.name}}`, p.example ?? `{${p.name}}`); + examplePath = examplePath.replace( + `{${p.name}}`, + p.example ?? `{${p.name}}` + ); } } - let query = ""; + let query = ''; if (r.queryParams?.length) { const parts = r.queryParams .filter((q) => q.required && q.example) - .map((q) => `${encodeURIComponent(q.name)}=${encodeURIComponent(q.example!)}`); - if (parts.length) query = `?${parts.join("&")}`; + .map( + (q) => `${encodeURIComponent(q.name)}=${encodeURIComponent(q.example!)}` + ); + if (parts.length) query = `?${parts.join('&')}`; } const pathWithQuery = `${examplePath}${query}`; const requestHeaders: DeveloperApiDocHeader[] = [ { - name: "Authorization", + name: 'Authorization', required: false, - description: "Bearer token: `Authorization: Bearer pfc_live_...`", + description: 'Bearer token: `Authorization: Bearer pfc_live_...`', }, { - name: "X-Api-Key", + name: 'X-Api-Key', required: false, description: - "Alternative to Authorization: send the raw `pfc_live_...` secret in this header.", + 'Alternative to Authorization: send the raw `pfc_live_...` secret in this header.', }, { - name: "Accept", + name: 'Accept', required: false, - description: "Optional; responses are JSON (`application/json`).", + description: 'Optional; responses are JSON (`application/json`).', }, ]; @@ -126,45 +143,54 @@ function endpointFromRoute(r: DeveloperExtRouteDefinition): DeveloperApiDocEndpo requestBodySummary: r.requestBodySummary, requestBodyExampleJson: r.requestBodyExampleJson, requestHeaders, - responseContentType: "application/json", + responseContentType: 'application/json', responseSummary: r.responseSummary, - exampleCurl: buildExampleCurl(r.method, pathWithQuery, r.requestBodyExampleJson), + exampleCurl: buildExampleCurl( + r.method, + pathWithQuery, + r.requestBodyExampleJson + ), }; } export function buildDeveloperApiPublicSpec(): DeveloperApiPublicSpec { - const defaultPerMinute = Number(process.env.DEVELOPER_API_RATE_LIMIT_PER_MINUTE); - const perMin = Number.isFinite(defaultPerMinute) && defaultPerMinute > 0 ? defaultPerMinute : 120; + const defaultPerMinute = Number( + process.env.DEVELOPER_API_RATE_LIMIT_PER_MINUTE + ); + const perMin = + Number.isFinite(defaultPerMinute) && defaultPerMinute > 0 + ? defaultPerMinute + : 120; return { specVersion: 1, generatedAt: new Date().toISOString(), - title: "PFControl Developer API", + title: 'PFControl Developer API', description: - "HTTP JSON API under /api/ext/v1: static /data/... routes (mirrors the public data API), plus /sessions and /sessions/.../flights for session and flight access when granted. Join codes, client IPs, and ACARS tokens are never returned in developer API responses. Flight updates via PUT are only allowed for sessions created with the same API key. Each key is limited to its scopes.", - baseUrlTemplate: "/api/ext/v1", + 'HTTP JSON API under /api/ext/v1: static /data/... routes (mirrors the public data API), plus /sessions and /sessions/.../flights for session and flight access when granted. Join codes, client IPs, and ACARS tokens are never returned in developer API responses. Flight updates via PUT are only allowed for sessions created with the same API key. Each key is limited to its scopes.', + baseUrlTemplate: '/api/ext/v1', authentication: { description: - "Use a developer API key issued from the Developers portal after your application is approved. Keys start with `pfc_live_` (legacy `pf_live_` keys still work until rotated). Either header style works; do not send cookies for machine clients.", + 'Use a developer API key issued from the Developers portal after your application is approved. Keys start with `pfc_live_` (legacy `pf_live_` keys still work until rotated). Either header style works; do not send cookies for machine clients.', headers: [ { - name: "Authorization", + name: 'Authorization', required: false, - description: "Bearer pfc_live_…", + description: 'Bearer pfc_live_…', }, { - name: "X-Api-Key", + name: 'X-Api-Key', required: false, - description: "Raw secret string (same value as after Bearer).", + description: 'Raw secret string (same value as after Bearer).', }, ], }, rateLimiting: { description: - "Per API key, per minute sliding window (Redis-backed). HTTP 429 with Retry-After when exceeded.", + 'Per API key, per minute sliding window (Redis-backed). HTTP 429 with Retry-After when exceeded.', defaultPerMinute: perMin, - envVar: "DEVELOPER_API_RATE_LIMIT_PER_MINUTE", + envVar: 'DEVELOPER_API_RATE_LIMIT_PER_MINUTE', }, endpoints: [...DEVELOPER_EXT_ROUTES].map(endpointFromRoute), }; -} \ No newline at end of file +} diff --git a/server/developer/apiKeySecret.ts b/server/developer/apiKeySecret.ts index 567c2b8d..3dd326d3 100644 --- a/server/developer/apiKeySecret.ts +++ b/server/developer/apiKeySecret.ts @@ -1,21 +1,21 @@ -import crypto from "crypto"; +import crypto from 'crypto'; -export const DEVELOPER_KEY_PREFIX = "pfc_live_"; +export const DEVELOPER_KEY_PREFIX = 'pfc_live_'; export function hashDeveloperApiKeySecret(secret: string): string { - return crypto.createHash("sha256").update(secret, "utf8").digest("hex"); + return crypto.createHash('sha256').update(secret, 'utf8').digest('hex'); } export function newDeveloperKeyDisplayPrefix(): string { - return `${DEVELOPER_KEY_PREFIX}${crypto.randomBytes(4).toString("hex")}`; + return `${DEVELOPER_KEY_PREFIX}${crypto.randomBytes(4).toString('hex')}`; } export function newPendingDeveloperKeyPrefix(): string { - return `pfc_req_${crypto.randomBytes(4).toString("hex")}`; + return `pfc_req_${crypto.randomBytes(4).toString('hex')}`; } export function generateDeveloperApiKeyPlaintext(): string { - return `${DEVELOPER_KEY_PREFIX}${crypto.randomBytes(32).toString("hex")}`; + return `${DEVELOPER_KEY_PREFIX}${crypto.randomBytes(32).toString('hex')}`; } export function buildNewDeveloperKeyCredentials(): { @@ -31,6 +31,8 @@ export function buildNewDeveloperKeyCredentials(): { }; } -export function isSupportedDeveloperApiKeySecretFormat(secret: string): boolean { +export function isSupportedDeveloperApiKeySecretFormat( + secret: string +): boolean { return secret.startsWith(DEVELOPER_KEY_PREFIX); -} \ No newline at end of file +} diff --git a/server/developer/developerNotificationUnsubscribeToken.ts b/server/developer/developerNotificationUnsubscribeToken.ts index dca82535..589b1afd 100644 --- a/server/developer/developerNotificationUnsubscribeToken.ts +++ b/server/developer/developerNotificationUnsubscribeToken.ts @@ -1,19 +1,19 @@ -import jwt, { type JwtPayload } from "jsonwebtoken"; +import jwt, { type JwtPayload } from 'jsonwebtoken'; -const TOKEN_TYP = "dev_notify_unsub"; +const TOKEN_TYP = 'dev_notify_unsub'; function apiOriginForEmailLinks(): string { - const explicit = process.env.PUBLIC_APP_URL?.trim().replace(/\/$/, ""); + const explicit = process.env.PUBLIC_APP_URL?.trim().replace(/\/$/, ''); if (explicit) return explicit; - const fe = process.env.FRONTEND_URL?.trim().replace(/\/$/, ""); - const port = process.env.PORT?.trim() || "9901"; + const fe = process.env.FRONTEND_URL?.trim().replace(/\/$/, ''); + const port = process.env.PORT?.trim() || '9901'; if (fe) { try { const u = new URL(fe); - const local = u.hostname === "localhost" || u.hostname === "127.0.0.1"; - const vitePort = u.port === "5173" || u.port === "4173"; + const local = u.hostname === 'localhost' || u.hostname === '127.0.0.1'; + const vitePort = u.port === '5173' || u.port === '4173'; if (local && vitePort) { return `${u.protocol}//${u.hostname}:${port}`; } @@ -26,13 +26,18 @@ function apiOriginForEmailLinks(): string { return `http://localhost:${port}`; } -export function createDeveloperNotificationUnsubscribeToken(userId: string, email: string): string { +export function createDeveloperNotificationUnsubscribeToken( + userId: string, + email: string +): string { const secret = process.env.JWT_SECRET; if (!secret) { - throw new Error("JWT_SECRET is not set"); + throw new Error('JWT_SECRET is not set'); } const em = email.trim().toLowerCase(); - return jwt.sign({ typ: TOKEN_TYP, sub: userId, em }, secret, { expiresIn: "180d" }); + return jwt.sign({ typ: TOKEN_TYP, sub: userId, em }, secret, { + expiresIn: '180d', + }); } export function verifyDeveloperNotificationUnsubscribeToken(token: string): { @@ -49,8 +54,8 @@ export function verifyDeveloperNotificationUnsubscribeToken(token: string): { }; if ( decoded.typ !== TOKEN_TYP || - typeof decoded.sub !== "string" || - typeof decoded.em !== "string" + typeof decoded.sub !== 'string' || + typeof decoded.em !== 'string' ) { return null; } @@ -60,8 +65,11 @@ export function verifyDeveloperNotificationUnsubscribeToken(token: string): { } } -export function createDeveloperNotificationUnsubscribeUrl(userId: string, email: string): string { +export function createDeveloperNotificationUnsubscribeUrl( + userId: string, + email: string +): string { const t = createDeveloperNotificationUnsubscribeToken(userId, email); const origin = apiOriginForEmailLinks(); return `${origin}/api/developer/notification-unsubscribe?token=${encodeURIComponent(t)}`; -} \ No newline at end of file +} diff --git a/server/developer/extRoutes.ts b/server/developer/extRoutes.ts index 7dd83d8f..2a07a927 100644 --- a/server/developer/extRoutes.ts +++ b/server/developer/extRoutes.ts @@ -1,4 +1,4 @@ -export type HttpMethod = "GET" | "POST" | "PUT"; +export type HttpMethod = 'GET' | 'POST' | 'PUT'; export interface DeveloperExtRouteParamDoc { name: string; @@ -14,8 +14,8 @@ export interface DeveloperExtRouteQueryDoc { } type RoutePattern = - | { kind: "exact"; path: string } - | { kind: "regex"; regex: RegExp; pathTemplate: string }; + | { kind: 'exact'; path: string } + | { kind: 'regex'; regex: RegExp; pathTemplate: string }; export interface DeveloperExtRouteDefinition { scopeId: string; @@ -30,377 +30,424 @@ export interface DeveloperExtRouteDefinition { export const DEVELOPER_EXT_ROUTES: readonly DeveloperExtRouteDefinition[] = [ { - scopeId: "ratings.controller_stats", - method: "GET", + scopeId: 'ratings.controller_stats', + method: 'GET', pattern: { - kind: "regex", + kind: 'regex', regex: /^\/ratings\/controllers\/[^/]+\/stats$/i, - pathTemplate: "/ratings/controllers/{controllerId}/stats", + pathTemplate: '/ratings/controllers/{controllerId}/stats', }, responseSummary: - "Aggregate rating count and average for a VATSIM controller id (no pilot identifiers).", + 'Aggregate rating count and average for a VATSIM controller id (no pilot identifiers).', pathParams: [ - { name: "controllerId", description: "VATSIM controller identifier.", example: "1234567" }, + { + name: 'controllerId', + description: 'VATSIM controller identifier.', + example: '1234567', + }, ], }, { - scopeId: "notifications.read", - method: "GET", - pattern: { kind: "exact", path: "/notifications/active" }, + scopeId: 'notifications.read', + method: 'GET', + pattern: { kind: 'exact', path: '/notifications/active' }, responseSummary: - "Public active announcements (same fields as web homepage feed; no admin-only data).", + 'Public active announcements (same fields as web homepage feed; no admin-only data).', }, { - scopeId: "flight_logs.read", - method: "GET", - pattern: { kind: "exact", path: "/flight-logs" }, + scopeId: 'flight_logs.read', + method: 'GET', + pattern: { kind: 'exact', path: '/flight-logs' }, responseSummary: - "Sanitized flight change audit entries for sessions you own (id, timestamps, action, session/flight ids only; no IP, no old/new payload text).", + 'Sanitized flight change audit entries for sessions you own (id, timestamps, action, session/flight ids only; no IP, no old/new payload text).', queryParams: [ { - name: "sessionId", + name: 'sessionId', required: false, - description: "Filter to one owned session.", - example: "sess_abc123", + description: 'Filter to one owned session.', + example: 'sess_abc123', }, { - name: "page", + name: 'page', required: false, - description: "Page number (default 1).", - example: "1", + description: 'Page number (default 1).', + example: '1', }, { - name: "limit", + name: 'limit', required: false, - description: "Page size (max 100, default 50).", - example: "50", + description: 'Page size (max 100, default 50).', + example: '50', }, ], }, { - scopeId: "sessions.network_pfatc", - method: "GET", + scopeId: 'sessions.network_pfatc', + method: 'GET', pattern: { - kind: "regex", + kind: 'regex', regex: /^\/sessions\/network\/pfatc\/[^/]+$/i, - pathTemplate: "/sessions/network/pfatc/{sessionId}", + pathTemplate: '/sessions/network/pfatc/{sessionId}', }, responseSummary: - "One PFATC network session (sanitized): airport, runway, counts, controller public profile. Not limited to sessions you own.", - pathParams: [{ name: "sessionId", description: "Session identifier.", example: "sess_abc123" }], + 'One PFATC network session (sanitized): airport, runway, counts, controller public profile. Not limited to sessions you own.', + pathParams: [ + { + name: 'sessionId', + description: 'Session identifier.', + example: 'sess_abc123', + }, + ], }, { - scopeId: "sessions.network_pfatc", - method: "GET", - pattern: { kind: "exact", path: "/sessions/network/pfatc" }, + scopeId: 'sessions.network_pfatc', + method: 'GET', + pattern: { kind: 'exact', path: '/sessions/network/pfatc' }, responseSummary: - "JSON array of PFATC network sessions (sanitized; no access_id). Optional airport (ICAO), page, limit.", + 'JSON array of PFATC network sessions (sanitized; no access_id). Optional airport (ICAO), page, limit.', queryParams: [ { - name: "airport", + name: 'airport', required: false, - description: "Filter to one airport ICAO (4 letters).", - example: "EGLL", + description: 'Filter to one airport ICAO (4 letters).', + example: 'EGLL', }, { - name: "page", + name: 'page', required: false, - description: "Page number (default 1).", - example: "1", + description: 'Page number (default 1).', + example: '1', }, { - name: "limit", + name: 'limit', required: false, - description: "Page size (max 100, default 50).", - example: "50", + description: 'Page size (max 100, default 50).', + example: '50', }, ], }, { - scopeId: "sessions.network_aatc", - method: "GET", + scopeId: 'sessions.network_aatc', + method: 'GET', pattern: { - kind: "regex", + kind: 'regex', regex: /^\/sessions\/network\/aatc\/[^/]+$/i, - pathTemplate: "/sessions/network/aatc/{sessionId}", + pathTemplate: '/sessions/network/aatc/{sessionId}', }, responseSummary: - "One Advanced ATC (AATC) network session (sanitized). Not limited to sessions you own.", - pathParams: [{ name: "sessionId", description: "Session identifier.", example: "sess_abc123" }], + 'One Advanced ATC (AATC) network session (sanitized). Not limited to sessions you own.', + pathParams: [ + { + name: 'sessionId', + description: 'Session identifier.', + example: 'sess_abc123', + }, + ], }, { - scopeId: "sessions.network_aatc", - method: "GET", - pattern: { kind: "exact", path: "/sessions/network/aatc" }, + scopeId: 'sessions.network_aatc', + method: 'GET', + pattern: { kind: 'exact', path: '/sessions/network/aatc' }, responseSummary: - "JSON array of Advanced ATC (AATC) network sessions (sanitized; no access_id). Optional airport, page, limit.", + 'JSON array of Advanced ATC (AATC) network sessions (sanitized; no access_id). Optional airport, page, limit.', queryParams: [ { - name: "airport", + name: 'airport', required: false, - description: "Filter to one airport ICAO (4 letters).", - example: "EGLL", + description: 'Filter to one airport ICAO (4 letters).', + example: 'EGLL', }, { - name: "page", + name: 'page', required: false, - description: "Page number (default 1).", - example: "1", + description: 'Page number (default 1).', + example: '1', }, { - name: "limit", + name: 'limit', required: false, - description: "Page size (max 100, default 50).", - example: "50", + description: 'Page size (max 100, default 50).', + example: '50', }, ], }, { - scopeId: "flights.read", - method: "GET", + scopeId: 'flights.read', + method: 'GET', pattern: { - kind: "regex", + kind: 'regex', regex: /^\/sessions\/[^/]+\/flights\/[^/]+$/i, - pathTemplate: "/sessions/{sessionId}/flights/{flightId}", + pathTemplate: '/sessions/{sessionId}/flights/{flightId}', }, - responseSummary: "Single flight JSON (no IP, ACARS token, or pilot Discord linkage).", + responseSummary: + 'Single flight JSON (no IP, ACARS token, or pilot Discord linkage).', pathParams: [ - { name: "sessionId", description: "Session identifier.", example: "sess_abc123" }, { - name: "flightId", - description: "Flight UUID.", - example: "550e8400-e29b-41d4-a716-446655440000", + name: 'sessionId', + description: 'Session identifier.', + example: 'sess_abc123', + }, + { + name: 'flightId', + description: 'Flight UUID.', + example: '550e8400-e29b-41d4-a716-446655440000', }, ], }, { - scopeId: "flights.update", - method: "PUT", + scopeId: 'flights.update', + method: 'PUT', pattern: { - kind: "regex", + kind: 'regex', regex: /^\/sessions\/[^/]+\/flights\/[^/]+$/i, - pathTemplate: "/sessions/{sessionId}/flights/{flightId}", + pathTemplate: '/sessions/{sessionId}/flights/{flightId}', }, responseSummary: - "Updated flight JSON (sanitized). Only allowed for sessions created with this same API key.", + 'Updated flight JSON (sanitized). Only allowed for sessions created with this same API key.', pathParams: [ - { name: "sessionId", description: "Session identifier.", example: "sess_abc123" }, { - name: "flightId", - description: "Flight UUID.", - example: "550e8400-e29b-41d4-a716-446655440000", + name: 'sessionId', + description: 'Session identifier.', + example: 'sess_abc123', + }, + { + name: 'flightId', + description: 'Flight UUID.', + example: '550e8400-e29b-41d4-a716-446655440000', }, ], requestBodySummary: - "Partial flight fields (same subset as web UI): callsign, aircraft, departure, arrival, route, sid, star, runway, cruisingFL, clearedFL, squawk, wtc, status, remark, clearance, stand, gate, hidden, etc.", - requestBodyExampleJson: JSON.stringify({ status: "ACTIVE", runway: "27L", squawk: "1234" }), + 'Partial flight fields (same subset as web UI): callsign, aircraft, departure, arrival, route, sid, star, runway, cruisingFL, clearedFL, squawk, wtc, status, remark, clearance, stand, gate, hidden, etc.', + requestBodyExampleJson: JSON.stringify({ + status: 'ACTIVE', + runway: '27L', + squawk: '1234', + }), }, { - scopeId: "flights.list", - method: "GET", + scopeId: 'flights.list', + method: 'GET', pattern: { - kind: "regex", + kind: 'regex', regex: /^\/sessions\/[^/]+\/flights$/i, - pathTemplate: "/sessions/{sessionId}/flights", + pathTemplate: '/sessions/{sessionId}/flights', }, - responseSummary: "JSON array of flights (sanitized; no IPs or ACARS tokens).", + responseSummary: + 'JSON array of flights (sanitized; no IPs or ACARS tokens).', pathParams: [ { - name: "sessionId", - description: "Session you own (created_by matches key owner).", - example: "sess_abc123", + name: 'sessionId', + description: 'Session you own (created_by matches key owner).', + example: 'sess_abc123', }, ], }, { - scopeId: "flights.create", - method: "POST", + scopeId: 'flights.create', + method: 'POST', pattern: { - kind: "regex", + kind: 'regex', regex: /^\/sessions\/[^/]+\/flights$/i, - pathTemplate: "/sessions/{sessionId}/flights", + pathTemplate: '/sessions/{sessionId}/flights', }, - responseSummary: "Creates a flight; returns sanitized flight (no ACARS token in response).", - pathParams: [{ name: "sessionId", description: "Session you own.", example: "sess_abc123" }], + responseSummary: + 'Creates a flight; returns sanitized flight (no ACARS token in response).', + pathParams: [ + { + name: 'sessionId', + description: 'Session you own.', + example: 'sess_abc123', + }, + ], requestBodySummary: - "Flight fields (same as web submit): callsign, aircraft, flight_type, departure, arrival, route, etc.", + 'Flight fields (same as web submit): callsign, aircraft, flight_type, departure, arrival, route, etc.', requestBodyExampleJson: JSON.stringify({ - callsign: "BAW123", - aircraft: "A320", - flight_type: "IFR", - departure: "EGLL", - arrival: "LFPG", + callsign: 'BAW123', + aircraft: 'A320', + flight_type: 'IFR', + departure: 'EGLL', + arrival: 'LFPG', }), }, { - scopeId: "sessions.read", - method: "GET", + scopeId: 'sessions.read', + method: 'GET', pattern: { - kind: "regex", + kind: 'regex', regex: /^\/sessions\/[^/]+$/i, - pathTemplate: "/sessions/{sessionId}", + pathTemplate: '/sessions/{sessionId}', }, responseSummary: - "Session metadata without access_id (join codes are not exposed via developer API).", - pathParams: [{ name: "sessionId", description: "Session identifier.", example: "sess_abc123" }], + 'Session metadata without access_id (join codes are not exposed via developer API).', + pathParams: [ + { + name: 'sessionId', + description: 'Session identifier.', + example: 'sess_abc123', + }, + ], }, { - scopeId: "sessions.list", - method: "GET", - pattern: { kind: "exact", path: "/sessions" }, + scopeId: 'sessions.list', + method: 'GET', + pattern: { kind: 'exact', path: '/sessions' }, responseSummary: - "JSON array of sessions you created (no access_id). Includes apiManaged when the session was created via the developer API.", + 'JSON array of sessions you created (no access_id). Includes apiManaged when the session was created via the developer API.', }, { - scopeId: "sessions.create", - method: "POST", - pattern: { kind: "exact", path: "/sessions" }, + scopeId: 'sessions.create', + method: 'POST', + pattern: { kind: 'exact', path: '/sessions' }, responseSummary: - "Creates a session tied to your user and this API key (API-managed). Returns session id and metadata without access_id.", + 'Creates a session tied to your user and this API key (API-managed). Returns session id and metadata without access_id.', requestBodySummary: - "airportIcao (required), optional isPFATC, isAdvancedATC (mutually exclusive), activeRunway.", + 'airportIcao (required), optional isPFATC, isAdvancedATC (mutually exclusive), activeRunway.', requestBodyExampleJson: JSON.stringify({ - airportIcao: "EGLL", + airportIcao: 'EGLL', isPFATC: false, isAdvancedATC: false, - activeRunway: "27L", + activeRunway: '27L', }), }, { - scopeId: "data.airports", - method: "GET", - pattern: { kind: "exact", path: "/data/airports" }, - responseSummary: "JSON array of airport objects (static dataset).", + scopeId: 'data.airports', + method: 'GET', + pattern: { kind: 'exact', path: '/data/airports' }, + responseSummary: 'JSON array of airport objects (static dataset).', }, { - scopeId: "data.aircrafts", - method: "GET", - pattern: { kind: "exact", path: "/data/aircrafts" }, - responseSummary: "JSON array of aircraft reference records.", + scopeId: 'data.aircrafts', + method: 'GET', + pattern: { kind: 'exact', path: '/data/aircrafts' }, + responseSummary: 'JSON array of aircraft reference records.', }, { - scopeId: "data.airlines", - method: "GET", - pattern: { kind: "exact", path: "/data/airlines" }, - responseSummary: "JSON array of airline reference records.", + scopeId: 'data.airlines', + method: 'GET', + pattern: { kind: 'exact', path: '/data/airlines' }, + responseSummary: 'JSON array of airline reference records.', }, { - scopeId: "data.frequencies", - method: "GET", - pattern: { kind: "exact", path: "/data/frequencies" }, - responseSummary: "JSON array of per-airport frequency summaries.", + scopeId: 'data.frequencies', + method: 'GET', + pattern: { kind: 'exact', path: '/data/frequencies' }, + responseSummary: 'JSON array of per-airport frequency summaries.', }, { - scopeId: "data.backgrounds", - method: "GET", - pattern: { kind: "exact", path: "/data/backgrounds" }, - responseSummary: "JSON array of background image metadata (filename, path, extension).", + scopeId: 'data.backgrounds', + method: 'GET', + pattern: { kind: 'exact', path: '/data/backgrounds' }, + responseSummary: + 'JSON array of background image metadata (filename, path, extension).', }, { - scopeId: "data.find_route", - method: "GET", - pattern: { kind: "exact", path: "/data/findRoute" }, - responseSummary: "JSON object with path (waypoint ids), distance, success.", + scopeId: 'data.find_route', + method: 'GET', + pattern: { kind: 'exact', path: '/data/findRoute' }, + responseSummary: 'JSON object with path (waypoint ids), distance, success.', queryParams: [ { - name: "from", + name: 'from', required: true, - description: "Start waypoint identifier.", - example: "EGLL", + description: 'Start waypoint identifier.', + example: 'EGLL', }, { - name: "to", + name: 'to', required: true, - description: "End waypoint identifier.", - example: "LFPG", + description: 'End waypoint identifier.', + example: 'LFPG', }, ], }, { - scopeId: "data.airport_runways", - method: "GET", + scopeId: 'data.airport_runways', + method: 'GET', pattern: { - kind: "regex", + kind: 'regex', regex: /^\/data\/airports\/[^/]+\/runways$/i, - pathTemplate: "/data/airports/{icao}/runways", + pathTemplate: '/data/airports/{icao}/runways', }, - responseSummary: "JSON array of runway strings/objects for the airport.", + responseSummary: 'JSON array of runway strings/objects for the airport.', pathParams: [ { - name: "icao", - description: "Airport ICAO code (case-insensitive in URL).", - example: "EGLL", + name: 'icao', + description: 'Airport ICAO code (case-insensitive in URL).', + example: 'EGLL', }, ], }, { - scopeId: "data.airport_sids", - method: "GET", + scopeId: 'data.airport_sids', + method: 'GET', pattern: { - kind: "regex", + kind: 'regex', regex: /^\/data\/airports\/[^/]+\/sids$/i, - pathTemplate: "/data/airports/{icao}/sids", + pathTemplate: '/data/airports/{icao}/sids', }, - responseSummary: "JSON array of SID definitions for the airport.", + responseSummary: 'JSON array of SID definitions for the airport.', pathParams: [ { - name: "icao", - description: "Airport ICAO code.", - example: "EGLL", + name: 'icao', + description: 'Airport ICAO code.', + example: 'EGLL', }, ], }, { - scopeId: "data.airport_stars", - method: "GET", + scopeId: 'data.airport_stars', + method: 'GET', pattern: { - kind: "regex", + kind: 'regex', regex: /^\/data\/airports\/[^/]+\/stars$/i, - pathTemplate: "/data/airports/{icao}/stars", + pathTemplate: '/data/airports/{icao}/stars', }, - responseSummary: "JSON array of STAR definitions for the airport.", + responseSummary: 'JSON array of STAR definitions for the airport.', pathParams: [ { - name: "icao", - description: "Airport ICAO code.", - example: "EGLL", + name: 'icao', + description: 'Airport ICAO code.', + example: 'EGLL', }, ], }, { - scopeId: "data.airport_status", - method: "GET", + scopeId: 'data.airport_status', + method: 'GET', pattern: { - kind: "regex", + kind: 'regex', regex: /^\/data\/airports\/[^/]+\/status$/i, - pathTemplate: "/data/airports/{icao}/status", + pathTemplate: '/data/airports/{icao}/status', }, responseSummary: - "JSON with active PFATC/Advanced session summary, controller, runway, flight count, METAR when available.", + 'JSON with active PFATC/Advanced session summary, controller, runway, flight count, METAR when available.', pathParams: [ { - name: "icao", - description: "Airport ICAO code.", - example: "EGLL", + name: 'icao', + description: 'Airport ICAO code.', + example: 'EGLL', }, ], }, ]; export function pathTemplateForRoute(r: DeveloperExtRouteDefinition): string { - if (r.pattern.kind === "exact") return r.pattern.path; + if (r.pattern.kind === 'exact') return r.pattern.path; return r.pattern.pathTemplate; } -export function matchExtDeveloperRoute(method: string, pathNoQuery: string): string | null { - const p = pathNoQuery.split("?")[0]; +export function matchExtDeveloperRoute( + method: string, + pathNoQuery: string +): string | null { + const p = pathNoQuery.split('?')[0]; for (const r of DEVELOPER_EXT_ROUTES) { if (r.method !== method) continue; - if (r.pattern.kind === "exact" && r.pattern.path === p) return r.scopeId; - if (r.pattern.kind === "regex" && r.pattern.regex.test(p)) return r.scopeId; + if (r.pattern.kind === 'exact' && r.pattern.path === p) return r.scopeId; + if (r.pattern.kind === 'regex' && r.pattern.regex.test(p)) return r.scopeId; } return null; } /** @deprecated Use matchExtDeveloperRoute; kept for internal naming continuity. */ -export const matchExtDataRoute = matchExtDeveloperRoute; \ No newline at end of file +export const matchExtDataRoute = matchExtDeveloperRoute; diff --git a/server/developer/scopeRegistry.ts b/server/developer/scopeRegistry.ts index c62f9465..60225427 100644 --- a/server/developer/scopeRegistry.ts +++ b/server/developer/scopeRegistry.ts @@ -1,9 +1,13 @@ -export { matchExtDataRoute, matchExtDeveloperRoute, DEVELOPER_EXT_ROUTES } from "./extRoutes.js"; +export { + matchExtDataRoute, + matchExtDeveloperRoute, + DEVELOPER_EXT_ROUTES, +} from './extRoutes.js'; export type { DeveloperExtRouteDefinition, DeveloperExtRouteParamDoc, DeveloperExtRouteQueryDoc, -} from "./extRoutes.js"; +} from './extRoutes.js'; export interface DeveloperScopeCatalogEntry { id: string; @@ -13,133 +17,144 @@ export interface DeveloperScopeCatalogEntry { export const DEVELOPER_SCOPE_CATALOG: DeveloperScopeCatalogEntry[] = [ { - id: "data.airports", - label: "Airport directory", - description: "Full airport dataset (ICAO, runways metadata bundle, etc.).", + id: 'data.airports', + label: 'Airport directory', + description: 'Full airport dataset (ICAO, runways metadata bundle, etc.).', }, { - id: "data.aircrafts", - label: "Aircraft types", - description: "Aircraft reference data.", + id: 'data.aircrafts', + label: 'Aircraft types', + description: 'Aircraft reference data.', }, { - id: "data.airlines", - label: "Airlines", - description: "Airline reference data.", + id: 'data.airlines', + label: 'Airlines', + description: 'Airline reference data.', }, { - id: "data.frequencies", - label: "Frequencies summary", - description: "Per-airport frequency summaries derived from airport data.", + id: 'data.frequencies', + label: 'Frequencies summary', + description: 'Per-airport frequency summaries derived from airport data.', }, { - id: "data.backgrounds", - label: "Background assets", - description: "List of available session background images.", + id: 'data.backgrounds', + label: 'Background assets', + description: 'List of available session background images.', }, { - id: "data.airport_runways", - label: "Airport runways", - description: "GET /data/airports/:icao/runways — runway list for one airport.", + id: 'data.airport_runways', + label: 'Airport runways', + description: + 'GET /data/airports/:icao/runways — runway list for one airport.', }, { - id: "data.airport_sids", - label: "Airport SIDs", - description: "GET /data/airports/:icao/sids — SID definitions.", + id: 'data.airport_sids', + label: 'Airport SIDs', + description: 'GET /data/airports/:icao/sids — SID definitions.', }, { - id: "data.airport_stars", - label: "Airport STARs", - description: "GET /data/airports/:icao/stars — STAR definitions.", + id: 'data.airport_stars', + label: 'Airport STARs', + description: 'GET /data/airports/:icao/stars — STAR definitions.', }, { - id: "data.find_route", - label: "Route finder", - description: "GET /data/findRoute?from=&to= — waypoint graph route between fixes.", + id: 'data.find_route', + label: 'Route finder', + description: + 'GET /data/findRoute?from=&to= — waypoint graph route between fixes.', }, { - id: "data.airport_status", - label: "Airport status", - description: "GET /data/airports/:icao/status — active PFATC session, METAR, etc.", + id: 'data.airport_status', + label: 'Airport status', + description: + 'GET /data/airports/:icao/status — active PFATC session, METAR, etc.', }, { - id: "sessions.network_pfatc", - label: "PFATC sessions", + id: 'sessions.network_pfatc', + label: 'PFATC sessions', description: - "GET /sessions/network/pfatc — list PFATC network sessions worldwide (sanitized). Optional GET /sessions/network/pfatc/{sessionId} for one row. No access_id or ATIS.", + 'GET /sessions/network/pfatc — list PFATC network sessions worldwide (sanitized). Optional GET /sessions/network/pfatc/{sessionId} for one row. No access_id or ATIS.', }, { - id: "sessions.network_aatc", - label: "AATC sessions", + id: 'sessions.network_aatc', + label: 'AATC sessions', description: - "GET /sessions/network/aatc — list Advanced ATC (AATC) network sessions worldwide (sanitized). Optional GET /sessions/network/aatc/{sessionId}. No access_id or ATIS.", + 'GET /sessions/network/aatc — list Advanced ATC (AATC) network sessions worldwide (sanitized). Optional GET /sessions/network/aatc/{sessionId}. No access_id or ATIS.', }, { - id: "sessions.list", - label: "List my sessions", - description: "GET /sessions — sessions you created; join codes are never returned.", + id: 'sessions.list', + label: 'List my sessions', + description: + 'GET /sessions — sessions you created; join codes are never returned.', }, { - id: "sessions.create", - label: "Create session", + id: 'sessions.create', + label: 'Create session', description: - "POST /sessions — creates a session tied to this API key (API-managed for scoped updates).", + 'POST /sessions — creates a session tied to this API key (API-managed for scoped updates).', }, { - id: "sessions.read", - label: "Read session", - description: "GET /sessions/:sessionId — metadata for a session you own (no access_id).", + id: 'sessions.read', + label: 'Read session', + description: + 'GET /sessions/:sessionId — metadata for a session you own (no access_id).', }, { - id: "flights.list", - label: "List session flights", - description: "GET /sessions/:sessionId/flights — all flights in a session you own (sanitized).", + id: 'flights.list', + label: 'List session flights', + description: + 'GET /sessions/:sessionId/flights — all flights in a session you own (sanitized).', }, { - id: "flights.read", - label: "Read flight", - description: "GET /sessions/:sessionId/flights/:flightId — one flight (sanitized).", + id: 'flights.read', + label: 'Read flight', + description: + 'GET /sessions/:sessionId/flights/:flightId — one flight (sanitized).', }, { - id: "flights.create", - label: "Create flight", - description: "POST /sessions/:sessionId/flights — add a flight to a session you own.", + id: 'flights.create', + label: 'Create flight', + description: + 'POST /sessions/:sessionId/flights — add a flight to a session you own.', }, { - id: "flights.update", - label: "Update flight", + id: 'flights.update', + label: 'Update flight', description: - "PUT /sessions/:sessionId/flights/:flightId — update only for sessions created with this same API key.", + 'PUT /sessions/:sessionId/flights/:flightId — update only for sessions created with this same API key.', }, { - id: "ratings.controller_stats", - label: "Controller rating stats", + id: 'ratings.controller_stats', + label: 'Controller rating stats', description: - "GET /ratings/controllers/{controllerId}/stats — aggregate average and count only (no pilot rows).", + 'GET /ratings/controllers/{controllerId}/stats — aggregate average and count only (no pilot rows).', }, { - id: "notifications.read", - label: "Active notifications", - description: "GET /notifications/active — public announcement banners (no admin CRUD).", + id: 'notifications.read', + label: 'Active notifications', + description: + 'GET /notifications/active — public announcement banners (no admin CRUD).', }, { - id: "flight_logs.read", - label: "Own session flight logs (metadata)", + id: 'flight_logs.read', + label: 'Own session flight logs (metadata)', description: - "GET /flight-logs — audit metadata for sessions you own; no IPs, no old/new JSON bodies.", + 'GET /flight-logs — audit metadata for sessions you own; no IPs, no old/new JSON bodies.', }, ]; -export const ALL_DEVELOPER_SCOPE_IDS: string[] = DEVELOPER_SCOPE_CATALOG.map((s) => s.id); +export const ALL_DEVELOPER_SCOPE_IDS: string[] = DEVELOPER_SCOPE_CATALOG.map( + (s) => s.id +); export function isValidScopeList(scopes: unknown): scopes is string[] { if (!Array.isArray(scopes)) return false; if (scopes.length === 0) return false; const set = new Set(ALL_DEVELOPER_SCOPE_IDS); - return scopes.every((s) => typeof s === "string" && set.has(s)); + return scopes.every((s) => typeof s === 'string' && set.has(s)); } export function isScopeSubset(scopes: string[], allowed: string[]): boolean { const allow = new Set(allowed); return scopes.every((s) => allow.has(s)); -} \ No newline at end of file +} diff --git a/server/developer/sendDeveloperAdminNoticeEmail.ts b/server/developer/sendDeveloperAdminNoticeEmail.ts index a7bcd5a1..4c1c43d4 100644 --- a/server/developer/sendDeveloperAdminNoticeEmail.ts +++ b/server/developer/sendDeveloperAdminNoticeEmail.ts @@ -1,10 +1,10 @@ -import { Resend } from "resend"; -import { getDeveloperProfile } from "../db/developer.js"; -import { createDeveloperNotificationUnsubscribeUrl } from "./developerNotificationUnsubscribeToken.js"; +import { Resend } from 'resend'; +import { getDeveloperProfile } from '../db/developer.js'; +import { createDeveloperNotificationUnsubscribeUrl } from './developerNotificationUnsubscribeToken.js'; -const NOTICE_SUCCESS_PREFIX = "[[success]]"; +const NOTICE_SUCCESS_PREFIX = '[[success]]'; const EMAIL_MAX_LEN = 320; -const DEFAULT_DEVELOPER_NOTICE_TEMPLATE_ID = "pfcontrol-devs"; +const DEFAULT_DEVELOPER_NOTICE_TEMPLATE_ID = 'pfcontrol-devs'; function isPlausibleDeveloperNotificationEmail(s: string): boolean { const t = s.trim(); @@ -17,9 +17,9 @@ function isPlausibleDeveloperNotificationEmail(s: string): boolean { function subjectForNotice(detail: string): string { const d = detail.trim(); if (d.startsWith(NOTICE_SUCCESS_PREFIX)) { - return "Your developer application was approved"; + return 'Your developer application was approved'; } - return "Your developer account was updated"; + return 'Your developer account was updated'; } function bodyForNotice(detail: string): string { @@ -27,13 +27,13 @@ function bodyForNotice(detail: string): string { if (text.startsWith(NOTICE_SUCCESS_PREFIX)) { text = text .slice(NOTICE_SUCCESS_PREFIX.length) - .replace(/^\s*\n+/, "") + .replace(/^\s*\n+/, '') .trim(); } if (!text) { - text = "An administrator updated your developer settings."; + text = 'An administrator updated your developer settings.'; } - const base = process.env.FRONTEND_URL?.replace(/\/$/, ""); + const base = process.env.FRONTEND_URL?.replace(/\/$/, ''); if (base) { return `${text}\n\nOpen your developer portal: ${base}/developers`; } @@ -42,11 +42,15 @@ function bodyForNotice(detail: string): string { function developerNoticeTemplateId(): string { return ( - process.env.RESEND_DEVELOPER_NOTICE_TEMPLATE_ID?.trim() || DEFAULT_DEVELOPER_NOTICE_TEMPLATE_ID + process.env.RESEND_DEVELOPER_NOTICE_TEMPLATE_ID?.trim() || + DEFAULT_DEVELOPER_NOTICE_TEMPLATE_ID ); } -export async function sendDeveloperAdminNoticeEmail(userId: string, detail: string): Promise { +export async function sendDeveloperAdminNoticeEmail( + userId: string, + detail: string +): Promise { const apiKey = process.env.RESEND_API_KEY?.trim(); if (!apiKey) return; @@ -56,7 +60,7 @@ export async function sendDeveloperAdminNoticeEmail(userId: string, detail: stri if (!profile) return; const raw = profile.notification_email; - const to = typeof raw === "string" ? raw.trim() : ""; + const to = typeof raw === 'string' ? raw.trim() : ''; if (!to || !isPlausibleDeveloperNotificationEmail(to)) return; const subject = subjectForNotice(detail); @@ -66,7 +70,10 @@ export async function sendDeveloperAdminNoticeEmail(userId: string, detail: stri try { unsubscribeUrl = createDeveloperNotificationUnsubscribeUrl(userId, to); } catch (e) { - console.error("[developer admin notice email] failed to build unsubscribe URL", e); + console.error( + '[developer admin notice email] failed to build unsubscribe URL', + e + ); return; } @@ -86,6 +93,6 @@ export async function sendDeveloperAdminNoticeEmail(userId: string, detail: stri }, }); if (error) { - console.error("[developer admin notice email] Resend error:", error); + console.error('[developer admin notice email] Resend error:', error); } -} \ No newline at end of file +} diff --git a/server/main.ts b/server/main.ts index b9bdc974..dba710c6 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1,68 +1,68 @@ -import path from "path"; -import { fileURLToPath, pathToFileURL } from "url"; -import { existsSync } from "fs"; -import express from "express"; -import type { RequestHandler, Response } from "express"; -import cors from "cors"; -import cookieParser from "cookie-parser"; -import apiRoutes from "./routes/index.js"; -import dotenv from "dotenv"; -import http from "http"; -import chalk from "chalk"; -import Redis from "ioredis"; -import { createAdapter } from "@socket.io/redis-adapter"; - -import { setupSessionUsersWebsocket } from "./websockets/sessionUsersWebsocket.js"; -import { setupChatWebsocket } from "./websockets/chatWebsocket.js"; -import { setupGlobalChatWebsocket } from "./websockets/globalChatWebsocket.js"; -import { setupFlightsWebsocket } from "./websockets/flightsWebsocket.js"; -import { setupOverviewWebsocket } from "./websockets/overviewWebsocket.js"; -import { setupArrivalsWebsocket } from "./websockets/arrivalsWebsocket.js"; -import { setupSectorControllerWebsocket } from "./websockets/sectorControllerWebsocket.js"; -import { setupVoiceChatWebsocket } from "./websockets/voiceChatWebsocket.js"; -import { setupNotificationsWebsocket } from "./websockets/notificationsWebsocket.js"; - -import { startStatsFlushing } from "./utils/statisticsCache.js"; -import { updateLeaderboard } from "./db/leaderboard.js"; -import { startFlightLogsCleanup } from "./db/flightLogs.js"; -import { apiLogger, cleanupOldApiLogs } from "./middleware/apiLogger.js"; -import { httpErrorHandler } from "./middleware/httpErrorHandler.js"; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { existsSync } from 'fs'; +import express from 'express'; +import type { RequestHandler, Response } from 'express'; +import cors from 'cors'; +import cookieParser from 'cookie-parser'; +import apiRoutes from './routes/index.js'; +import dotenv from 'dotenv'; +import http from 'http'; +import chalk from 'chalk'; +import Redis from 'ioredis'; +import { createAdapter } from '@socket.io/redis-adapter'; + +import { setupSessionUsersWebsocket } from './websockets/sessionUsersWebsocket.js'; +import { setupChatWebsocket } from './websockets/chatWebsocket.js'; +import { setupGlobalChatWebsocket } from './websockets/globalChatWebsocket.js'; +import { setupFlightsWebsocket } from './websockets/flightsWebsocket.js'; +import { setupOverviewWebsocket } from './websockets/overviewWebsocket.js'; +import { setupArrivalsWebsocket } from './websockets/arrivalsWebsocket.js'; +import { setupSectorControllerWebsocket } from './websockets/sectorControllerWebsocket.js'; +import { setupVoiceChatWebsocket } from './websockets/voiceChatWebsocket.js'; +import { setupNotificationsWebsocket } from './websockets/notificationsWebsocket.js'; + +import { startStatsFlushing } from './utils/statisticsCache.js'; +import { updateLeaderboard } from './db/leaderboard.js'; +import { startFlightLogsCleanup } from './db/flightLogs.js'; +import { apiLogger, cleanupOldApiLogs } from './middleware/apiLogger.js'; +import { httpErrorHandler } from './middleware/httpErrorHandler.js'; import { applyImmutableAsset, filenameLooksContentHashed, -} from "./utils/httpCache.js"; -import { cleanupOldDeveloperUsage } from "./db/developer.js"; -import { cleanupOldWebsocketSnapshots } from "./db/websocketSnapshots.js"; -import { startDatabaseMetricsCapture } from "./db/databaseMetrics.js"; -import { registerAdminSocketNamespace } from "./realtime/socketRegistry.js"; -import posthogClient, { initTelemetry } from "./utils/posthog.js"; -import { setupExpressErrorHandler } from "posthog-node"; +} from './utils/httpCache.js'; +import { cleanupOldDeveloperUsage } from './db/developer.js'; +import { cleanupOldWebsocketSnapshots } from './db/websocketSnapshots.js'; +import { startDatabaseMetricsCapture } from './db/databaseMetrics.js'; +import { registerAdminSocketNamespace } from './realtime/socketRegistry.js'; +import posthogClient, { initTelemetry } from './utils/posthog.js'; +import { setupExpressErrorHandler } from 'posthog-node'; dotenv.config({ path: - process.env.NODE_ENV === "production" - ? ".env.production" - : ".env.development", + process.env.NODE_ENV === 'production' + ? '.env.production' + : '.env.development', }); initTelemetry(); -console.log(chalk.bgBlue("NODE_ENV:"), process.env.NODE_ENV); +console.log(chalk.bgBlue('NODE_ENV:'), process.env.NODE_ENV); const requiredEnv = [ - "DISCORD_CLIENT_ID", - "DISCORD_CLIENT_SECRET", - "DISCORD_REDIRECT_URI", - "FRONTEND_URL", - "JWT_SECRET", - "POSTGRES_DB_URL", - "REDIS_URL", - "PORT", + 'DISCORD_CLIENT_ID', + 'DISCORD_CLIENT_SECRET', + 'DISCORD_REDIRECT_URI', + 'FRONTEND_URL', + 'JWT_SECRET', + 'POSTGRES_DB_URL', + 'REDIS_URL', + 'PORT', ]; const missingEnv = requiredEnv.filter( - (key) => !process.env[key] || process.env[key] === "" + (key) => !process.env[key] || process.env[key] === '' ); if (missingEnv.length > 0) { console.error( - "Missing required environment variables:", - missingEnv.join(", ") + 'Missing required environment variables:', + missingEnv.join(', ') ); process.exit(1); } @@ -73,12 +73,12 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const astroEntryCandidates = [ - path.join(__dirname, "../astro/dist/server/entry.mjs"), - path.join(__dirname, "../../astro/dist/server/entry.mjs"), + path.join(__dirname, '../astro/dist/server/entry.mjs'), + path.join(__dirname, '../../astro/dist/server/entry.mjs'), ]; const astroEntryPath = astroEntryCandidates.find((p) => existsSync(p)) ?? null; const astroClientDir = astroEntryPath - ? path.resolve(path.dirname(astroEntryPath), "..", "client") + ? path.resolve(path.dirname(astroEntryPath), '..', 'client') : null; let astroHandler: RequestHandler | null = null; @@ -89,83 +89,83 @@ if (astroEntryPath) { }; astroHandler = handler; console.log( - chalk.green.bold("[Astro] SSR enabled"), - chalk.green("— middleware loaded from"), + chalk.green.bold('[Astro] SSR enabled'), + chalk.green('— middleware loaded from'), chalk.cyan(path.relative(process.cwd(), astroEntryPath) || astroEntryPath) ); if (astroClientDir && existsSync(astroClientDir)) { console.log( - chalk.green.bold("[Astro] Static assets"), - chalk.green("—"), + chalk.green.bold('[Astro] Static assets'), + chalk.green('—'), chalk.cyan( path.relative(process.cwd(), astroClientDir) || astroClientDir ) ); } else if (astroClientDir) { console.warn( - chalk.yellow("[Astro] Expected client bundle missing (no styles?) at"), + chalk.yellow('[Astro] Expected client bundle missing (no styles?) at'), chalk.yellow(astroClientDir) ); } } catch (err) { console.warn( - chalk.yellow.bold("[Astro] SSR disabled"), - chalk.yellow("— failed to import handler:"), + chalk.yellow.bold('[Astro] SSR disabled'), + chalk.yellow('— failed to import handler:'), err ); } } else { console.warn( - chalk.yellow.bold("[Astro] SSR disabled"), - chalk.yellow("— no build at astro/dist/server/entry.mjs (run"), - chalk.cyan("npm run build:astro"), - chalk.yellow("). Checked:") + chalk.yellow.bold('[Astro] SSR disabled'), + chalk.yellow('— no build at astro/dist/server/entry.mjs (run'), + chalk.cyan('npm run build:astro'), + chalk.yellow('). Checked:') ); for (const p of astroEntryCandidates) { - console.warn(chalk.gray(" ·"), p); + console.warn(chalk.gray(' ·'), p); } console.warn( chalk.gray( - "Use the API port (e.g. http://localhost:9901/) for SSR pages — Vite on :5173 always serves the React SPA." + 'Use the API port (e.g. http://localhost:9901/) for SSR pages — Vite on :5173 always serves the React SPA.' ) ); } const app = express(); -app.set("trust proxy", 1); +app.set('trust proxy', 1); app.use( cors({ origin: - process.env.NODE_ENV === "production" - ? ["https://pfcontrol.com", "https://canary.pfcontrol.com"] - : ["http://localhost:9901", "http://localhost:5173"], + process.env.NODE_ENV === 'production' + ? ['https://pfcontrol.com', 'https://canary.pfcontrol.com'] + : ['http://localhost:9901', 'http://localhost:5173'], credentials: true, - methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: [ - "Content-Type", - "Authorization", - "X-Api-Key", - "X-Requested-With", - "Accept", - "Origin", - "Access-Control-Allow-Credentials", + 'Content-Type', + 'Authorization', + 'X-Api-Key', + 'X-Requested-With', + 'Accept', + 'Origin', + 'Access-Control-Allow-Credentials', ], }) ); -const developerApiCorsOrigins = (process.env.DEVELOPER_API_CORS_ORIGINS ?? "") - .split(",") +const developerApiCorsOrigins = (process.env.DEVELOPER_API_CORS_ORIGINS ?? '') + .split(',') .map((s) => s.trim()) .filter(Boolean); if (developerApiCorsOrigins.length > 0) { app.use( - "/api/ext", + '/api/ext', cors({ origin: developerApiCorsOrigins, credentials: false, - methods: ["GET", "HEAD", "OPTIONS"], - allowedHeaders: ["Authorization", "X-Api-Key", "Content-Type", "Accept"], + methods: ['GET', 'HEAD', 'OPTIONS'], + allowedHeaders: ['Authorization', 'X-Api-Key', 'Content-Type', 'Accept'], }) ); } @@ -174,30 +174,30 @@ app.use(express.json()); app.use(apiLogger()); -app.get("/health", (_req, res) => { - res.json({ status: "ok", environment: process.env.NODE_ENV }); +app.get('/health', (_req, res) => { + res.json({ status: 'ok', environment: process.env.NODE_ENV }); }); -app.use("/api", apiRoutes); +app.use('/api', apiRoutes); function setHashedStaticHeaders(res: Response, filePath: string): void { if (filenameLooksContentHashed(filePath)) { applyImmutableAsset(res); } - if (filePath.endsWith(".js")) { - res.setHeader("Content-Type", "application/javascript; charset=utf-8"); - } else if (filePath.endsWith(".css")) { - res.setHeader("Content-Type", "text/css; charset=utf-8"); + if (filePath.endsWith('.js')) { + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + } else if (filePath.endsWith('.css')) { + res.setHeader('Content-Type', 'text/css; charset=utf-8'); } } app.use( - express.static(path.join(__dirname, "../public"), { + express.static(path.join(__dirname, '../public'), { setHeaders: setHashedStaticHeaders, }) ); app.use( - express.static(path.join(__dirname, "..", "..", "dist"), { + express.static(path.join(__dirname, '..', '..', 'dist'), { index: false, setHeaders: setHashedStaticHeaders, }) @@ -212,26 +212,26 @@ if (astroClientDir && existsSync(astroClientDir)) { } app.use((req, res, next) => { - if (req.method !== "GET" && req.method !== "HEAD") return next(); - if (!req.path.startsWith("/_astro/")) return next(); + if (req.method !== 'GET' && req.method !== 'HEAD') return next(); + if (!req.path.startsWith('/_astro/')) return next(); res .status(404) - .type("text/plain") + .type('text/plain') .send( - "Astro client chunk not found. Try a hard refresh (Ctrl+Shift+R) or clear site data for this host — your page may reference an old deploy." + 'Astro client chunk not found. Try a hard refresh (Ctrl+Shift+R) or clear site data for this host — your page may reference an old deploy.' ); }); if (astroHandler) { app.use((req, res, next) => { - if (req.query["tutorial"] === "true") return next(); + if (req.query['tutorial'] === 'true') return next(); astroHandler!(req, res, next); }); } -app.get("/{*any}", (_req, res) => { - res.setHeader("Cache-Control", "no-cache"); - res.sendFile(path.join(__dirname, "..", "..", "dist", "index.html")); +app.get('/{*any}', (_req, res) => { + res.setHeader('Cache-Control', 'no-cache'); + res.sendFile(path.join(__dirname, '..', '..', 'dist', 'index.html')); }); setupExpressErrorHandler(posthogClient, app); @@ -247,49 +247,49 @@ const subClient = pubClient.duplicate(); const sessionUsersIO = setupSessionUsersWebsocket(server); sessionUsersIO.adapter(createAdapter(pubClient, subClient)); registerAdminSocketNamespace( - "session-users", - "Session Users", - "/sockets/session-users", + 'session-users', + 'Session Users', + '/sockets/session-users', sessionUsersIO ); const chatIO = setupChatWebsocket(server, sessionUsersIO); chatIO.adapter(createAdapter(pubClient, subClient)); -registerAdminSocketNamespace("chat", "Session Chat", "/sockets/chat", chatIO); +registerAdminSocketNamespace('chat', 'Session Chat', '/sockets/chat', chatIO); const globalChatIO = setupGlobalChatWebsocket(server, sessionUsersIO); globalChatIO.adapter(createAdapter(pubClient, subClient)); registerAdminSocketNamespace( - "global-chat", - "Global Chat", - "/sockets/global-chat", + 'global-chat', + 'Global Chat', + '/sockets/global-chat', globalChatIO ); const flightsIO = setupFlightsWebsocket(server); flightsIO.adapter(createAdapter(pubClient, subClient)); registerAdminSocketNamespace( - "flights", - "Flights", - "/sockets/flights", + 'flights', + 'Flights', + '/sockets/flights', flightsIO ); const overviewIO = setupOverviewWebsocket(server, sessionUsersIO); overviewIO.adapter(createAdapter(pubClient, subClient)); registerAdminSocketNamespace( - "overview", - "Overview", - "/sockets/overview", + 'overview', + 'Overview', + '/sockets/overview', overviewIO ); const arrivalsIO = setupArrivalsWebsocket(server); arrivalsIO.adapter(createAdapter(pubClient, subClient)); registerAdminSocketNamespace( - "arrivals", - "Arrivals", - "/sockets/arrivals", + 'arrivals', + 'Arrivals', + '/sockets/arrivals', arrivalsIO ); @@ -299,27 +299,27 @@ const sectorControllerIO = setupSectorControllerWebsocket( ); sectorControllerIO.adapter(createAdapter(pubClient, subClient)); registerAdminSocketNamespace( - "sector-controller", - "Sector Controller", - "/sockets/sector-controller", + 'sector-controller', + 'Sector Controller', + '/sockets/sector-controller', sectorControllerIO ); const voiceChatIO = setupVoiceChatWebsocket(server); voiceChatIO.adapter(createAdapter(pubClient, subClient)); registerAdminSocketNamespace( - "voice-chat", - "Voice Chat", - "/sockets/voice-chat", + 'voice-chat', + 'Voice Chat', + '/sockets/voice-chat', voiceChatIO ); const notificationsIO = setupNotificationsWebsocket(server); notificationsIO.adapter(createAdapter(pubClient, subClient)); registerAdminSocketNamespace( - "notifications", - "Notifications", - "/sockets/notifications", + 'notifications', + 'Notifications', + '/sockets/notifications', notificationsIO ); @@ -349,9 +349,9 @@ setInterval( server.listen(PORT, () => { console.log(chalk.green(`Server running on http://localhost:${PORT}`)); - void import("./realtime/activeSessions.js") + void import('./realtime/activeSessions.js') .then((m) => m.rebuildActiveNetworkSetsFromRedis()) .catch((err) => { - console.error("[activeSessions] startup rebuild failed:", err); + console.error('[activeSessions] startup rebuild failed:', err); }); -}); \ No newline at end of file +}); diff --git a/server/middleware/apiLogger.ts b/server/middleware/apiLogger.ts index 3f51a027..a3b8825a 100644 --- a/server/middleware/apiLogger.ts +++ b/server/middleware/apiLogger.ts @@ -1,10 +1,10 @@ -import { Request, Response, NextFunction } from "express"; -import { mainDb } from "../db/connection.js"; -import { recordTableDeletes } from "../db/databaseMetrics.js"; -import { getClientIp } from "../utils/getIpAddress.js"; -import { JwtPayloadClient } from "../types/JwtPayload.js"; -import { sql } from "kysely"; -import { logger } from "../utils/posthog.js"; +import { Request, Response, NextFunction } from 'express'; +import { mainDb } from '../db/connection.js'; +import { recordTableDeletes } from '../db/databaseMetrics.js'; +import { getClientIp } from '../utils/getIpAddress.js'; +import { JwtPayloadClient } from '../types/JwtPayload.js'; +import { sql } from 'kysely'; +import { logger } from '../utils/posthog.js'; interface RequestWithUser extends Request { user?: JwtPayloadClient; @@ -26,31 +26,31 @@ export interface ApiLogEntry { } const EXCLUDED_PATHS = [ - "/health", - "/api/ext/", - "/api/data/metar", - "/api/data/airports", - "/api/data/airlines", - "/api/data/aircrafts", - "/api/data/frequencies", - "/api/admin/api-logs", - "/assets", - ".css", - ".js", - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".ico", - ".svg", - ".woff", - ".woff2", - ".ttf", - ".eot", + '/health', + '/api/ext/', + '/api/data/metar', + '/api/data/airports', + '/api/data/airlines', + '/api/data/aircrafts', + '/api/data/frequencies', + '/api/admin/api-logs', + '/assets', + '.css', + '.js', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.ico', + '.svg', + '.woff', + '.woff2', + '.ttf', + '.eot', ]; -const SENSITIVE_FIELDS = ["token", "secret", "key", "authorization"]; +const SENSITIVE_FIELDS = ['token', 'secret', 'key', 'authorization']; function shouldLogRequest(path: string): boolean { return !EXCLUDED_PATHS.some((excluded) => @@ -59,7 +59,7 @@ function shouldLogRequest(path: string): boolean { } function sanitizeObject(obj: unknown): unknown { - if (!obj || typeof obj !== "object") return obj; + if (!obj || typeof obj !== 'object') return obj; if (Array.isArray(obj)) { return obj.map((item) => sanitizeObject(item)); @@ -70,7 +70,7 @@ function sanitizeObject(obj: unknown): unknown { const lowerKey = key.toLowerCase(); if (SENSITIVE_FIELDS.some((field) => lowerKey.includes(field))) { - sanitized[key] = "[REDACTED]"; + sanitized[key] = '[REDACTED]'; } else { sanitized[key] = sanitizeObject(value); } @@ -82,17 +82,17 @@ function sanitizeObject(obj: unknown): unknown { function truncateString(str: string, maxLength: number = 10000): string { if (str.length <= maxLength) return str; - return str.substring(0, maxLength) + "... [TRUNCATED]"; + return str.substring(0, maxLength) + '... [TRUNCATED]'; } export async function logApiCall(logEntry: ApiLogEntry): Promise { try { const ipAddress = Array.isArray(logEntry.ip_address) - ? logEntry.ip_address.join(", ") + ? logEntry.ip_address.join(', ') : logEntry.ip_address; await mainDb - .insertInto("api_logs") + .insertInto('api_logs') .values({ id: sql`DEFAULT`, user_id: logEntry.user_id, @@ -110,7 +110,7 @@ export async function logApiCall(logEntry: ApiLogEntry): Promise { }) .execute(); } catch (error) { - console.error("Failed to log API call:", error); + console.error('Failed to log API call:', error); } } @@ -127,18 +127,18 @@ export function apiLogger() { res.send = function (data) { try { - if (data && typeof data === "object") { + if (data && typeof data === 'object') { responseBody = truncateString(JSON.stringify(sanitizeObject(data))); - } else if (typeof data === "string") { + } else if (typeof data === 'string') { responseBody = truncateString(data); } } catch { - responseBody = "[SERIALIZATION_ERROR]"; + responseBody = '[SERIALIZATION_ERROR]'; } return originalSend.call(this, data); }; - res.on("finish", async () => { + res.on('finish', async () => { const endTime = Date.now(); const responseTime = endTime - startTime; @@ -151,13 +151,13 @@ export function apiLogger() { JSON.stringify(sanitizeObject(req.body)) ); } catch { - requestBody = "[SERIALIZATION_ERROR]"; + requestBody = '[SERIALIZATION_ERROR]'; } } const ipAddress = getClientIp(req); const finalIpAddress = Array.isArray(ipAddress) - ? ipAddress.join(", ") + ? ipAddress.join(', ') : ipAddress; const logEntry: ApiLogEntry = { @@ -168,7 +168,7 @@ export function apiLogger() { status_code: res.statusCode, response_time: responseTime, ip_address: finalIpAddress, - user_agent: req.get("User-Agent") || null, + user_agent: req.get('User-Agent') || null, request_body: requestBody, response_body: responseBody, error_message: errorMessage, @@ -178,7 +178,7 @@ export function apiLogger() { setImmediate(() => logApiCall(logEntry)); if (res.statusCode >= 500) { - logger.error("API 5xx response", { + logger.error('API 5xx response', { method: req.method, path: req.originalUrl, status_code: res.statusCode, @@ -187,7 +187,7 @@ export function apiLogger() { }); } } catch (error) { - console.error("Error creating API log entry:", error); + console.error('Error creating API log entry:', error); } }); @@ -204,14 +204,14 @@ export async function cleanupOldApiLogs( cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); const result = await mainDb - .deleteFrom("api_logs") - .where("created_at", "<", cutoffDate) + .deleteFrom('api_logs') + .where('created_at', '<', cutoffDate) .executeTakeFirst(); const deleted = Number(result?.numDeletedRows ?? 0); - await recordTableDeletes("api_logs", deleted); + await recordTableDeletes('api_logs', deleted); console.log(`Cleaned up ${deleted} old API log entries`); } catch (error) { - console.error("Failed to cleanup old API logs:", error); + console.error('Failed to cleanup old API logs:', error); } -} \ No newline at end of file +} diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index ef92175e..fa3935f2 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -115,7 +115,6 @@ export default async function requireAuth( } } - const validIp = ip && ip !== 'unknown' ? ip : null; if (!isBanned && validIp) { const ipBanCacheKey = `ban:ip:${validIp}`; @@ -135,11 +134,10 @@ export default async function requireAuth( return res.status(403).json({ error: 'Account is banned' }); } - // VPN gate check — block if stored flag OR current IP is detected as VPN const gateEnabled = await isVpnGateEnabled(); if (gateEnabled) { - if (user.is_vpn || (validIp && await isIpVpn(validIp))) { + if (user.is_vpn || (validIp && (await isIpVpn(validIp)))) { const hasException = await isVpnException(decoded.userId); if (!hasException) { return res.status(403).json({ error: 'VPN access blocked' }); diff --git a/server/middleware/developerExtApi.ts b/server/middleware/developerExtApi.ts index 4dc91744..eb121929 100644 --- a/server/middleware/developerExtApi.ts +++ b/server/middleware/developerExtApi.ts @@ -1,35 +1,37 @@ -import type { Request, Response, NextFunction } from "express"; -import { getClientIp } from "../utils/getIpAddress.js"; -import { hashIp } from "../utils/encryption.js"; +import type { Request, Response, NextFunction } from 'express'; +import { getClientIp } from '../utils/getIpAddress.js'; +import { hashIp } from '../utils/encryption.js'; import { hashDeveloperApiKeySecret, isSupportedDeveloperApiKeySecretFormat, -} from "../developer/apiKeySecret.js"; +} from '../developer/apiKeySecret.js'; import { findActiveDeveloperKeyBySecretHash, insertDeveloperApiUsage, touchDeveloperApiKeyLastUsed, -} from "../db/developer.js"; -import { matchExtDeveloperRoute } from "../developer/extRoutes.js"; -import { redisConnection } from "../db/connection.js"; +} from '../db/developer.js'; +import { matchExtDeveloperRoute } from '../developer/extRoutes.js'; +import { redisConnection } from '../db/connection.js'; function extractApiSecret(req: Request): string | null { - const auth = req.get("authorization"); - if (auth?.toLowerCase().startsWith("bearer ")) { + const auth = req.get('authorization'); + if (auth?.toLowerCase().startsWith('bearer ')) { const v = auth.slice(7).trim(); if (v) return v; } - const x = req.get("x-api-key"); + const x = req.get('x-api-key'); if (x?.trim()) return x.trim(); return null; } function parseScopesFromKey(scopes: unknown): string[] { - if (Array.isArray(scopes)) return scopes.filter((s): s is string => typeof s === "string"); - if (typeof scopes === "string") { + if (Array.isArray(scopes)) + return scopes.filter((s): s is string => typeof s === 'string'); + if (typeof scopes === 'string') { try { const p = JSON.parse(scopes) as unknown; - if (Array.isArray(p)) return p.filter((s): s is string => typeof s === "string"); + if (Array.isArray(p)) + return p.filter((s): s is string => typeof s === 'string'); } catch { // ignore } @@ -37,22 +39,26 @@ function parseScopesFromKey(scopes: unknown): string[] { return []; } -export function developerExtUsageLifecycle(req: Request, res: Response, next: NextFunction) { +export function developerExtUsageLifecycle( + req: Request, + res: Response, + next: NextFunction +) { req.developerExtStartedAt = Date.now(); - res.on("finish", () => { + res.on('finish', () => { const ext = req.developerExt; const started = req.developerExtStartedAt; if (!ext?.keyId || !ext.matchedScopeId || started == null) return; const durationMs = Math.max(0, Date.now() - started); const ip = getClientIp(req); - const validIp = ip && ip !== "unknown" ? ip : null; + const validIp = ip && ip !== 'unknown' ? ip : null; const ipHash = validIp ? hashIp(validIp) : null; void insertDeveloperApiUsage({ keyId: ext.keyId, userId: ext.userId, scopeId: ext.matchedScopeId, method: req.method, - path: ext.matchedPath || req.originalUrl.split("?")[0], + path: ext.matchedPath || req.originalUrl.split('?')[0], statusCode: res.statusCode, durationMs, ipHash, @@ -65,16 +71,20 @@ export function developerExtUsageLifecycle(req: Request, res: Response, next: Ne next(); } -export async function developerExtApiAuth(req: Request, res: Response, next: NextFunction) { +export async function developerExtApiAuth( + req: Request, + res: Response, + next: NextFunction +) { try { const secret = extractApiSecret(req); if (!secret || !isSupportedDeveloperApiKeySecretFormat(secret)) { - return res.status(401).json({ error: "Invalid or missing API key" }); + return res.status(401).json({ error: 'Invalid or missing API key' }); } const secretHash = hashDeveloperApiKeySecret(secret); const row = await findActiveDeveloperKeyBySecretHash(secretHash); if (!row) { - return res.status(401).json({ error: "Invalid or missing API key" }); + return res.status(401).json({ error: 'Invalid or missing API key' }); } const scopes = parseScopesFromKey(row.key.scopes); req.developerExt = { @@ -82,7 +92,7 @@ export async function developerExtApiAuth(req: Request, res: Response, next: Nex userId: row.key.user_id, scopes, matchedScopeId: null, - matchedPath: "", + matchedPath: '', keyPrefix: row.key.prefix, keyName: row.key.name, rateLimitPerMinute: row.rateLimitPerMinute, @@ -94,24 +104,30 @@ export async function developerExtApiAuth(req: Request, res: Response, next: Nex } export function getDeveloperExtPath(req: Request): string { - const raw = (req.path || "/").split("?")[0]; - if (!raw || raw === "") return "/"; - return raw.startsWith("/") ? raw : `/${raw}`; + const raw = (req.path || '/').split('?')[0]; + if (!raw || raw === '') return '/'; + return raw.startsWith('/') ? raw : `/${raw}`; } -export async function developerExtScopeGuard(req: Request, res: Response, next: NextFunction) { +export async function developerExtScopeGuard( + req: Request, + res: Response, + next: NextFunction +) { try { const ext = req.developerExt; if (!ext) { - return res.status(401).json({ error: "Invalid or missing API key" }); + return res.status(401).json({ error: 'Invalid or missing API key' }); } const path = getDeveloperExtPath(req); const scopeId = matchExtDeveloperRoute(req.method, path); if (!scopeId) { - return res.status(404).json({ error: "Not found" }); + return res.status(404).json({ error: 'Not found' }); } if (!ext.scopes.includes(scopeId)) { - return res.status(403).json({ error: "This API key is not allowed to access this endpoint" }); + return res + .status(403) + .json({ error: 'This API key is not allowed to access this endpoint' }); } ext.matchedScopeId = scopeId; ext.matchedPath = path; @@ -131,13 +147,19 @@ export function getDeveloperApiDefaultRateLimitPerMinute(): number { return Number.isFinite(envDefault) && envDefault > 0 ? envDefault : 120; } -export async function developerExtRateLimit(req: Request, res: Response, next: NextFunction) { +export async function developerExtRateLimit( + req: Request, + res: Response, + next: NextFunction +) { const ext = req.developerExt; if (!ext?.keyId) return next(); const fallback = getDeveloperApiDefaultRateLimitPerMinute(); const perKey = ext.rateLimitPerMinute; const raw = - perKey != null && Number.isFinite(perKey) && perKey > 0 ? Math.floor(perKey) : fallback; + perKey != null && Number.isFinite(perKey) && perKey > 0 + ? Math.floor(perKey) + : fallback; const max = Math.max(RPM_FLOOR, raw); const windowStart = Math.floor(Date.now() / 60_000); const rkey = `devapi:rl:${ext.keyId}:${windowStart}`; @@ -147,11 +169,11 @@ export async function developerExtRateLimit(req: Request, res: Response, next: N await redisConnection.expire(rkey, 70); } if (n > max) { - res.setHeader("Retry-After", "60"); - return res.status(429).json({ error: "Rate limit exceeded" }); + res.setHeader('Retry-After', '60'); + return res.status(429).json({ error: 'Rate limit exceeded' }); } } catch (e) { - console.warn("[developerExtRateLimit] Redis error:", e); + console.warn('[developerExtRateLimit] Redis error:', e); } next(); -} \ No newline at end of file +} diff --git a/server/middleware/flightAccess.ts b/server/middleware/flightAccess.ts index f3902729..eeaed19a 100644 --- a/server/middleware/flightAccess.ts +++ b/server/middleware/flightAccess.ts @@ -1,22 +1,35 @@ -import { Request, Response, NextFunction } from "express"; -import { mainDb } from "../db/connection.js"; -import { getUserRoles } from "../db/roles.js"; - -function hasPermission(roles: Awaited>, permKey: string): boolean { +import { Request, Response, NextFunction } from 'express'; +import { mainDb } from '../db/connection.js'; +import { getUserRoles } from '../db/roles.js'; + +function hasPermission( + roles: Awaited>, + permKey: string +): boolean { return roles.some((role) => { let perms = role.permissions; - if (typeof perms === "string") { - try { perms = JSON.parse(perms); } catch { return false; } + if (typeof perms === 'string') { + try { + perms = JSON.parse(perms); + } catch { + return false; + } } - return perms && typeof perms === "object" && (perms as Record)[permKey] === true; + return ( + perms && + typeof perms === 'object' && + (perms as Record)[permKey] === true + ); }); } /** Check if user can edit PFATC session flights (pfatc_sector permission) */ -export async function isPFATCSectorController(userId: string): Promise { +export async function isPFATCSectorController( + userId: string +): Promise { try { const userRoles = await getUserRoles(userId); - return hasPermission(userRoles, "pfatc_sector"); + return hasPermission(userRoles, 'pfatc_sector'); } catch { return false; } @@ -26,7 +39,7 @@ export async function isPFATCSectorController(userId: string): Promise export async function isAATCSectorController(userId: string): Promise { try { const userRoles = await getUserRoles(userId); - return hasPermission(userRoles, "aatc_sector"); + return hasPermission(userRoles, 'aatc_sector'); } catch { return false; } @@ -36,42 +49,55 @@ export async function isAATCSectorController(userId: string): Promise { export async function isEventController(userId: string): Promise { try { const userRoles = await getUserRoles(userId); - return hasPermission(userRoles, "pfatc_sector") || hasPermission(userRoles, "aatc_sector"); + return ( + hasPermission(userRoles, 'pfatc_sector') || + hasPermission(userRoles, 'aatc_sector') + ); } catch { return false; } } -export async function requireFlightAccess(req: Request, res: Response, next: NextFunction) { +export async function requireFlightAccess( + req: Request, + res: Response, + next: NextFunction +) { try { const { sessionId } = req.params; const userId = req.user?.userId; if (!sessionId || !userId) { return res.status(400).json({ - error: "Session ID and authentication are required", + error: 'Session ID and authentication are required', }); } const session = await mainDb - .selectFrom("sessions") - .select(["session_id", "access_id", "created_by", "is_pfatc", "is_advanced_atc"]) - .where("session_id", "=", sessionId) + .selectFrom('sessions') + .select([ + 'session_id', + 'access_id', + 'created_by', + 'is_pfatc', + 'is_advanced_atc', + ]) + .where('session_id', '=', sessionId) .executeTakeFirst(); if (!session) { - return res.status(404).json({ error: "Session not found" }); + return res.status(404).json({ error: 'Session not found' }); } const userRoles = await getUserRoles(userId); // PFATC Sector Controller can edit flights in PFATC sessions - if (hasPermission(userRoles, "pfatc_sector") && session.is_pfatc) { + if (hasPermission(userRoles, 'pfatc_sector') && session.is_pfatc) { return next(); } // AATC Sector Controller can edit flights in Advanced ATC sessions - if (hasPermission(userRoles, "aatc_sector") && session.is_advanced_atc) { + if (hasPermission(userRoles, 'aatc_sector') && session.is_advanced_atc) { return next(); } @@ -85,12 +111,12 @@ export async function requireFlightAccess(req: Request, res: Response, next: Nex } return res.status(403).json({ - error: "Not authorized to modify flights in this session", + error: 'Not authorized to modify flights in this session', }); } catch (error) { - console.error("Flight access validation error:", error); + console.error('Flight access validation error:', error); return res.status(500).json({ - error: "Failed to verify flight access permissions", + error: 'Failed to verify flight access permissions', }); } } @@ -98,28 +124,36 @@ export async function requireFlightAccess(req: Request, res: Response, next: Nex export async function canModifySession( userId: string, sessionId: string, - accessId?: string, + accessId?: string ): Promise { try { const session = await mainDb - .selectFrom("sessions") - .select(["session_id", "access_id", "created_by", "is_pfatc", "is_advanced_atc"]) - .where("session_id", "=", sessionId) + .selectFrom('sessions') + .select([ + 'session_id', + 'access_id', + 'created_by', + 'is_pfatc', + 'is_advanced_atc', + ]) + .where('session_id', '=', sessionId) .executeTakeFirst(); if (!session) return false; const userRoles = await getUserRoles(userId); - if (hasPermission(userRoles, "pfatc_sector") && session.is_pfatc) return true; - if (hasPermission(userRoles, "aatc_sector") && session.is_advanced_atc) return true; + if (hasPermission(userRoles, 'pfatc_sector') && session.is_pfatc) + return true; + if (hasPermission(userRoles, 'aatc_sector') && session.is_advanced_atc) + return true; if (accessId && accessId === session.access_id) return true; if (userId === session.created_by) return true; return false; } catch (error) { - console.error("Error checking session modification permissions:", error); + console.error('Error checking session modification permissions:', error); return false; } } diff --git a/server/middleware/httpErrorHandler.ts b/server/middleware/httpErrorHandler.ts index 645dea83..9cf1b1ef 100644 --- a/server/middleware/httpErrorHandler.ts +++ b/server/middleware/httpErrorHandler.ts @@ -1,19 +1,19 @@ -import type { NextFunction, Request, Response } from "express"; -import { logger } from "../utils/posthog.js"; +import type { NextFunction, Request, Response } from 'express'; +import { logger } from '../utils/posthog.js'; function getHttpStatus(err: unknown): number { - if (!err || typeof err !== "object") return 500; + if (!err || typeof err !== 'object') return 500; const o = err as Record; const raw = o.status ?? o.statusCode ?? o.status_code; - if (typeof raw === "number" && raw >= 400 && raw < 600) return raw; - if (typeof raw === "string") { + if (typeof raw === 'number' && raw >= 400 && raw < 600) return raw; + if (typeof raw === 'string') { const n = Number(raw); if (!Number.isNaN(n) && n >= 400 && n < 600) return n; } const output = o.output as { statusCode?: number } | undefined; if ( output && - typeof output.statusCode === "number" && + typeof output.statusCode === 'number' && output.statusCode >= 400 && output.statusCode < 600 ) { @@ -26,15 +26,15 @@ export function httpErrorHandler( err: unknown, req: Request, res: Response, - _next: NextFunction, + _next: NextFunction ): void { if (res.headersSent) return; const status = getHttpStatus(err); - const isProd = process.env.NODE_ENV === "production"; + const isProd = process.env.NODE_ENV === 'production'; if (status >= 500) { - logger.error("Unhandled server error", { + logger.error('Unhandled server error', { status, method: req.method, path: req.originalUrl, @@ -42,19 +42,25 @@ export function httpErrorHandler( }); } - if (req.originalUrl.startsWith("/api")) { + if (req.originalUrl.startsWith('/api')) { const clientSafe = status < 500 && err instanceof Error && err.message ? err.message : !isProd && err instanceof Error ? err.message - : "Internal server error"; + : 'Internal server error'; res.status(status).json({ error: clientSafe }); return; } res .status(status >= 500 ? 500 : status) - .type("text/plain") - .send(isProd ? "Something went wrong" : err instanceof Error ? err.message : "Error"); -} \ No newline at end of file + .type('text/plain') + .send( + isProd + ? 'Something went wrong' + : err instanceof Error + ? err.message + : 'Error' + ); +} diff --git a/server/og/SubmitOgCard.tsx b/server/og/SubmitOgCard.tsx index bf12abaa..85b94e06 100644 --- a/server/og/SubmitOgCard.tsx +++ b/server/og/SubmitOgCard.tsx @@ -208,4 +208,4 @@ export function SubmitOgCard({ ); -} \ No newline at end of file +} diff --git a/server/og/loadInterFonts.ts b/server/og/loadInterFonts.ts index 4767a5d6..3ccac153 100644 --- a/server/og/loadInterFonts.ts +++ b/server/og/loadInterFonts.ts @@ -32,4 +32,4 @@ export async function getInterFontsForSatori(): Promise< { name: 'Inter', data: inter400, weight: 400, style: 'normal' }, { name: 'Inter', data: inter700, weight: 700, style: 'normal' }, ]; -} \ No newline at end of file +} diff --git a/server/og/ogLinkIcons.ts b/server/og/ogLinkIcons.ts index 59deab0a..5316129d 100644 --- a/server/og/ogLinkIcons.ts +++ b/server/og/ogLinkIcons.ts @@ -87,7 +87,8 @@ export async function loadOgLinkIcons(): Promise<{ } const vatsim = - (await rasterizeVatsim(vatsimPath)) ?? (await rasterizeSvg(ROBLOX_ICON_SVG)); + (await rasterizeVatsim(vatsimPath)) ?? + (await rasterizeSvg(ROBLOX_ICON_SVG)); const star = await rasterizeSvg(STAR_ICON_SVG); cached = { roblox, vatsim, star }; diff --git a/server/og/profileBackground.ts b/server/og/profileBackground.ts index 1cbf14dd..b2396690 100644 --- a/server/og/profileBackground.ts +++ b/server/og/profileBackground.ts @@ -58,7 +58,13 @@ function resolveFilename( return { kind: 'local', filePath: localPath }; } // File not found locally — fall back to the hero image. - const heroPath = path.join(process.cwd(), 'public', 'assets', 'images', 'hero.webp'); + const heroPath = path.join( + process.cwd(), + 'public', + 'assets', + 'images', + 'hero.webp' + ); if (fs.existsSync(heroPath)) { return { kind: 'local', filePath: heroPath }; } @@ -101,4 +107,4 @@ export function resolveProfileBackground( } return resolveFilename(selected, frontendBase); -} \ No newline at end of file +} diff --git a/server/og/profileOgCache.ts b/server/og/profileOgCache.ts index 3f1f8935..889e3e0c 100644 --- a/server/og/profileOgCache.ts +++ b/server/og/profileOgCache.ts @@ -126,4 +126,4 @@ export async function setCachedProfileOgPng( console.warn('[og] profile cache write:', err.message); } } -} \ No newline at end of file +} diff --git a/server/og/renderProfileOgPng.ts b/server/og/renderProfileOgPng.ts index bd5f6faa..b6fb2479 100644 --- a/server/og/renderProfileOgPng.ts +++ b/server/og/renderProfileOgPng.ts @@ -237,4 +237,4 @@ export async function renderPublicProfileOgPng( }); const pngData = resvg.render(); return Buffer.from(pngData.asPng()); -} \ No newline at end of file +} diff --git a/server/og/renderSubmitOgPng.ts b/server/og/renderSubmitOgPng.ts index 06295f47..d0d39500 100644 --- a/server/og/renderSubmitOgPng.ts +++ b/server/og/renderSubmitOgPng.ts @@ -94,4 +94,4 @@ export async function renderPublicSubmitOgPng( }); const pngData = resvg.render(); return Buffer.from(pngData.asPng()); -} \ No newline at end of file +} diff --git a/server/og/resolvedBackgroundToDataUrl.ts b/server/og/resolvedBackgroundToDataUrl.ts index 8d6df6c4..ff438267 100644 --- a/server/og/resolvedBackgroundToDataUrl.ts +++ b/server/og/resolvedBackgroundToDataUrl.ts @@ -26,4 +26,4 @@ export async function resolvedBackgroundToDataUrl( } return (await fetchUrlAsDataUrl(resolved.url)) ?? null; -} \ No newline at end of file +} diff --git a/server/og/submitBackground.ts b/server/og/submitBackground.ts index 0f6d5191..dd9962e9 100644 --- a/server/og/submitBackground.ts +++ b/server/og/submitBackground.ts @@ -27,4 +27,4 @@ export function resolveSubmitSessionBackground( } return null; -} \ No newline at end of file +} diff --git a/server/og/submitOgCache.ts b/server/og/submitOgCache.ts index 7aa3fe7d..66f1e10d 100644 --- a/server/og/submitOgCache.ts +++ b/server/og/submitOgCache.ts @@ -104,4 +104,4 @@ export async function setCachedSubmitOgPng( console.warn('[og] submit cache write:', err.message); } } -} \ No newline at end of file +} diff --git a/server/og/toSatoriSafeDataUrl.ts b/server/og/toSatoriSafeDataUrl.ts index f8c16d15..68e8c4cd 100644 --- a/server/og/toSatoriSafeDataUrl.ts +++ b/server/og/toSatoriSafeDataUrl.ts @@ -20,4 +20,4 @@ export async function toSatoriSafeDataUrl( } catch { return null; } -} \ No newline at end of file +} diff --git a/server/realtime/activeSessions.ts b/server/realtime/activeSessions.ts index 533d8f8d..8ae37c88 100644 --- a/server/realtime/activeSessions.ts +++ b/server/realtime/activeSessions.ts @@ -1,11 +1,11 @@ -import { redisConnection } from "../db/connection.js"; -import { getSessionById } from "../db/sessions.js"; +import { redisConnection } from '../db/connection.js'; +import { getSessionById } from '../db/sessions.js'; import { getNetworkKind, isAdvancedNetworkSession, type NetworkKind, -} from "../utils/advancedNetworkSession.js"; -import { keys, TTL } from "./keys.js"; +} from '../utils/advancedNetworkSession.js'; +import { keys, TTL } from './keys.js'; export type SessionMeta = { sessionId: string; @@ -161,8 +161,8 @@ export async function unregisterActiveSession( } } else { try { - await redisConnection.srem(keys.activeNetwork("pfatc"), sessionId); - await redisConnection.srem(keys.activeNetwork("advanced_atc"), sessionId); + await redisConnection.srem(keys.activeNetwork('pfatc'), sessionId); + await redisConnection.srem(keys.activeNetwork('advanced_atc'), sessionId); } catch { // ignore } @@ -173,8 +173,8 @@ export async function unregisterActiveSession( export async function getActiveNetworkSessionIds(): Promise { try { const [pfatc, aatc] = await Promise.all([ - redisConnection.smembers(keys.activeNetwork("pfatc")), - redisConnection.smembers(keys.activeNetwork("advanced_atc")), + redisConnection.smembers(keys.activeNetwork('pfatc')), + redisConnection.smembers(keys.activeNetwork('advanced_atc')), ]); return [...new Set([...pfatc, ...aatc])]; } catch { @@ -186,8 +186,8 @@ export async function getActiveNetworkSessionIds(): Promise { export async function rebuildActiveNetworkSetsFromRedis(): Promise { try { await redisConnection.del( - keys.activeNetwork("pfatc"), - keys.activeNetwork("advanced_atc") + keys.activeNetwork('pfatc'), + keys.activeNetwork('advanced_atc') ); const sessionIds = await redisConnection.smembers(keys.activeUsersIndex()); @@ -203,9 +203,9 @@ export async function rebuildActiveNetworkSetsFromRedis(): Promise { return; } - const activeKeys = await redisConnection.keys("activeUsers:*"); + const activeKeys = await redisConnection.keys('activeUsers:*'); for (const key of activeKeys) { - const sessionId = key.replace("activeUsers:", ""); + const sessionId = key.replace('activeUsers:', ''); const count = await redisConnection.hlen(key); if (count > 0) { await redisConnection.sadd(keys.activeUsersIndex(), sessionId); @@ -213,7 +213,7 @@ export async function rebuildActiveNetworkSetsFromRedis(): Promise { } } } catch (err) { - console.error("[activeSessions] rebuild failed:", err); + console.error('[activeSessions] rebuild failed:', err); } } @@ -226,4 +226,4 @@ export async function onSessionUsersChanged( } else { await unregisterActiveSession(sessionId); } -} \ No newline at end of file +} diff --git a/server/realtime/arrivals.ts b/server/realtime/arrivals.ts index 4f540d81..e8692d6d 100644 --- a/server/realtime/arrivals.ts +++ b/server/realtime/arrivals.ts @@ -2,14 +2,14 @@ import { getExternalArrivalFlights, type ClientFlight, type ExternalArrivalFlight, -} from "../db/flights.js"; -import { getSessionsByAirportAndNetwork } from "../db/sessions.js"; -import { redisConnection } from "../db/connection.js"; -import type { NetworkKind } from "../utils/advancedNetworkSession.js"; -import { keys, TTL } from "./keys.js"; -import { perfAsync } from "./perf.js"; -import { getSessionMeta } from "./activeSessions.js"; -import { getArrivalsIO } from "./socketRegistry.js"; +} from '../db/flights.js'; +import { getSessionsByAirportAndNetwork } from '../db/sessions.js'; +import { redisConnection } from '../db/connection.js'; +import type { NetworkKind } from '../utils/advancedNetworkSession.js'; +import { keys, TTL } from './keys.js'; +import { perfAsync } from './perf.js'; +import { getSessionMeta } from './activeSessions.js'; +import { getArrivalsIO } from './socketRegistry.js'; export async function getCachedExternalArrivals( airportIcao: string, @@ -24,7 +24,7 @@ export async function getCachedExternalArrivals( } return perfAsync( - "getExternalArrivalFlights", + 'getExternalArrivalFlights', async () => { const flights = await getExternalArrivalFlights(airportIcao, networkKind); try { @@ -106,9 +106,9 @@ export async function broadcastArrivalChange( try { const sessionIds = await getArrivalSessionIds(arrivalIcao, kind); for (const sessionId of sessionIds) { - arrivalsIO.to(sessionId).emit("arrivalUpdated", flight); + arrivalsIO.to(sessionId).emit('arrivalUpdated', flight); } } catch (err) { - console.error("[arrivals] broadcast failed:", err); + console.error('[arrivals] broadcast failed:', err); } -} \ No newline at end of file +} diff --git a/server/realtime/chatCache.ts b/server/realtime/chatCache.ts index d6d3e545..f36f6f4b 100644 --- a/server/realtime/chatCache.ts +++ b/server/realtime/chatCache.ts @@ -1,7 +1,7 @@ -import { redisConnection } from "../db/connection.js"; -import { getChatMessagesFromDb } from "../db/chats.js"; -import { keys, TTL } from "./keys.js"; -import { perfAsync } from "./perf.js"; +import { redisConnection } from '../db/connection.js'; +import { getChatMessagesFromDb } from '../db/chats.js'; +import { keys, TTL } from './keys.js'; +import { perfAsync } from './perf.js'; const CHAT_RECENT_LIMIT = 100; @@ -25,7 +25,7 @@ export async function getCachedSessionChatMessages( } return perfAsync( - "getChatMessages", + 'getChatMessages', async () => { const messages = await getChatMessagesFromDb(sessionId, limit); try { @@ -87,7 +87,7 @@ export type GlobalChatMessageDto = { }; export async function getCachedGlobalChatMessages( - networkKind: "pfatc" | "aatc", + networkKind: 'pfatc' | 'aatc', loader: () => Promise ): Promise { const cacheKey = keys.chatGlobal(networkKind); @@ -112,11 +112,11 @@ export async function getCachedGlobalChatMessages( } export async function invalidateGlobalChatCache( - networkKind: "pfatc" | "aatc" + networkKind: 'pfatc' | 'aatc' ): Promise { try { await redisConnection.del(keys.chatGlobal(networkKind)); } catch { // ignore } -} \ No newline at end of file +} diff --git a/server/realtime/flightsRead.ts b/server/realtime/flightsRead.ts index 5233c24d..4d360df0 100644 --- a/server/realtime/flightsRead.ts +++ b/server/realtime/flightsRead.ts @@ -1,11 +1,11 @@ -import { sql } from "kysely"; -import { mainDb } from "../db/connection.js"; -import { redisConnection } from "../db/connection.js"; -import { sanitizeFlightForClient, type ClientFlight } from "../db/flights.js"; -import { validateSessionId } from "../utils/validation.js"; -import { keys, TTL } from "./keys.js"; -import { perfAsync } from "./perf.js"; -import { getUserBadgesByIds } from "./userCache.js"; +import { sql } from 'kysely'; +import { mainDb } from '../db/connection.js'; +import { redisConnection } from '../db/connection.js'; +import { sanitizeFlightForClient, type ClientFlight } from '../db/flights.js'; +import { validateSessionId } from '../utils/validation.js'; +import { keys, TTL } from './keys.js'; +import { perfAsync } from './perf.js'; +import { getUserBadgesByIds } from './userCache.js'; function createUTCDate(): Date { const now = new Date(); @@ -35,7 +35,7 @@ async function attachUsersToFlights( ...new Set( rows .map((r) => r.userId) - .filter((id): id is string => typeof id === "string") + .filter((id): id is string => typeof id === 'string') ), ]; @@ -48,7 +48,7 @@ async function attachUsersToFlights( ...flight, user: { id: userId, - discord_username: badge.username ?? "Unknown", + discord_username: badge.username ?? 'Unknown', discord_avatar_url: badge.avatar, }, }; @@ -66,22 +66,22 @@ export async function getFlightsForSessions( const sinceIso = sinceIsoHours(hoursBack); return perfAsync( - "getFlightsForSessions", + 'getFlightsForSessions', async () => { const rows = await mainDb - .selectFrom("flights") + .selectFrom('flights') .selectAll() - .where("session_id", "in", validIds) + .where('session_id', 'in', validIds) .where((eb) => eb.or([ - eb("flight_plan_time", ">=", sinceIso), - eb("updated_at", ">=", sql`${sinceIso}`), - eb("created_at", ">=", sql`${sinceIso}`), + eb('flight_plan_time', '>=', sinceIso), + eb('updated_at', '>=', sql`${sinceIso}`), + eb('created_at', '>=', sql`${sinceIso}`), ]) ) .orderBy( sql`COALESCE(flight_plan_time::timestamp, created_at, updated_at)`, - "desc" + 'desc' ) .execute(); @@ -140,12 +140,12 @@ export async function getAllFlightsForSession( const validSessionId = validateSessionId(sessionId); const rows = await mainDb - .selectFrom("flights") + .selectFrom('flights') .selectAll() - .where("session_id", "=", validSessionId) + .where('session_id', '=', validSessionId) .orderBy( sql`COALESCE(flight_plan_time::timestamp, created_at, updated_at)`, - "desc" + 'desc' ) .execute(); @@ -203,4 +203,4 @@ export async function invalidateFlightSourceCache( } catch { // ignore } -} \ No newline at end of file +} diff --git a/server/realtime/invalidate.ts b/server/realtime/invalidate.ts index 31a52da8..ef4a67c3 100644 --- a/server/realtime/invalidate.ts +++ b/server/realtime/invalidate.ts @@ -1,16 +1,16 @@ -import { redisConnection } from "../db/connection.js"; -import type { ClientFlight } from "../db/flights.js"; -import type { NetworkKind } from "../utils/advancedNetworkSession.js"; -import { keys } from "./keys.js"; +import { redisConnection } from '../db/connection.js'; +import type { ClientFlight } from '../db/flights.js'; +import type { NetworkKind } from '../utils/advancedNetworkSession.js'; +import { keys } from './keys.js'; import { invalidateSessionFlightsCache, setFlightSourceCache, invalidateFlightSourceCache, -} from "./flightsRead.js"; -import { invalidateArrivalsCache, broadcastArrivalChange } from "./arrivals.js"; -import { scheduleOverviewRefresh } from "./overview.js"; -import { getOverviewIO } from "./socketRegistry.js"; -import { onSessionUsersChanged } from "./activeSessions.js"; +} from './flightsRead.js'; +import { invalidateArrivalsCache, broadcastArrivalChange } from './arrivals.js'; +import { scheduleOverviewRefresh } from './overview.js'; +import { getOverviewIO } from './socketRegistry.js'; +import { onSessionUsersChanged } from './activeSessions.js'; export type FlightChangePayload = { sessionId: string; @@ -42,7 +42,7 @@ export async function onFlightChanged( if (flight && !deleted) { const overviewIO = getOverviewIO(); if (overviewIO) { - overviewIO.emit("flightUpdated", { sessionId, flight }); + overviewIO.emit('flightUpdated', { sessionId, flight }); } void broadcastArrivalChange(flight, sessionId, networkKind); } @@ -71,4 +71,4 @@ export async function onChatMessage(sessionId: string): Promise { } catch { // ignore } -} \ No newline at end of file +} diff --git a/server/realtime/keys.ts b/server/realtime/keys.ts index 58762f6b..8f9566c2 100644 --- a/server/realtime/keys.ts +++ b/server/realtime/keys.ts @@ -1,4 +1,4 @@ -import { prefixKey } from "../utils/cacheTtl.js"; +import { prefixKey } from '../utils/cacheTtl.js'; export const TTL = { USER_SESSIONS_SEC: 5 * 60, @@ -15,23 +15,23 @@ export const TTL = { export const keys = { userSessions: (userId: string) => prefixKey(`user:sessions:${userId}`), - activeNetwork: (network: "pfatc" | "advanced_atc") => + activeNetwork: (network: 'pfatc' | 'advanced_atc') => prefixKey(`active:network:${network}`), sessionMeta: (sessionId: string) => prefixKey(`session:meta:${sessionId}`), sessionFlights: (sessionId: string) => prefixKey(`flights:session:${sessionId}`), - overviewSnapshot: () => prefixKey("overview:snapshot"), - overviewVersion: () => prefixKey("overview:version"), - arrivals: (network: "pfatc" | "advanced_atc", icao: string) => + overviewSnapshot: () => prefixKey('overview:snapshot'), + overviewVersion: () => prefixKey('overview:version'), + arrivals: (network: 'pfatc' | 'advanced_atc', icao: string) => prefixKey(`arrivals:${network}:${icao.toUpperCase()}`), - sessionsByAirport: (network: "pfatc" | "advanced_atc", icao: string) => + sessionsByAirport: (network: 'pfatc' | 'advanced_atc', icao: string) => prefixKey(`sessions:airport:${network}:${icao.toUpperCase()}`), flightSource: (flightId: string) => prefixKey(`flight:source:${flightId}`), atisDecrypted: (sessionId: string) => prefixKey(`session:atis:${sessionId}`), userBadge: (userId: string) => prefixKey(`users:badge:${userId}`), chatRecent: (sessionId: string) => prefixKey(`chat:recent:${sessionId}`), - chatGlobal: (networkKind: "pfatc" | "aatc") => + chatGlobal: (networkKind: 'pfatc' | 'aatc') => prefixKey(`chat:global:${networkKind}`), activeUsers: (sessionId: string) => `activeUsers:${sessionId}`, - activeUsersIndex: () => prefixKey("activeUsers:index"), -} as const; \ No newline at end of file + activeUsersIndex: () => prefixKey('activeUsers:index'), +} as const; diff --git a/server/realtime/overview.ts b/server/realtime/overview.ts index fd4bd473..e2828bbc 100644 --- a/server/realtime/overview.ts +++ b/server/realtime/overview.ts @@ -1,15 +1,15 @@ -import { redisConnection } from "../db/connection.js"; -import { decrypt } from "../utils/encryption.js"; -import type { ClientFlight } from "../db/flights.js"; -import { keys, TTL } from "./keys.js"; -import { perfAsync } from "./perf.js"; +import { redisConnection } from '../db/connection.js'; +import { decrypt } from '../utils/encryption.js'; +import type { ClientFlight } from '../db/flights.js'; +import { keys, TTL } from './keys.js'; +import { perfAsync } from './perf.js'; import { getActiveNetworkSessionIds, getSessionMetas, type SessionMeta, -} from "./activeSessions.js"; -import { getFlightsForSessions } from "./flightsRead.js"; -import { getUserBadgesByIds } from "./userCache.js"; +} from './activeSessions.js'; +import { getFlightsForSessions } from './flightsRead.js'; +import { getUserBadgesByIds } from './userCache.js'; type SectorController = { id: string; @@ -24,7 +24,7 @@ async function getActiveSectorControllersFromRedis(): Promise< > { try { const controllers = await redisConnection.hgetall( - "activeSectorControllers" + 'activeSectorControllers' ); return Object.values(controllers).map( (data) => JSON.parse(data as string) as SectorController @@ -82,7 +82,7 @@ type SessionUsersReader = { >; }; -const OVERVIEW_CACHE_ENABLED = process.env.OVERVIEW_CACHE !== "0"; +const OVERVIEW_CACHE_ENABLED = process.env.OVERVIEW_CACHE !== '0'; let refreshTimer: ReturnType | null = null; let refreshInFlight = false; @@ -101,7 +101,7 @@ async function getDecryptedAtis( } try { const encrypted = - typeof encryptedAtis === "string" + typeof encryptedAtis === 'string' ? JSON.parse(encryptedAtis) : encryptedAtis; const atisData = decrypt(encrypted); @@ -114,7 +114,7 @@ async function getDecryptedAtis( } return atisData; } catch (err) { - console.error("Error decrypting ATIS:", err); + console.error('Error decrypting ATIS:', err); return null; } } @@ -122,7 +122,7 @@ async function getDecryptedAtis( export async function buildOverviewSnapshot( sessionUsersIO: SessionUsersReader ): Promise { - return perfAsync("buildOverviewSnapshot", async () => { + return perfAsync('buildOverviewSnapshot', async () => { const sectorControllers = await getActiveSectorControllersFromRedis(); const activeSessionIds = await getActiveNetworkSessionIds(); const metas = await getSessionMetas(activeSessionIds); @@ -156,7 +156,7 @@ export async function buildOverviewSnapshot( const flights = flightsBySession.get(sessionId) ?? []; let atisData = null; if (meta.hasAtis) { - const { getSessionById } = await import("../db/sessions.js"); + const { getSessionById } = await import('../db/sessions.js'); const fullSession = await getSessionById(sessionId); if (fullSession?.atis) { atisData = await getDecryptedAtis(sessionId, fullSession.atis); @@ -166,8 +166,8 @@ export async function buildOverviewSnapshot( const controllers: OverviewController[] = users.map((user) => { const badge = badges.get(user.id); return { - username: user.username || "Unknown", - role: user.position || "APP", + username: user.username || 'Unknown', + role: user.position || 'APP', avatar: badge?.avatar ?? user.avatar, hasVatsimRating: badge?.hasVatsimRating ?? false, isEventController: badge?.isEventController ?? false, @@ -208,8 +208,8 @@ export async function buildOverviewSnapshot( activeUsers: 1, controllers: [ { - username: sectorController.username || "Unknown", - role: "CTR", + username: sectorController.username || 'Unknown', + role: 'CTR', avatar, hasVatsimRating: badge?.hasVatsimRating ?? false, isEventController: badge?.isEventController ?? false, @@ -221,7 +221,7 @@ export async function buildOverviewSnapshot( }); } - const arrivalsByAirport: OverviewData["arrivalsByAirport"] = {}; + const arrivalsByAirport: OverviewData['arrivalsByAirport'] = {}; for (const session of activeSessions) { for (const flight of session.flights) { if (!flight.arrival) continue; @@ -298,7 +298,7 @@ async function runCoalescedOverviewRefresh(): Promise { try { await refreshOverviewSnapshot(sessionUsersIORef); } catch (err) { - console.error("[overview] refresh failed:", err); + console.error('[overview] refresh failed:', err); } finally { refreshInFlight = false; } @@ -316,4 +316,4 @@ export async function getOverviewForClient( } } return refreshOverviewSnapshot(sessionUsersIO); -} \ No newline at end of file +} diff --git a/server/realtime/perf.ts b/server/realtime/perf.ts index e7737aee..922caed9 100644 --- a/server/realtime/perf.ts +++ b/server/realtime/perf.ts @@ -1,6 +1,6 @@ const PERF_ENABLED = - process.env.PERF_LOG !== "0" && - (process.env.PERF_LOG === "1" || process.env.NODE_ENV !== "production"); + process.env.PERF_LOG !== '0' && + (process.env.PERF_LOG === '1' || process.env.NODE_ENV !== 'production'); export async function perfAsync( label: string, @@ -13,7 +13,7 @@ export async function perfAsync( return await fn(); } finally { const duration_ms = Math.round(performance.now() - start); - console.log("[perf]", JSON.stringify({ label, duration_ms, ...meta })); + console.log('[perf]', JSON.stringify({ label, duration_ms, ...meta })); } } @@ -28,6 +28,6 @@ export function perfSync( return fn(); } finally { const duration_ms = Math.round(performance.now() - start); - console.log("[perf]", JSON.stringify({ label, duration_ms, ...meta })); + console.log('[perf]', JSON.stringify({ label, duration_ms, ...meta })); } -} \ No newline at end of file +} diff --git a/server/realtime/socketRegistry.ts b/server/realtime/socketRegistry.ts index 5ada1160..01493e6b 100644 --- a/server/realtime/socketRegistry.ts +++ b/server/realtime/socketRegistry.ts @@ -1,5 +1,5 @@ -import type { Server as SocketServer } from "socket.io"; -import { persistWebsocketSnapshots } from "../db/websocketSnapshots.js"; +import type { Server as SocketServer } from 'socket.io'; +import { persistWebsocketSnapshots } from '../db/websocketSnapshots.js'; export type RegisteredSocketNamespace = { id: string; @@ -105,10 +105,10 @@ export async function getAdminSocketStatsWithHistory(): Promise< }> > { const { getWebsocketHourlyHistory } = - await import("../db/websocketSnapshots.js"); + await import('../db/websocketSnapshots.js'); const hourly = await getWebsocketHourlyHistory(); return getAdminSocketStats().map((ns) => ({ ...ns, history24h: hourly.get(ns.id) ?? [], })); -} \ No newline at end of file +} diff --git a/server/realtime/userCache.ts b/server/realtime/userCache.ts index 054c2a7c..01330b63 100644 --- a/server/realtime/userCache.ts +++ b/server/realtime/userCache.ts @@ -1,7 +1,7 @@ -import { mainDb } from "../db/connection.js"; -import { redisConnection } from "../db/connection.js"; -import { getUserRoles } from "../db/roles.js"; -import { keys, TTL } from "./keys.js"; +import { mainDb } from '../db/connection.js'; +import { redisConnection } from '../db/connection.js'; +import { getUserRoles } from '../db/roles.js'; +import { keys, TTL } from './keys.js'; function hasPermission( roles: Awaited>, @@ -9,7 +9,7 @@ function hasPermission( ): boolean { return roles.some((role) => { let perms = role.permissions; - if (typeof perms === "string") { + if (typeof perms === 'string') { try { perms = JSON.parse(perms); } catch { @@ -18,12 +18,12 @@ function hasPermission( } return ( perms && - typeof perms === "object" && + typeof perms === 'object' && (perms as Record)[permKey] === true ); }); } -import { perfAsync } from "./perf.js"; +import { perfAsync } from './perf.js'; export type UserBadgeDto = { username: string | null; @@ -36,17 +36,17 @@ export type UserBadgeDto = { async function loadBadge(userId: string): Promise { const user = await mainDb - .selectFrom("users") - .select(["username", "avatar", "vatsim_rating_id"]) - .where("id", "=", userId) + .selectFrom('users') + .select(['username', 'avatar', 'vatsim_rating_id']) + .where('id', '=', userId) .executeTakeFirst(); let isPFATCSector = false; let isAATCSector = false; try { const roles = await getUserRoles(userId); - isPFATCSector = hasPermission(roles, "pfatc_sector"); - isAATCSector = hasPermission(roles, "aatc_sector"); + isPFATCSector = hasPermission(roles, 'pfatc_sector'); + isAATCSector = hasPermission(roles, 'aatc_sector'); } catch { // ignore } @@ -97,7 +97,7 @@ export async function getUserBadgesByIds( if (unique.length === 0) return result; return perfAsync( - "getUserBadgesByIds", + 'getUserBadgesByIds', async () => { const cacheKeys = unique.map((id) => keys.userBadge(id)); let cachedValues: (string | null)[] = []; @@ -151,4 +151,4 @@ export async function invalidateUserBadge(userId: string): Promise { } catch { // ignore } -} \ No newline at end of file +} diff --git a/server/routes/admin/alts.ts b/server/routes/admin/alts.ts index 2d08f6e9..2a970b33 100644 --- a/server/routes/admin/alts.ts +++ b/server/routes/admin/alts.ts @@ -72,7 +72,9 @@ function discordCreatedAt(userId: string): Date { } function daysBetween(a: Date, b: Date): number { - return Math.floor(Math.abs(b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24)); + return Math.floor( + Math.abs(b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24) + ); } function scoreLabel(score: number): 'low' | 'medium' | 'high' | 'critical' { @@ -84,15 +86,15 @@ function scoreLabel(score: number): 'low' | 'medium' | 'high' | 'critical' { function computeScore(signals: ClusterSignals, memberCount: number): number { let base = 0; - if (signals.shared_fingerprint && signals.shared_ip) base = 0.80; + if (signals.shared_fingerprint && signals.shared_ip) base = 0.8; else if (signals.shared_fingerprint) base = 0.55; - else base = 0.50; // shared IP alone is a strong signal — raised from 0.35 + else base = 0.5; // shared IP alone is a strong signal — raised from 0.35 if (signals.has_banned_member) base += 0.15; - if (signals.young_account_joined_after_ban) base += 0.10; + if (signals.young_account_joined_after_ban) base += 0.1; // More accounts sharing a signal = higher confidence - if (memberCount >= 5) base += 0.10; + if (memberCount >= 5) base += 0.1; else if (memberCount >= 3) base += 0.05; if (signals.vpn_overlap) { @@ -131,11 +133,18 @@ class UnionFind { else this.ipEdges.add(this.find(a)); } - components(): Map { - const map = new Map(); + components(): Map< + string, + { members: string[]; hasFp: boolean; hasIp: boolean } + > { + const map = new Map< + string, + { members: string[]; hasFp: boolean; hasIp: boolean } + >(); for (const id of this.parent.keys()) { const root = this.find(id); - if (!map.has(root)) map.set(root, { members: [], hasFp: false, hasIp: false }); + if (!map.has(root)) + map.set(root, { members: [], hasFp: false, hasIp: false }); map.get(root)!.members.push(id); } // propagate signals to final roots @@ -221,7 +230,11 @@ router.get('/', async (req, res) => { if (allMemberIds.size === 0) { const response: AltClustersResponse = { clusters: [], - stats: { total_clusters: 0, total_flagged_accounts: 0, scan_duration_ms: Date.now() - startTime }, + stats: { + total_clusters: 0, + total_flagged_accounts: 0, + scan_duration_ms: Date.now() - startTime, + }, }; return res.json(response); } @@ -237,12 +250,18 @@ router.get('/', async (req, res) => { .where('user_id', 'in', memberIdArray) .where('active', '=', true) .where((eb) => - eb.or([eb('expires_at', 'is', null), eb('expires_at', '>', new Date())]) + eb.or([ + eb('expires_at', 'is', null), + eb('expires_at', '>', new Date()), + ]) ) .execute(), ]); - const userMap = new Map extends Promise ? T : never>(); + const userMap = new Map< + string, + ReturnType extends Promise ? T : never + >(); for (let i = 0; i < memberIdArray.length; i++) { const u = userResults[i]; if (u) userMap.set(memberIdArray[i], u); @@ -254,8 +273,12 @@ router.get('/', async (req, res) => { banMap.set(ban.user_id, { active: ban.active ?? true, reason: ban.reason ?? null, - expires_at: ban.expires_at ? new Date(ban.expires_at as unknown as string).toISOString() : null, - banned_at: ban.banned_at ? new Date(ban.banned_at as unknown as string).toISOString() : null, + expires_at: ban.expires_at + ? new Date(ban.expires_at as unknown as string).toISOString() + : null, + banned_at: ban.banned_at + ? new Date(ban.banned_at as unknown as string).toISOString() + : null, }); } } @@ -272,7 +295,9 @@ router.get('/', async (req, res) => { if (!u) continue; const discordCreated = discordCreatedAt(id); - const platformJoined = u.created_at ? new Date(u.created_at as unknown as string) : null; + const platformJoined = u.created_at + ? new Date(u.created_at as unknown as string) + : null; const discordAgeDays = platformJoined ? daysBetween(discordCreated, platformJoined) : 0; @@ -285,7 +310,9 @@ router.get('/', async (req, res) => { created_at: platformJoined ? platformJoined.toISOString() : '', discord_created_at: discordCreated.toISOString(), discord_account_age_days: discordAgeDays, - last_login: u.last_login ? new Date(u.last_login as unknown as string).toISOString() : null, + last_login: u.last_login + ? new Date(u.last_login as unknown as string).toISOString() + : null, is_vpn: u.is_vpn ?? false, fingerprint_id: u.fingerprint_id ?? null, ip_hash: u.ip_hash ?? null, @@ -310,7 +337,8 @@ router.get('/', async (req, res) => { if (!m.created_at) return false; // skip members without join date const joinedMs = new Date(m.created_at).getTime(); const discordMs = new Date(m.discord_created_at).getTime(); - const discordAgeDaysAtJoin = (joinedMs - discordMs) / (1000 * 60 * 60 * 24); + const discordAgeDaysAtJoin = + (joinedMs - discordMs) / (1000 * 60 * 60 * 24); return joinedMs > earliestBanDate && discordAgeDaysAtJoin <= 90; }); @@ -331,7 +359,9 @@ router.get('/', async (req, res) => { members.sort((a, b) => { if (a.ban && !b.ban) return -1; if (!a.ban && b.ban) return 1; - return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); + return ( + new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ); }); const clusterId = [...comp.members].sort().join(':'); @@ -348,9 +378,13 @@ router.get('/', async (req, res) => { } // Sort by score desc, then member count desc - clusters.sort((a, b) => b.score - a.score || b.member_count - a.member_count); + clusters.sort( + (a, b) => b.score - a.score || b.member_count - a.member_count + ); - const flaggedIds = new Set(clusters.flatMap((c) => c.members.map((m) => m.id))); + const flaggedIds = new Set( + clusters.flatMap((c) => c.members.map((m) => m.id)) + ); const response: AltClustersResponse = { clusters, diff --git a/server/routes/admin/ban.ts b/server/routes/admin/ban.ts index 265a0afb..afadea83 100644 --- a/server/routes/admin/ban.ts +++ b/server/routes/admin/ban.ts @@ -91,7 +91,16 @@ router.post('/ban', async (req, res) => { }, }); - capture(req,{ distinctId: req.user.userId, event: 'admin_user_banned', properties: { target_user_id: userId, target_ip: ip, reason, expires_at: expiresAt } }); + capture(req, { + distinctId: req.user.userId, + event: 'admin_user_banned', + properties: { + target_user_id: userId, + target_ip: ip, + reason, + expires_at: expiresAt, + }, + }); res.json({ success: true }); }); @@ -135,7 +144,11 @@ router.post('/unban', async (req, res) => { }, }); - capture(req,{ distinctId: req.user.userId, event: 'admin_user_unbanned', properties: { target: userIdOrIp } }); + capture(req, { + distinctId: req.user.userId, + event: 'admin_user_unbanned', + properties: { target: userIdOrIp }, + }); res.json({ success: true }); }); @@ -169,10 +182,16 @@ router.post('/vpn-gate/toggle', async (req, res) => { return res.status(400).json({ error: 'enabled must be a boolean' }); } if (!req.user) { - return res.status(401).json({ error: 'Unauthorized: user not found in request' }); + return res + .status(401) + .json({ error: 'Unauthorized: user not found in request' }); } await updateVpnGateSetting('vpn_gate_enabled', enabled); - capture(req,{ distinctId: req.user.userId, event: 'admin_vpn_gate_toggled', properties: { enabled } }); + capture(req, { + distinctId: req.user.userId, + event: 'admin_vpn_gate_toggled', + properties: { enabled }, + }); await logAdminAction({ adminId: req.user.userId, adminUsername: req.user.username || 'Unknown', @@ -195,7 +214,9 @@ router.post('/vpn-gate/exceptions', async (req, res) => { return res.status(400).json({ error: 'userId is required' }); } if (!req.user) { - return res.status(401).json({ error: 'Unauthorized: user not found in request' }); + return res + .status(401) + .json({ error: 'Unauthorized: user not found in request' }); } const targetUser = await getUserById(userId); if (!targetUser) { @@ -209,7 +230,11 @@ router.post('/vpn-gate/exceptions', async (req, res) => { req.user.username || 'Unknown', notes || '' ); - capture(req,{ distinctId: req.user.userId, event: 'admin_vpn_exception_added', properties: { target_user_id: userId, target_username: resolvedUsername } }); + capture(req, { + distinctId: req.user.userId, + event: 'admin_vpn_exception_added', + properties: { target_user_id: userId, target_username: resolvedUsername }, + }); await logAdminAction({ adminId: req.user.userId, adminUsername: req.user.username || 'Unknown', @@ -221,7 +246,12 @@ router.post('/vpn-gate/exceptions', async (req, res) => { return Array.isArray(ip) ? ip[0] : (ip ?? null); })(), userAgent: req.get('User-Agent'), - details: { userId, username: resolvedUsername, notes, timestamp: new Date().toISOString() }, + details: { + userId, + username: resolvedUsername, + notes, + timestamp: new Date().toISOString(), + }, }); res.json({ success: true, exception }); }); @@ -229,10 +259,16 @@ router.post('/vpn-gate/exceptions', async (req, res) => { router.delete('/vpn-gate/exceptions/:userId', async (req, res) => { const { userId } = req.params; if (!req.user) { - return res.status(401).json({ error: 'Unauthorized: user not found in request' }); + return res + .status(401) + .json({ error: 'Unauthorized: user not found in request' }); } const removed = await removeVpnException(userId); - capture(req,{ distinctId: req.user.userId, event: 'admin_vpn_exception_removed', properties: { target_user_id: userId } }); + capture(req, { + distinctId: req.user.userId, + event: 'admin_vpn_exception_removed', + properties: { target_user_id: userId }, + }); await logAdminAction({ adminId: req.user.userId, adminUsername: req.user.username || 'Unknown', diff --git a/server/routes/admin/database.ts b/server/routes/admin/database.ts index 5e6c2da6..0b43ae36 100644 --- a/server/routes/admin/database.ts +++ b/server/routes/admin/database.ts @@ -1,15 +1,15 @@ -import express from "express"; -import { requirePermission } from "../../middleware/rolePermissions.js"; -import { getDailyStatistics } from "../../db/admin.js"; +import express from 'express'; +import { requirePermission } from '../../middleware/rolePermissions.js'; +import { getDailyStatistics } from '../../db/admin.js'; import { fetchPgTableSizes, getActivitySummary, refreshTodayMetrics, -} from "../../db/databaseMetrics.js"; +} from '../../db/databaseMetrics.js'; import { buildDatabaseProjection, DATABASE_RETENTION_POLICIES, -} from "../../db/databaseProjection.js"; +} from '../../db/databaseProjection.js'; const router = express.Router(); @@ -20,13 +20,13 @@ function formatBytes(bytes: number): string { return `${(bytes / 1024 ** 3).toFixed(2)} GB`; } -router.get("/", requirePermission("admin"), async (_req, res) => { +router.get('/', requirePermission('admin'), async (_req, res) => { try { try { await refreshTodayMetrics(); } catch (metricsError) { console.error( - "[admin/database] refreshTodayMetrics failed:", + '[admin/database] refreshTodayMetrics failed:', metricsError ); } @@ -96,9 +96,9 @@ router.get("/", requirePermission("admin"), async (_req, res) => { polledAt: new Date().toISOString(), }); } catch (error) { - console.error("Error fetching database stats:", error); - res.status(500).json({ error: "Failed to fetch database statistics" }); + console.error('Error fetching database stats:', error); + res.status(500).json({ error: 'Failed to fetch database statistics' }); } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/admin/developers.ts b/server/routes/admin/developers.ts index 61ffff4b..ea2076fb 100644 --- a/server/routes/admin/developers.ts +++ b/server/routes/admin/developers.ts @@ -1,6 +1,6 @@ -import express from "express"; -import { createAuditLogger } from "../../middleware/auditLogger.js"; -import { requirePermission } from "../../middleware/rolePermissions.js"; +import express from 'express'; +import { createAuditLogger } from '../../middleware/auditLogger.js'; +import { requirePermission } from '../../middleware/rolePermissions.js'; import { approvePendingDeveloperApiKey, bumpDeveloperAdminNoticeSeq, @@ -18,29 +18,34 @@ import { setDeveloperProfileStatus, listApprovedDevelopersSummary, deleteDeveloperAllDataForUser, -} from "../../db/developer.js"; -import { buildNewDeveloperKeyCredentials } from "../../developer/apiKeySecret.js"; +} from '../../db/developer.js'; +import { buildNewDeveloperKeyCredentials } from '../../developer/apiKeySecret.js'; import { DEVELOPER_SCOPE_CATALOG, isScopeSubset, isValidScopeList, -} from "../../developer/scopeRegistry.js"; -import { mainDb } from "../../db/connection.js"; -import { getDeveloperApiDefaultRateLimitPerMinute } from "../../middleware/developerExtApi.js"; -import { sendDeveloperAdminNoticeEmail } from "../../developer/sendDeveloperAdminNoticeEmail.js"; +} from '../../developer/scopeRegistry.js'; +import { mainDb } from '../../db/connection.js'; +import { getDeveloperApiDefaultRateLimitPerMinute } from '../../middleware/developerExtApi.js'; +import { sendDeveloperAdminNoticeEmail } from '../../developer/sendDeveloperAdminNoticeEmail.js'; -const SCOPE_LABEL = new Map(DEVELOPER_SCOPE_CATALOG.map((s) => [s.id, s.label])); +const SCOPE_LABEL = new Map( + DEVELOPER_SCOPE_CATALOG.map((s) => [s.id, s.label]) +); -async function notifyDeveloperInAppAndEmail(userId: string, detail: string): Promise { +async function notifyDeveloperInAppAndEmail( + userId: string, + detail: string +): Promise { await bumpDeveloperAdminNoticeSeq(userId, detail); void sendDeveloperAdminNoticeEmail(userId, detail).catch((err) => { - console.error("[developer admin notice email]", err); + console.error('[developer admin notice email]', err); }); } function labelScopes(ids: string[]): string { - if (ids.length === 0) return "(none)"; - return ids.map((id) => SCOPE_LABEL.get(id) ?? id).join(", "); + if (ids.length === 0) return '(none)'; + return ids.map((id) => SCOPE_LABEL.get(id) ?? id).join(', '); } function scopeListChange(prev: string[], next: string[]): string { @@ -48,7 +53,8 @@ function scopeListChange(prev: string[], next: string[]): string { const nextSet = new Set(next); const added = next.filter((id) => !prevSet.has(id)); const removed = prev.filter((id) => !nextSet.has(id)); - if (!added.length && !removed.length) return "The allowed scope list was unchanged."; + if (!added.length && !removed.length) + return 'The allowed scope list was unchanged.'; if (removed.length && added.length) { return `Removed: ${labelScopes(removed)}. Added: ${labelScopes(added)}.`; } @@ -57,11 +63,11 @@ function scopeListChange(prev: string[], next: string[]): string { } function formatRpm(rpm: number | null | undefined): string { - if (rpm == null) return "default server limit"; + if (rpm == null) return 'default server limit'; return `${rpm} requests/minute`; } -const DEVELOPER_ADMIN_NOTICE_SUCCESS_PREFIX = "[[success]]"; +const DEVELOPER_ADMIN_NOTICE_SUCCESS_PREFIX = '[[success]]'; function applicationApprovedNotice(input: { requested: string[]; @@ -71,11 +77,13 @@ function applicationApprovedNotice(input: { reviewerNote: string | null; }): string { const scopesSame = - JSON.stringify([...input.requested].sort()) === JSON.stringify([...input.approved].sort()); + JSON.stringify([...input.requested].sort()) === + JSON.stringify([...input.approved].sort()); const delta = scopeListChange(input.requested, input.approved); - const scopeChanged = !scopesSame && delta !== "The allowed scope list was unchanged."; + const scopeChanged = + !scopesSame && delta !== 'The allowed scope list was unchanged.'; - const bits: string[] = ["Your developer application was approved."]; + const bits: string[] = ['Your developer application was approved.']; if (scopeChanged) { bits.push(`Compared to your request: ${delta}`); } @@ -83,13 +91,13 @@ function applicationApprovedNotice(input: { bits.push( input.rateLimitValue != null ? `Default rate limit for new API keys: ${formatRpm(input.rateLimitValue)}.` - : `Default rate limit for new API keys now follows the site-wide limit (${formatRpm(getDeveloperApiDefaultRateLimitPerMinute())}).`, + : `Default rate limit for new API keys now follows the site-wide limit (${formatRpm(getDeveloperApiDefaultRateLimitPerMinute())}).` ); } if (input.reviewerNote?.trim()) { bits.push(`Note from reviewer: ${input.reviewerNote.trim()}`); } - return `${DEVELOPER_ADMIN_NOTICE_SUCCESS_PREFIX}\n\n${bits.join("\n\n")}`; + return `${DEVELOPER_ADMIN_NOTICE_SUCCESS_PREFIX}\n\n${bits.join('\n\n')}`; } function noticeKeyScopesAndRate( @@ -97,43 +105,55 @@ function noticeKeyScopesAndRate( prevScopes: string[], nextScopes: string[], prevRpm: number | null, - nextRpm: number | null, + nextRpm: number | null ): string { const sameScopes = - JSON.stringify([...prevScopes].sort()) === JSON.stringify([...nextScopes].sort()); + JSON.stringify([...prevScopes].sort()) === + JSON.stringify([...nextScopes].sort()); const sameRpm = prevRpm === nextRpm; - const bits: string[] = [`An administrator updated your API key "${keyName}".`]; + const bits: string[] = [ + `An administrator updated your API key "${keyName}".`, + ]; if (!sameScopes) bits.push(scopeListChange(prevScopes, nextScopes)); if (!sameRpm) { - bits.push(`Rate limit changed from ${formatRpm(prevRpm)} to ${formatRpm(nextRpm)}.`); + bits.push( + `Rate limit changed from ${formatRpm(prevRpm)} to ${formatRpm(nextRpm)}.` + ); } - return bits.join(" "); + return bits.join(' '); } const router = express.Router(); -router.use(requirePermission("admin")); +router.use(requirePermission('admin')); -router.get("/catalog", createAuditLogger("ADMIN_DEVELOPER_SCOPE_CATALOG"), (_req, res) => { - res.json({ scopes: DEVELOPER_SCOPE_CATALOG }); -}); +router.get( + '/catalog', + createAuditLogger('ADMIN_DEVELOPER_SCOPE_CATALOG'), + (_req, res) => { + res.json({ scopes: DEVELOPER_SCOPE_CATALOG }); + } +); function normalizeScopes(raw: unknown): string[] { if (!Array.isArray(raw)) return []; - return raw.filter((x): x is string => typeof x === "string"); + return raw.filter((x): x is string => typeof x === 'string'); } router.get( - "/applications", - createAuditLogger("ADMIN_DEVELOPER_APPLICATIONS_LIST"), + '/applications', + createAuditLogger('ADMIN_DEVELOPER_APPLICATIONS_LIST'), async (req, res) => { try { const page = - typeof req.query.page === "string" ? Math.max(1, parseInt(req.query.page, 10) || 1) : 1; + typeof req.query.page === 'string' + ? Math.max(1, parseInt(req.query.page, 10) || 1) + : 1; const limit = - typeof req.query.limit === "string" + typeof req.query.limit === 'string' ? Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20)) : 20; - const statusRaw = typeof req.query.status === "string" ? req.query.status.trim() : ""; + const statusRaw = + typeof req.query.status === 'string' ? req.query.status.trim() : ''; const status = statusRaw.length > 0 ? statusRaw : undefined; const { applications, total } = await listDeveloperApplications({ status, @@ -144,9 +164,9 @@ router.get( const users = userIds.length > 0 ? await mainDb - .selectFrom("users") - .select(["id", "username"]) - .where("id", "in", userIds) + .selectFrom('users') + .select(['id', 'username']) + .where('id', 'in', userIds) .execute() : []; const userMap = new Map(users.map((u) => [u.id, u.username])); @@ -162,7 +182,9 @@ router.get( reviewedBy: a.reviewed_by, reviewedAt: a.reviewed_at, reviewerNote: a.reviewer_note, - approvedScopes: a.approved_scopes ? normalizeScopes(a.approved_scopes) : null, + approvedScopes: a.approved_scopes + ? normalizeScopes(a.approved_scopes) + : null, createdAt: a.created_at, })), total, @@ -170,34 +192,40 @@ router.get( limit, }); } catch (e) { - console.error("[admin/developers applications]", e); - res.status(500).json({ error: "Failed to list applications" }); + console.error('[admin/developers applications]', e); + res.status(500).json({ error: 'Failed to list applications' }); } - }, + } ); router.post( - "/applications/:id/approve", - createAuditLogger("ADMIN_DEVELOPER_APPLICATION_APPROVED"), + '/applications/:id/approve', + createAuditLogger('ADMIN_DEVELOPER_APPLICATION_APPROVED'), async (req, res) => { try { const id = parseInt(req.params.id, 10); - if (!Number.isFinite(id)) return res.status(400).json({ error: "Invalid id" }); + if (!Number.isFinite(id)) + return res.status(400).json({ error: 'Invalid id' }); const app = await getDeveloperApplicationById(id); - if (!app) return res.status(404).json({ error: "Application not found" }); - if (app.status !== "pending") { - return res.status(400).json({ error: "Application is not pending" }); + if (!app) return res.status(404).json({ error: 'Application not found' }); + if (app.status !== 'pending') { + return res.status(400).json({ error: 'Application is not pending' }); } const requested = normalizeScopes(app.requested_scopes); - const { approvedScopes: bodyScopes, rateLimitPerMinute: bodyRpm, note } = req.body ?? {}; + const { + approvedScopes: bodyScopes, + rateLimitPerMinute: bodyRpm, + note, + } = req.body ?? {}; let approved: string[]; if (bodyScopes === undefined || bodyScopes === null) { approved = requested; } else { if (!isValidScopeList(bodyScopes)) { - return res - .status(400) - .json({ error: "approvedScopes must be a non-empty array of valid scope ids" }); + return res.status(400).json({ + error: + 'approvedScopes must be a non-empty array of valid scope ids', + }); } approved = bodyScopes; } @@ -205,25 +233,27 @@ router.post( let rpmInput: number | null | undefined = undefined; if ( req.body != null && - Object.prototype.hasOwnProperty.call(req.body, "rateLimitPerMinute") + Object.prototype.hasOwnProperty.call(req.body, 'rateLimitPerMinute') ) { const v = bodyRpm; - if (v === null || v === "") { + if (v === null || v === '') { rpmInput = null; } else { - const n = typeof v === "number" ? v : Number(v); + const n = typeof v === 'number' ? v : Number(v); if (!Number.isFinite(n) || n < 0) { - return res.status(400).json({ error: "rateLimitPerMinute invalid" }); + return res + .status(400) + .json({ error: 'rateLimitPerMinute invalid' }); } rpmInput = n === 0 ? null : Math.floor(n); } } - const reviewedBy = req.user?.userId ?? "unknown"; - const reviewerNote = typeof note === "string" ? note : null; + const reviewedBy = req.user?.userId ?? 'unknown'; + const reviewerNote = typeof note === 'string' ? note : null; await updateApplicationReview({ applicationId: id, - status: "approved", + status: 'approved', reviewedBy, reviewerNote, approvedScopes: approved, @@ -231,8 +261,10 @@ router.post( await upsertDeveloperProfile({ userId: app.user_id, approvedScopes: approved, - status: "active", - ...(rpmInput !== undefined ? { defaultRateLimitPerMinute: rpmInput } : {}), + status: 'active', + ...(rpmInput !== undefined + ? { defaultRateLimitPerMinute: rpmInput } + : {}), }); const notice = applicationApprovedNotice({ requested, @@ -244,186 +276,210 @@ router.post( await notifyDeveloperInAppAndEmail(app.user_id, notice); res.json({ ok: true, userId: app.user_id, approvedScopes: approved }); } catch (e) { - console.error("[admin/developers approve]", e); - res.status(500).json({ error: "Failed to approve application" }); + console.error('[admin/developers approve]', e); + res.status(500).json({ error: 'Failed to approve application' }); } - }, + } ); router.post( - "/applications/:id/reject", - createAuditLogger("ADMIN_DEVELOPER_APPLICATION_REJECTED"), + '/applications/:id/reject', + createAuditLogger('ADMIN_DEVELOPER_APPLICATION_REJECTED'), async (req, res) => { try { const id = parseInt(req.params.id, 10); - if (!Number.isFinite(id)) return res.status(400).json({ error: "Invalid id" }); + if (!Number.isFinite(id)) + return res.status(400).json({ error: 'Invalid id' }); const app = await getDeveloperApplicationById(id); - if (!app) return res.status(404).json({ error: "Application not found" }); - if (app.status !== "pending") { - return res.status(400).json({ error: "Application is not pending" }); + if (!app) return res.status(404).json({ error: 'Application not found' }); + if (app.status !== 'pending') { + return res.status(400).json({ error: 'Application is not pending' }); } - const reviewedBy = req.user?.userId ?? "unknown"; + const reviewedBy = req.user?.userId ?? 'unknown'; await updateApplicationReview({ applicationId: id, - status: "rejected", + status: 'rejected', reviewedBy, - reviewerNote: typeof req.body?.note === "string" ? req.body.note : null, + reviewerNote: typeof req.body?.note === 'string' ? req.body.note : null, approvedScopes: null, }); res.json({ ok: true }); } catch (e) { - console.error("[admin/developers reject]", e); - res.status(500).json({ error: "Failed to reject application" }); + console.error('[admin/developers reject]', e); + res.status(500).json({ error: 'Failed to reject application' }); } - }, + } ); -router.get("/developers", createAuditLogger("ADMIN_DEVELOPERS_LIST"), async (_req, res) => { - try { - const rows = await listApprovedDevelopersSummary(); - const userIds = rows.map((r) => r.userId); - const users = - userIds.length > 0 - ? await mainDb - .selectFrom("users") - .select(["id", "username", "avatar"]) - .where("id", "in", userIds) - .execute() - : []; - const userMap = new Map( - users.map((u) => [u.id, { username: u.username, avatar: u.avatar ?? null }]), - ); - res.json({ - developers: rows.map((r) => { - const u = userMap.get(r.userId); - return { - ...r, - username: u?.username ?? r.userId, - avatar: u?.avatar ?? null, - }; - }), - }); - } catch (e) { - console.error("[admin/developers list]", e); - res.status(500).json({ error: "Failed to list developers" }); +router.get( + '/developers', + createAuditLogger('ADMIN_DEVELOPERS_LIST'), + async (_req, res) => { + try { + const rows = await listApprovedDevelopersSummary(); + const userIds = rows.map((r) => r.userId); + const users = + userIds.length > 0 + ? await mainDb + .selectFrom('users') + .select(['id', 'username', 'avatar']) + .where('id', 'in', userIds) + .execute() + : []; + const userMap = new Map( + users.map((u) => [ + u.id, + { username: u.username, avatar: u.avatar ?? null }, + ]) + ); + res.json({ + developers: rows.map((r) => { + const u = userMap.get(r.userId); + return { + ...r, + username: u?.username ?? r.userId, + avatar: u?.avatar ?? null, + }; + }), + }); + } catch (e) { + console.error('[admin/developers list]', e); + res.status(500).json({ error: 'Failed to list developers' }); + } } -}); +); router.delete( - "/profiles/:userId", - createAuditLogger("ADMIN_DEVELOPER_DELETED"), + '/profiles/:userId', + createAuditLogger('ADMIN_DEVELOPER_DELETED'), async (req, res) => { try { const { userId } = req.params; const ok = await deleteDeveloperAllDataForUser(userId); - if (!ok) return res.status(404).json({ error: "Developer profile not found" }); + if (!ok) + return res.status(404).json({ error: 'Developer profile not found' }); res.json({ ok: true }); } catch (e) { - console.error("[admin/developers delete]", e); - res.status(500).json({ error: "Failed to delete developer" }); + console.error('[admin/developers delete]', e); + res.status(500).json({ error: 'Failed to delete developer' }); } - }, + } ); router.patch( - "/profiles/:userId/scopes", - createAuditLogger("ADMIN_DEVELOPER_PROFILE_SCOPES_UPDATED"), + '/profiles/:userId/scopes', + createAuditLogger('ADMIN_DEVELOPER_PROFILE_SCOPES_UPDATED'), async (req, res) => { try { const { userId } = req.params; const { approvedScopes } = req.body ?? {}; if (!isValidScopeList(approvedScopes)) { - return res - .status(400) - .json({ error: "approvedScopes must be a non-empty array of valid scope ids" }); + return res.status(400).json({ + error: 'approvedScopes must be a non-empty array of valid scope ids', + }); } const prior = await getDeveloperProfile(userId); const prevScopes = prior ? normalizeScopes(prior.approved_scopes) : []; - const row = await updateDeveloperProfileApprovedScopes(userId, approvedScopes); - if (!row) return res.status(404).json({ error: "Developer profile not found" }); + const row = await updateDeveloperProfileApprovedScopes( + userId, + approvedScopes + ); + if (!row) + return res.status(404).json({ error: 'Developer profile not found' }); await notifyDeveloperInAppAndEmail( userId, - `An administrator updated your allowed API scopes. ${scopeListChange(prevScopes, approvedScopes)}`, + `An administrator updated your allowed API scopes. ${scopeListChange(prevScopes, approvedScopes)}` ); res.json({ ok: true, approvedScopes }); } catch (e) { - console.error("[admin/developers profile scopes]", e); - res.status(500).json({ error: "Failed to update profile scopes" }); + console.error('[admin/developers profile scopes]', e); + res.status(500).json({ error: 'Failed to update profile scopes' }); } - }, + } ); -router.get("/:userId/keys", createAuditLogger("ADMIN_DEVELOPER_KEYS_LIST"), async (req, res) => { - try { - const { userId } = req.params; - const profile = await getDeveloperProfile(userId); - if (!profile) return res.status(404).json({ error: "Developer profile not found" }); - const keys = await listDeveloperApiKeysForAdmin(userId); - res.json({ - keys: keys.map((k) => ({ - id: String(k.id), - name: k.name, - prefix: k.prefix, - status: k.status ?? "active", - scopes: normalizeScopes(k.scopes), - requestedScopes: k.requested_scopes ? normalizeScopes(k.requested_scopes) : [], - rateLimitPerMinute: k.rate_limit_per_minute, - reviewedBy: k.reviewed_by, - reviewedAt: k.reviewed_at, - reviewerNote: k.reviewer_note, - createdAt: k.created_at, - lastUsedAt: k.last_used_at, - revokedAt: k.revoked_at, - })), - }); - } catch (e) { - console.error("[admin/developers keys list]", e); - res.status(500).json({ error: "Failed to list keys" }); +router.get( + '/:userId/keys', + createAuditLogger('ADMIN_DEVELOPER_KEYS_LIST'), + async (req, res) => { + try { + const { userId } = req.params; + const profile = await getDeveloperProfile(userId); + if (!profile) + return res.status(404).json({ error: 'Developer profile not found' }); + const keys = await listDeveloperApiKeysForAdmin(userId); + res.json({ + keys: keys.map((k) => ({ + id: String(k.id), + name: k.name, + prefix: k.prefix, + status: k.status ?? 'active', + scopes: normalizeScopes(k.scopes), + requestedScopes: k.requested_scopes + ? normalizeScopes(k.requested_scopes) + : [], + rateLimitPerMinute: k.rate_limit_per_minute, + reviewedBy: k.reviewed_by, + reviewedAt: k.reviewed_at, + reviewerNote: k.reviewer_note, + createdAt: k.created_at, + lastUsedAt: k.last_used_at, + revokedAt: k.revoked_at, + })), + }); + } catch (e) { + console.error('[admin/developers keys list]', e); + res.status(500).json({ error: 'Failed to list keys' }); + } } -}); +); router.post( - "/:userId/keys/:keyId/approve", - createAuditLogger("ADMIN_DEVELOPER_KEY_APPROVED"), + '/:userId/keys/:keyId/approve', + createAuditLogger('ADMIN_DEVELOPER_KEY_APPROVED'), async (req, res) => { try { const { userId, keyId } = req.params; const profile = await getDeveloperProfile(userId); - if (!profile || profile.status !== "active") { - return res.status(404).json({ error: "Developer profile not found or not active" }); + if (!profile || profile.status !== 'active') { + return res + .status(404) + .json({ error: 'Developer profile not found or not active' }); } const key = await getDeveloperApiKeyForUser(keyId, userId); - if (!key || key.status !== "pending") { - return res.status(400).json({ error: "Key not found or not pending approval" }); + if (!key || key.status !== 'pending') { + return res + .status(400) + .json({ error: 'Key not found or not pending approval' }); } const requested = normalizeScopes(key.requested_scopes); const ceiling = normalizeScopes(profile.approved_scopes); const { approvedScopes, rateLimitPerMinute } = req.body ?? {}; if (!isValidScopeList(approvedScopes)) { - return res.status(400).json({ error: "approvedScopes invalid" }); + return res.status(400).json({ error: 'approvedScopes invalid' }); } if (!isScopeSubset(approvedScopes, requested)) { - return res - .status(400) - .json({ error: "approvedScopes must be a subset of requested scopes" }); + return res.status(400).json({ + error: 'approvedScopes must be a subset of requested scopes', + }); } if (!isScopeSubset(approvedScopes, ceiling)) { - return res - .status(400) - .json({ error: "approvedScopes must be within the developer profile ceiling" }); + return res.status(400).json({ + error: 'approvedScopes must be within the developer profile ceiling', + }); } const rpm = rateLimitPerMinute === undefined || rateLimitPerMinute === null ? null - : typeof rateLimitPerMinute === "number" + : typeof rateLimitPerMinute === 'number' ? rateLimitPerMinute : Number(rateLimitPerMinute); if (rpm != null && (!Number.isFinite(rpm) || rpm < 0)) { - return res.status(400).json({ error: "rateLimitPerMinute invalid" }); + return res.status(400).json({ error: 'rateLimitPerMinute invalid' }); } const { secret, prefix, secretHash } = buildNewDeveloperKeyCredentials(); - const reviewedBy = req.user?.userId ?? "unknown"; + const reviewedBy = req.user?.userId ?? 'unknown'; const row = await approvePendingDeveloperApiKey({ keyId, userId, @@ -432,10 +488,10 @@ router.post( secretHash, rateLimitPerMinute: rpm, reviewedBy, - reviewerNote: typeof req.body?.note === "string" ? req.body.note : null, + reviewerNote: typeof req.body?.note === 'string' ? req.body.note : null, }); if (!row) { - return res.status(500).json({ error: "Failed to approve key" }); + return res.status(500).json({ error: 'Failed to approve key' }); } let approveDetail = `An administrator approved your API key "${key.name}" with scopes: ${labelScopes(approvedScopes)}.`; if (rpm != null) approveDetail += ` Rate limit: ${formatRpm(rpm)}.`; @@ -448,70 +504,75 @@ router.post( secret, }); } catch (e) { - console.error("[admin/developers key approve]", e); - res.status(500).json({ error: "Failed to approve key" }); + console.error('[admin/developers key approve]', e); + res.status(500).json({ error: 'Failed to approve key' }); } - }, + } ); router.post( - "/:userId/keys/:keyId/reject", - createAuditLogger("ADMIN_DEVELOPER_KEY_REJECTED"), + '/:userId/keys/:keyId/reject', + createAuditLogger('ADMIN_DEVELOPER_KEY_REJECTED'), async (req, res) => { try { const { userId, keyId } = req.params; - const reviewedBy = req.user?.userId ?? "unknown"; + const reviewedBy = req.user?.userId ?? 'unknown'; const row = await rejectPendingDeveloperApiKey({ keyId, userId, reviewedBy, - reviewerNote: typeof req.body?.note === "string" ? req.body.note : null, + reviewerNote: typeof req.body?.note === 'string' ? req.body.note : null, }); - if (!row) return res.status(400).json({ error: "Key not found or not pending" }); + if (!row) + return res.status(400).json({ error: 'Key not found or not pending' }); await notifyDeveloperInAppAndEmail( userId, - `An administrator rejected your pending API key "${row.name}".`, + `An administrator rejected your pending API key "${row.name}".` ); res.json({ ok: true }); } catch (e) { - console.error("[admin/developers key reject]", e); - res.status(500).json({ error: "Failed to reject key" }); + console.error('[admin/developers key reject]', e); + res.status(500).json({ error: 'Failed to reject key' }); } - }, + } ); router.patch( - "/:userId/keys/:keyId", - createAuditLogger("ADMIN_DEVELOPER_KEY_UPDATED"), + '/:userId/keys/:keyId', + createAuditLogger('ADMIN_DEVELOPER_KEY_UPDATED'), async (req, res) => { try { const { userId, keyId } = req.params; const profile = await getDeveloperProfile(userId); - if (!profile || profile.status !== "active") { - return res.status(404).json({ error: "Developer profile not found or not active" }); + if (!profile || profile.status !== 'active') { + return res + .status(404) + .json({ error: 'Developer profile not found or not active' }); } const key = await getDeveloperApiKeyForUser(keyId, userId); - if (!key || key.status !== "active" || key.revoked_at) { - return res.status(400).json({ error: "Key not found or not editable" }); + if (!key || key.status !== 'active' || key.revoked_at) { + return res.status(400).json({ error: 'Key not found or not editable' }); } const { scopes, rateLimitPerMinute } = req.body ?? {}; if (!isValidScopeList(scopes)) { - return res.status(400).json({ error: "scopes invalid" }); + return res.status(400).json({ error: 'scopes invalid' }); } const ceiling = normalizeScopes(profile.approved_scopes); if (!isScopeSubset(scopes, ceiling)) { - return res.status(400).json({ error: "scopes must be within profile ceiling" }); + return res + .status(400) + .json({ error: 'scopes must be within profile ceiling' }); } const rpm = rateLimitPerMinute === undefined ? (key.rate_limit_per_minute as number | null) : rateLimitPerMinute === null ? null - : typeof rateLimitPerMinute === "number" + : typeof rateLimitPerMinute === 'number' ? rateLimitPerMinute : Number(rateLimitPerMinute); if (rpm != null && (!Number.isFinite(rpm) || rpm < 0)) { - return res.status(400).json({ error: "rateLimitPerMinute invalid" }); + return res.status(400).json({ error: 'rateLimitPerMinute invalid' }); } const prevScopes = normalizeScopes(key.scopes); const prevRpm = (key.rate_limit_per_minute ?? null) as number | null; @@ -521,12 +582,18 @@ router.patch( scopes, rateLimitPerMinute: rpm, }); - if (!row) return res.status(500).json({ error: "Failed to update key" }); + if (!row) return res.status(500).json({ error: 'Failed to update key' }); const nextScopes = normalizeScopes(row.scopes); const nextRpm = (row.rate_limit_per_minute ?? null) as number | null; await notifyDeveloperInAppAndEmail( userId, - noticeKeyScopesAndRate(key.name, prevScopes, nextScopes, prevRpm, nextRpm), + noticeKeyScopesAndRate( + key.name, + prevScopes, + nextScopes, + prevRpm, + nextRpm + ) ); res.json({ ok: true, @@ -535,70 +602,75 @@ router.patch( rateLimitPerMinute: row.rate_limit_per_minute, }); } catch (e) { - console.error("[admin/developers key patch]", e); - res.status(500).json({ error: "Failed to update key" }); + console.error('[admin/developers key patch]', e); + res.status(500).json({ error: 'Failed to update key' }); } - }, + } ); router.post( - "/:userId/keys/:keyId/revoke", - createAuditLogger("ADMIN_DEVELOPER_KEY_REVOKED"), + '/:userId/keys/:keyId/revoke', + createAuditLogger('ADMIN_DEVELOPER_KEY_REVOKED'), async (req, res) => { try { const { userId, keyId } = req.params; const row = await revokeDeveloperApiKey(keyId, userId); - if (!row) return res.status(404).json({ error: "Key not found or already revoked" }); + if (!row) + return res + .status(404) + .json({ error: 'Key not found or already revoked' }); await notifyDeveloperInAppAndEmail( userId, - `An administrator revoked your API key "${row.name}".`, + `An administrator revoked your API key "${row.name}".` ); res.json({ ok: true }); } catch (e) { - console.error("[admin/developers key revoke]", e); - res.status(500).json({ error: "Failed to revoke key" }); + console.error('[admin/developers key revoke]', e); + res.status(500).json({ error: 'Failed to revoke key' }); } - }, + } ); router.post( - "/profiles/:userId/suspend", - createAuditLogger("ADMIN_DEVELOPER_PROFILE_SUSPENDED"), + '/profiles/:userId/suspend', + createAuditLogger('ADMIN_DEVELOPER_PROFILE_SUSPENDED'), async (req, res) => { try { const { userId } = req.params; - const row = await setDeveloperProfileStatus(userId, "suspended"); - if (!row) return res.status(404).json({ error: "Developer profile not found" }); + const row = await setDeveloperProfileStatus(userId, 'suspended'); + if (!row) + return res.status(404).json({ error: 'Developer profile not found' }); await notifyDeveloperInAppAndEmail( userId, - "An administrator suspended your developer account. Your API keys no longer work until access is restored.", + 'An administrator suspended your developer account. Your API keys no longer work until access is restored.' ); res.json({ ok: true }); } catch (e) { - console.error("[admin/developers suspend]", e); - res.status(500).json({ error: "Failed to suspend profile" }); + console.error('[admin/developers suspend]', e); + res.status(500).json({ error: 'Failed to suspend profile' }); } - }, + } ); router.post( - "/profiles/:userId/reactivate", - createAuditLogger("ADMIN_DEVELOPER_PROFILE_REACTIVATED"), + '/profiles/:userId/reactivate', + createAuditLogger('ADMIN_DEVELOPER_PROFILE_REACTIVATED'), async (req, res) => { try { const { userId } = req.params; - const row = await setDeveloperProfileStatus(userId, "active"); - if (!row) return res.status(404).json({ error: "Developer profile not found" }); + const row = await setDeveloperProfileStatus(userId, 'active'); + if (!row) + return res.status(404).json({ error: 'Developer profile not found' }); await notifyDeveloperInAppAndEmail( userId, - "An administrator reactivated your developer account. Your keys work again according to their current status.", + 'An administrator reactivated your developer account. Your keys work again according to their current status.' ); res.json({ ok: true }); } catch (e) { - console.error("[admin/developers reactivate]", e); - res.status(500).json({ error: "Failed to reactivate profile" }); + console.error('[admin/developers reactivate]', e); + res.status(500).json({ error: 'Failed to reactivate profile' }); } - }, + } ); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/admin/index.ts b/server/routes/admin/index.ts index f08c9759..6dfbf1ef 100644 --- a/server/routes/admin/index.ts +++ b/server/routes/admin/index.ts @@ -1,57 +1,57 @@ -import express from "express"; -import requireAuth from "../../middleware/auth.js"; -import { requirePermission } from "../../middleware/rolePermissions.js"; -import { getDailyStatistics, getTotalStatistics } from "../../db/admin.js"; -import { getAppVersion } from "../../db/version.js"; +import express from 'express'; +import requireAuth from '../../middleware/auth.js'; +import { requirePermission } from '../../middleware/rolePermissions.js'; +import { getDailyStatistics, getTotalStatistics } from '../../db/admin.js'; +import { getAppVersion } from '../../db/version.js'; -import usersRouter from "./users.js"; -import sessionsRouter from "./sessions.js"; -import auditLogsRouter from "./audit-logs.js"; -import bansRouter from "./ban.js"; -import testersRouter from "./testers.js"; -import notificationRouter from "./notifications.js"; -import rolesRouter from "./roles.js"; -import chatReportsRouter from "./chat-reports.js"; -import updateModalsRouter from "./updateModals.js"; -import flightLogsRouter from "./flight-logs.js"; -import feedbackRouter from "./feedback.js"; -import apiLogsRouter from "./api-logs.js"; -import ratingsRouter from "./ratings.js"; -import altsRouter from "./alts.js"; -import developersRouter from "./developers.js"; -import websocketsRouter from "./websockets.js"; -import databaseRouter from "./database.js"; +import usersRouter from './users.js'; +import sessionsRouter from './sessions.js'; +import auditLogsRouter from './audit-logs.js'; +import bansRouter from './ban.js'; +import testersRouter from './testers.js'; +import notificationRouter from './notifications.js'; +import rolesRouter from './roles.js'; +import chatReportsRouter from './chat-reports.js'; +import updateModalsRouter from './updateModals.js'; +import flightLogsRouter from './flight-logs.js'; +import feedbackRouter from './feedback.js'; +import apiLogsRouter from './api-logs.js'; +import ratingsRouter from './ratings.js'; +import altsRouter from './alts.js'; +import developersRouter from './developers.js'; +import websocketsRouter from './websockets.js'; +import databaseRouter from './database.js'; const router = express.Router(); router.use(requireAuth); -router.use("/users", usersRouter); -router.use("/sessions", sessionsRouter); -router.use("/audit-logs", auditLogsRouter); -router.use("/bans", bansRouter); -router.use("/testers", testersRouter); -router.use("/notifications", notificationRouter); -router.use("/roles", rolesRouter); -router.use("/chat-reports", chatReportsRouter); -router.use("/update-modals", updateModalsRouter); -router.use("/flight-logs", flightLogsRouter); -router.use("/feedback", feedbackRouter); -router.use("/api-logs", apiLogsRouter); -router.use("/ratings", ratingsRouter); -router.use("/alts", altsRouter); -router.use("/developers", developersRouter); -router.use("/websockets", websocketsRouter); -router.use("/database", databaseRouter); +router.use('/users', usersRouter); +router.use('/sessions', sessionsRouter); +router.use('/audit-logs', auditLogsRouter); +router.use('/bans', bansRouter); +router.use('/testers', testersRouter); +router.use('/notifications', notificationRouter); +router.use('/roles', rolesRouter); +router.use('/chat-reports', chatReportsRouter); +router.use('/update-modals', updateModalsRouter); +router.use('/flight-logs', flightLogsRouter); +router.use('/feedback', feedbackRouter); +router.use('/api-logs', apiLogsRouter); +router.use('/ratings', ratingsRouter); +router.use('/alts', altsRouter); +router.use('/developers', developersRouter); +router.use('/websockets', websocketsRouter); +router.use('/database', databaseRouter); // GET: /api/admin/statistics - Get dashboard statistics -router.get("/statistics", requirePermission("admin"), async (req, res) => { +router.get('/statistics', requirePermission('admin'), async (req, res) => { try { const daysParam = req.query.days; const days = - typeof daysParam === "string" + typeof daysParam === 'string' ? parseInt(daysParam) - : Array.isArray(daysParam) && typeof daysParam[0] === "string" + : Array.isArray(daysParam) && typeof daysParam[0] === 'string' ? parseInt(daysParam[0]) : 30; const dailyStats = await getDailyStatistics(days); @@ -62,20 +62,20 @@ router.get("/statistics", requirePermission("admin"), async (req, res) => { totals: totalStats, }); } catch (error) { - console.error("Error fetching admin statistics:", error); - res.status(500).json({ error: "Failed to fetch statistics" }); + console.error('Error fetching admin statistics:', error); + res.status(500).json({ error: 'Failed to fetch statistics' }); } }); // GET: /api/admin/version - Get app version (admin only) -router.get("/version", requirePermission("admin"), async (req, res) => { +router.get('/version', requirePermission('admin'), async (req, res) => { try { const version = await getAppVersion(); res.json(version); } catch (error) { - console.error("Error fetching app version:", error); - res.status(500).json({ error: "Failed to fetch app version" }); + console.error('Error fetching app version:', error); + res.status(500).json({ error: 'Failed to fetch app version' }); } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/admin/ratings.ts b/server/routes/admin/ratings.ts index 5bd1384e..06d1145e 100644 --- a/server/routes/admin/ratings.ts +++ b/server/routes/admin/ratings.ts @@ -28,7 +28,7 @@ router.get('/daily', requirePermission('admin'), async (req, res) => { : Array.isArray(daysParam) && typeof daysParam[0] === 'string' ? parseInt(daysParam[0]) : 30; - + const dailyStats = await getControllerRatingsDailyStats(days); res.json(dailyStats); } catch (error) { diff --git a/server/routes/admin/roles.ts b/server/routes/admin/roles.ts index 4239cd51..fdf30a89 100644 --- a/server/routes/admin/roles.ts +++ b/server/routes/admin/roles.ts @@ -213,7 +213,12 @@ router.post('/assign', requirePermission('roles'), async (req, res) => { const result = await assignRoleToUser(userId, roleId); - if (req.user?.userId) capture(req,{ distinctId: req.user.userId, event: 'admin_role_assigned', properties: { target_user_id: userId, role_id: roleId } }); + if (req.user?.userId) + capture(req, { + distinctId: req.user.userId, + event: 'admin_role_assigned', + properties: { target_user_id: userId, role_id: roleId }, + }); if (req.user?.userId) { try { @@ -268,7 +273,12 @@ router.post( } const result = await removeRoleFromUser(userId, roleId); - if (req.user?.userId) capture(req,{ distinctId: req.user.userId, event: 'admin_role_removed', properties: { target_user_id: userId, role_id: roleId } }); + if (req.user?.userId) + capture(req, { + distinctId: req.user.userId, + event: 'admin_role_removed', + properties: { target_user_id: userId, role_id: roleId }, + }); res.json(result); } catch (error) { console.error('Error removing role:', error); diff --git a/server/routes/admin/sessions.ts b/server/routes/admin/sessions.ts index e93c9807..07ebdc33 100644 --- a/server/routes/admin/sessions.ts +++ b/server/routes/admin/sessions.ts @@ -99,8 +99,10 @@ router.post( }; const updates: Record = {}; - if (typeof pfatcEventMode === 'boolean') updates.pfatc_event_mode = pfatcEventMode; - if (typeof aatcEventMode === 'boolean') updates.aatc_event_mode = aatcEventMode; + if (typeof pfatcEventMode === 'boolean') + updates.pfatc_event_mode = pfatcEventMode; + if (typeof aatcEventMode === 'boolean') + updates.aatc_event_mode = aatcEventMode; if (Object.keys(updates).length === 0) { return res.status(400).json({ error: 'No valid fields provided' }); diff --git a/server/routes/admin/testers.ts b/server/routes/admin/testers.ts index 1c95f488..80398797 100644 --- a/server/routes/admin/testers.ts +++ b/server/routes/admin/testers.ts @@ -136,7 +136,11 @@ router.post('/', async (req, res) => { } } - capture(req,{ distinctId: req.user.userId, event: 'admin_tester_added', properties: { target_user_id: userId, target_username: user.username } }); + capture(req, { + distinctId: req.user.userId, + event: 'admin_tester_added', + properties: { target_user_id: userId, target_username: user.username }, + }); res.json(tester); } catch (error) { console.error('Error adding tester:', error); @@ -187,7 +191,15 @@ router.delete('/:userId', async (req, res) => { } } - if (req.user?.userId) capture(req,{ distinctId: req.user.userId, event: 'admin_tester_removed', properties: { target_user_id: userId, target_username: user?.username || removedTester.username } }); + if (req.user?.userId) + capture(req, { + distinctId: req.user.userId, + event: 'admin_tester_removed', + properties: { + target_user_id: userId, + target_username: user?.username || removedTester.username, + }, + }); res.json({ message: 'Tester removed successfully', tester: removedTester }); } catch (error) { console.error('Error removing tester:', error); @@ -209,7 +221,12 @@ router.put('/settings', async (req, res) => { tester_gate_enabled ); - if (req.user?.userId) capture(req,{ distinctId: req.user.userId, event: 'admin_tester_gate_toggled', properties: { enabled: tester_gate_enabled } }); + if (req.user?.userId) + capture(req, { + distinctId: req.user.userId, + event: 'admin_tester_gate_toggled', + properties: { enabled: tester_gate_enabled }, + }); if (req.user?.userId) { try { diff --git a/server/routes/admin/users.ts b/server/routes/admin/users.ts index 9d8dea4a..3f392342 100644 --- a/server/routes/admin/users.ts +++ b/server/routes/admin/users.ts @@ -94,7 +94,9 @@ router.post( router.post('/:userId/set-vpn', async (req, res) => { try { if (!req.user?.userId || !isAdmin(req.user.userId)) { - return res.status(403).json({ error: 'Access denied - insufficient permissions' }); + return res + .status(403) + .json({ error: 'Access denied - insufficient permissions' }); } const { userId } = req.params; @@ -123,7 +125,11 @@ router.post('/:userId/set-vpn', async (req, res) => { details: { isVpn }, }); } catch (auditErr) { - console.error('[audit] Failed to log VPN_FLAG_SET for user', userId, auditErr); + console.error( + '[audit] Failed to log VPN_FLAG_SET for user', + userId, + auditErr + ); } res.json({ success: true, userId, isVpn }); diff --git a/server/routes/admin/websockets.ts b/server/routes/admin/websockets.ts index 8d0a3f84..0e9e1ade 100644 --- a/server/routes/admin/websockets.ts +++ b/server/routes/admin/websockets.ts @@ -1,10 +1,10 @@ -import express from "express"; -import { requirePermission } from "../../middleware/rolePermissions.js"; -import { getAdminSocketStatsWithHistory } from "../../realtime/socketRegistry.js"; +import express from 'express'; +import { requirePermission } from '../../middleware/rolePermissions.js'; +import { getAdminSocketStatsWithHistory } from '../../realtime/socketRegistry.js'; const router = express.Router(); -router.get("/", requirePermission("admin"), async (_req, res) => { +router.get('/', requirePermission('admin'), async (_req, res) => { try { const namespaces = await getAdminSocketStatsWithHistory(); const totalConnected = namespaces.reduce((sum, n) => sum + n.connected, 0); @@ -14,9 +14,9 @@ router.get("/", requirePermission("admin"), async (_req, res) => { polledAt: new Date().toISOString(), }); } catch (error) { - console.error("Error fetching websocket stats:", error); - res.status(500).json({ error: "Failed to fetch websocket stats" }); + console.error('Error fetching websocket stats:', error); + res.status(500).json({ error: 'Failed to fetch websocket stats' }); } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/atis.ts b/server/routes/atis.ts index a5b7ab54..92267cd8 100644 --- a/server/routes/atis.ts +++ b/server/routes/atis.ts @@ -146,7 +146,12 @@ router.post('/generate', requireAuth, async (req, res) => { throw new Error('Failed to update session with ATIS data'); } - if (req.user?.userId) capture(req, { distinctId: req.user.userId, event: 'atis_generated', properties: { session_id: sessionId, icao, ident } }); + if (req.user?.userId) + capture(req, { + distinctId: req.user.userId, + event: 'atis_generated', + properties: { session_id: sessionId, icao, ident }, + }); res.json({ text: generatedAtis, diff --git a/server/routes/auth.ts b/server/routes/auth.ts index d02a83bc..f607ac7a 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -156,7 +156,11 @@ router.get('/discord/callback', authLimiter, async (req, res) => { is_vpn: isVpn, }, }); - capture(req, { distinctId: discordUser.id, event: 'user_logged_in', properties: { username: discordUser.username, is_vpn: isVpn } }); + capture(req, { + distinctId: discordUser.id, + event: 'user_logged_in', + properties: { username: discordUser.username, is_vpn: isVpn }, + }); const payload = { userId: discordUser.id, @@ -268,7 +272,13 @@ router.get('/roblox/callback', authLimiter, async (req, res) => { roblox_user_id: robloxUser.sub, }, }); - capture(req, { distinctId: userId, event: 'roblox_linked', properties: { roblox_username: robloxUser.preferred_username || robloxUser.name } }); + capture(req, { + distinctId: userId, + event: 'roblox_linked', + properties: { + roblox_username: robloxUser.preferred_username || robloxUser.name, + }, + }); res.redirect(FRONTEND_URL + '/settings?roblox_linked=true'); } catch (error) { @@ -282,7 +292,14 @@ router.post('/roblox/unlink', requireAuth, async (req, res) => { try { if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); await unlinkRobloxAccount(req.user.userId); - posthog.identify({ distinctId: req.user.userId, properties: { has_roblox: false, roblox_username: null, roblox_user_id: null } }); + posthog.identify({ + distinctId: req.user.userId, + properties: { + has_roblox: false, + roblox_username: null, + roblox_user_id: null, + }, + }); capture(req, { distinctId: req.user.userId, event: 'roblox_unlinked' }); res.json({ success: true, message: 'Roblox account unlinked' }); } catch (error) { @@ -464,7 +481,11 @@ router.get('/vatsim/callback', authLimiter, async (req, res) => { vatsim_rating_long: ratingLong, }, }); - capture(req, { distinctId: userId, event: 'vatsim_linked', properties: { vatsim_cid: cid, rating_short: fallbackShort } }); + capture(req, { + distinctId: userId, + event: 'vatsim_linked', + properties: { vatsim_cid: cid, rating_short: fallbackShort }, + }); res.redirect(FRONTEND_URL + '/settings?vatsim_linked=true'); } catch (error) { @@ -611,7 +632,11 @@ router.post('/vatsim/exchange', authLimiter, requireAuth, async (req, res) => { vatsim_rating_long: ratingLong2, }, }); - capture(req, { distinctId: req.user.userId, event: 'vatsim_linked', properties: { vatsim_cid: cid, rating_short: fallbackShort } }); + capture(req, { + distinctId: req.user.userId, + event: 'vatsim_linked', + properties: { vatsim_cid: cid, rating_short: fallbackShort }, + }); res.json({ success: true, vatsimCid: cid, @@ -639,7 +664,15 @@ router.post('/vatsim/unlink', requireAuth, async (req, res) => { const { unlinkVatsimAccount } = await import('../db/users.js'); if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); await unlinkVatsimAccount(req.user.userId); - posthog.identify({ distinctId: req.user.userId, properties: { has_vatsim: false, vatsim_cid: null, vatsim_rating: null, vatsim_rating_long: null } }); + posthog.identify({ + distinctId: req.user.userId, + properties: { + has_vatsim: false, + vatsim_cid: null, + vatsim_rating: null, + vatsim_rating_long: null, + }, + }); capture(req, { distinctId: req.user.userId, event: 'vatsim_unlinked' }); res.cookie('vatsim_force', '1', { httpOnly: true, @@ -662,8 +695,14 @@ router.put('/tutorial', requireAuth, async (req, res) => { if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); await updateTutorialStatus(req.user.userId, completed); if (completed) { - posthog.identify({ distinctId: req.user.userId, properties: { tutorial_completed: true } }); - capture(req, { distinctId: req.user.userId, event: 'tutorial_completed' }); + posthog.identify({ + distinctId: req.user.userId, + properties: { tutorial_completed: true }, + }); + capture(req, { + distinctId: req.user.userId, + event: 'tutorial_completed', + }); } res.json({ success: true }); } catch { @@ -751,9 +790,12 @@ router.get('/me', requireAuthSoft, async (req, res) => { } const vpnGateEnabled = await isVpnGateEnabled(); - const isCurrentlyVpn = !!user.is_vpn || (vpnGateEnabled && (await isVpnRequest(req))); + const isCurrentlyVpn = + !!user.is_vpn || (vpnGateEnabled && (await isVpnRequest(req))); const isVpnBlocked = - vpnGateEnabled && isCurrentlyVpn && !(await isVpnException(req.user.userId)); + vpnGateEnabled && + isCurrentlyVpn && + !(await isVpnException(req.user.userId)); // Return immediately for VPN-blocked users — skip rank queries. if (isVpnBlocked) { @@ -862,14 +904,21 @@ router.post('/fingerprint', requireAuthSoft, async (req, res) => { if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const { visitorId } = req.body; - if (!visitorId || typeof visitorId !== 'string' || visitorId.length < 8 || visitorId.length > 255) { + if ( + !visitorId || + typeof visitorId !== 'string' || + visitorId.length < 8 || + visitorId.length > 255 + ) { return res.status(400).json({ error: 'Invalid visitorId' }); } try { await updateUserFingerprint(req.user.userId, visitorId); - const fpToken = jwt.sign({ visitorId }, JWT_SECRET as string, { expiresIn: '7d' }); + const fpToken = jwt.sign({ visitorId }, JWT_SECRET as string, { + expiresIn: '7d', + }); res.cookie('fp_token', fpToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', @@ -891,8 +940,11 @@ router.post('/logout', (req, res) => { if (token && JWT_SECRET) { try { const decoded = jwt.verify(token, JWT_SECRET) as { userId?: string }; - if (decoded?.userId) capture(req, { distinctId: decoded.userId, event: 'user_logged_out' }); - } catch { /* invalid token, skip */ } + if (decoded?.userId) + capture(req, { distinctId: decoded.userId, event: 'user_logged_out' }); + } catch { + /* invalid token, skip */ + } } res.clearCookie('auth_token', { httpOnly: true, diff --git a/server/routes/chats.ts b/server/routes/chats.ts index a5f7ad45..e929da85 100644 --- a/server/routes/chats.ts +++ b/server/routes/chats.ts @@ -1,73 +1,79 @@ -import express from "express"; +import express from 'express'; import { addChatMessage, getChatMessages, deleteChatMessage, reportChatMessage, reportGlobalChatMessage, -} from "../db/chats.js"; -import { chatMessageLimiter } from "../middleware/rateLimiting.js"; -import requireAuth from "../middleware/auth.js"; -import { capture } from "../utils/posthog.js"; -import { mainDb } from "../db/connection.js"; -import { decrypt } from "../utils/encryption.js"; -import { sql } from "kysely"; +} from '../db/chats.js'; +import { chatMessageLimiter } from '../middleware/rateLimiting.js'; +import requireAuth from '../middleware/auth.js'; +import { capture } from '../utils/posthog.js'; +import { mainDb } from '../db/connection.js'; +import { decrypt } from '../utils/encryption.js'; +import { sql } from 'kysely'; const router = express.Router(); // POST: /api/chats/global/:messageId/report - Report a global chat message -router.post("/global/:messageId/report", requireAuth, async (req, res) => { +router.post('/global/:messageId/report', requireAuth, async (req, res) => { try { const { reason } = req.body; - if (typeof reason !== "string" || reason.length > 500) { - return res.status(400).json({ error: "Invalid or too long reason" }); + if (typeof reason !== 'string' || reason.length > 500) { + return res.status(400).json({ error: 'Invalid or too long reason' }); } const user = req.user; if (!user) { - return res.status(401).json({ error: "Unauthorized" }); + return res.status(401).json({ error: 'Unauthorized' }); } const messageId = Number(req.params.messageId); if (isNaN(messageId)) { - return res.status(400).json({ error: "Invalid message ID" }); + return res.status(400).json({ error: 'Invalid message ID' }); } await reportGlobalChatMessage(messageId, user.userId, reason); capture(req, { distinctId: user.userId, - event: "chat_message_reported", - properties: { message_id: messageId, type: "global" }, + event: 'chat_message_reported', + properties: { message_id: messageId, type: 'global' }, }); res.status(201).json({ success: true }); } catch (error) { - console.error("Global chat report error:", error); - res.status(500).json({ error: "Failed to report message" }); + console.error('Global chat report error:', error); + res.status(500).json({ error: 'Failed to report message' }); } }); // GET: /api/chats/global - Get global chat messages (last 30 minutes) -router.get("/global/messages", requireAuth, async (req, res) => { +router.get('/global/messages', requireAuth, async (req, res) => { try { const messages = await mainDb - .selectFrom("global_chat") + .selectFrom('global_chat') .selectAll() - .where("network_kind", "=", "pfatc") + .where('network_kind', '=', 'pfatc') .where((eb) => - eb(sql`sent_at`, ">=", sql`(NOW() AT TIME ZONE 'UTC') - INTERVAL '30 minutes'`), + eb( + sql`sent_at`, + '>=', + sql`(NOW() AT TIME ZONE 'UTC') - INTERVAL '30 minutes'` + ) ) - .where("deleted_at", "is", null) - .orderBy("sent_at", "asc") + .where('deleted_at', 'is', null) + .orderBy('sent_at', 'asc') .execute(); const formattedMessages = messages.map((msg) => { - let decryptedMessage = ""; + let decryptedMessage = ''; try { if (msg.message) { const encryptedData = - typeof msg.message === "string" ? JSON.parse(msg.message) : msg.message; - decryptedMessage = decrypt(encryptedData) || ""; + typeof msg.message === 'string' + ? JSON.parse(msg.message) + : msg.message; + decryptedMessage = decrypt(encryptedData) || ''; } } catch (e) { - console.error("[Global Chat] Error decrypting message:", e); - decryptedMessage = ""; + console.error('[Global Chat] Error decrypting message:', e); + decryptedMessage = ''; } let airportMentions = null; @@ -76,7 +82,10 @@ router.get("/global/messages", requireAuth, async (req, res) => { if (msg.airport_mentions) { if (Array.isArray(msg.airport_mentions)) { airportMentions = msg.airport_mentions; - } else if (typeof msg.airport_mentions === "string" && msg.airport_mentions.trim()) { + } else if ( + typeof msg.airport_mentions === 'string' && + msg.airport_mentions.trim() + ) { try { airportMentions = JSON.parse(msg.airport_mentions); } catch (e) { @@ -88,7 +97,10 @@ router.get("/global/messages", requireAuth, async (req, res) => { if (msg.user_mentions) { if (Array.isArray(msg.user_mentions)) { userMentions = msg.user_mentions; - } else if (typeof msg.user_mentions === "string" && msg.user_mentions.trim()) { + } else if ( + typeof msg.user_mentions === 'string' && + msg.user_mentions.trim() + ) { try { userMentions = JSON.parse(msg.user_mentions); } catch (e) { @@ -113,123 +125,147 @@ router.get("/global/messages", requireAuth, async (req, res) => { res.json(formattedMessages); } catch (error) { - console.error("Error fetching global chat messages:", error); - res.status(500).json({ error: "Failed to fetch global chat messages" }); + console.error('Error fetching global chat messages:', error); + res.status(500).json({ error: 'Failed to fetch global chat messages' }); } }); // GET: /api/chats/:sessionId -router.get("/:sessionId", requireAuth, async (req, res) => { +router.get('/:sessionId', requireAuth, async (req, res) => { try { const messages = await getChatMessages(req.params.sessionId); res.json(messages); } catch { - res.status(500).json({ error: "Failed to fetch chat messages" }); + res.status(500).json({ error: 'Failed to fetch chat messages' }); } }); // POST: /api/chats/:sessionId -router.post("/:sessionId", chatMessageLimiter, requireAuth, async (req, res) => { - try { - const { message } = req.body; - if (typeof message !== "string" || message.length > 500) { - return res.status(400).json({ error: "Message too long" }); - } - const user = req.user; - if (!user) { - return res.status(401).json({ error: "Unauthorized" }); +router.post( + '/:sessionId', + chatMessageLimiter, + requireAuth, + async (req, res) => { + try { + const { message } = req.body; + if (typeof message !== 'string' || message.length > 500) { + return res.status(400).json({ error: 'Message too long' }); + } + const user = req.user; + if (!user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const chatMsg = await addChatMessage(req.params.sessionId, { + userId: user.userId, + username: user.username, + avatar: user.avatar ?? '', + message, + }); + capture(req, { + distinctId: user.userId, + event: 'chat_message_sent', + properties: { session_id: req.params.sessionId }, + }); + res.status(201).json(chatMsg); + } catch { + res.status(500).json({ error: 'Failed to send message' }); } - const chatMsg = await addChatMessage(req.params.sessionId, { - userId: user.userId, - username: user.username, - avatar: user.avatar ?? "", - message, - }); - capture(req, { - distinctId: user.userId, - event: "chat_message_sent", - properties: { session_id: req.params.sessionId }, - }); - res.status(201).json(chatMsg); - } catch { - res.status(500).json({ error: "Failed to send message" }); } -}); +); // DELETE: /api/chats/:sessionId/:messageId -router.delete("/:sessionId/:messageId", requireAuth, async (req, res) => { +router.delete('/:sessionId/:messageId', requireAuth, async (req, res) => { try { const user = req.user; if (!user) { - return res.status(401).json({ error: "Unauthorized" }); + return res.status(401).json({ error: 'Unauthorized' }); } const messageId = Number(req.params.messageId); if (isNaN(messageId)) { - return res.status(400).json({ error: "Invalid message ID" }); + return res.status(400).json({ error: 'Invalid message ID' }); } - const success = await deleteChatMessage(req.params.sessionId, messageId, user.userId); + const success = await deleteChatMessage( + req.params.sessionId, + messageId, + user.userId + ); if (success) { res.json({ success: true }); } else { - res.status(403).json({ error: "Cannot delete this message" }); + res.status(403).json({ error: 'Cannot delete this message' }); } } catch { - res.status(500).json({ error: "Failed to delete message" }); + res.status(500).json({ error: 'Failed to delete message' }); } }); // POST: /api/chats/:sessionId/:messageId/report -router.post("/:sessionId/:messageId/report", requireAuth, async (req, res) => { +router.post('/:sessionId/:messageId/report', requireAuth, async (req, res) => { try { const { reason } = req.body; - if (typeof reason !== "string" || reason.length > 500) { - return res.status(400).json({ error: "Invalid or too long reason" }); + if (typeof reason !== 'string' || reason.length > 500) { + return res.status(400).json({ error: 'Invalid or too long reason' }); } const user = req.user; if (!user) { - return res.status(401).json({ error: "Unauthorized" }); + return res.status(401).json({ error: 'Unauthorized' }); } const messageId = Number(req.params.messageId); if (isNaN(messageId)) { - return res.status(400).json({ error: "Invalid message ID" }); + return res.status(400).json({ error: 'Invalid message ID' }); } - await reportChatMessage(req.params.sessionId, messageId, user.userId, reason); + await reportChatMessage( + req.params.sessionId, + messageId, + user.userId, + reason + ); capture(req, { distinctId: user.userId, - event: "chat_message_reported", - properties: { session_id: req.params.sessionId, message_id: messageId, type: "session" }, + event: 'chat_message_reported', + properties: { + session_id: req.params.sessionId, + message_id: messageId, + type: 'session', + }, }); res.status(201).json({ success: true }); } catch (error) { - console.error("Report error:", error); - res.status(500).json({ error: "Failed to report message" }); + console.error('Report error:', error); + res.status(500).json({ error: 'Failed to report message' }); } }); // GET: /api/chats/aatc/messages - Fetch AATC global chat messages (last 30 minutes) -router.get("/aatc/messages", requireAuth, async (req, res) => { +router.get('/aatc/messages', requireAuth, async (req, res) => { try { const messages = await mainDb - .selectFrom("global_chat") + .selectFrom('global_chat') .selectAll() - .where("network_kind", "=", "aatc") + .where('network_kind', '=', 'aatc') .where((eb) => - eb(sql`sent_at`, ">=", sql`(NOW() AT TIME ZONE 'UTC') - INTERVAL '30 minutes'`), + eb( + sql`sent_at`, + '>=', + sql`(NOW() AT TIME ZONE 'UTC') - INTERVAL '30 minutes'` + ) ) - .where("deleted_at", "is", null) - .orderBy("sent_at", "asc") + .where('deleted_at', 'is', null) + .orderBy('sent_at', 'asc') .execute(); const formattedMessages = messages.map((msg) => { - let decryptedMessage = ""; + let decryptedMessage = ''; try { if (msg.message) { const encryptedData = - typeof msg.message === "string" ? JSON.parse(msg.message) : msg.message; - decryptedMessage = decrypt(encryptedData) || ""; + typeof msg.message === 'string' + ? JSON.parse(msg.message) + : msg.message; + decryptedMessage = decrypt(encryptedData) || ''; } } catch { - decryptedMessage = ""; + decryptedMessage = ''; } let airportMentions = null; @@ -273,33 +309,34 @@ router.get("/aatc/messages", requireAuth, async (req, res) => { res.json(formattedMessages); } catch (error) { - console.error("Error fetching AATC chat messages:", error); - res.status(500).json({ error: "Failed to fetch AATC chat messages" }); + console.error('Error fetching AATC chat messages:', error); + res.status(500).json({ error: 'Failed to fetch AATC chat messages' }); } }); // POST: /api/chats/aatc/:messageId/report - Report an AATC global chat message -router.post("/aatc/:messageId/report", requireAuth, async (req, res) => { +router.post('/aatc/:messageId/report', requireAuth, async (req, res) => { try { const { reason } = req.body; - if (typeof reason !== "string" || reason.length > 500) { - return res.status(400).json({ error: "Invalid or too long reason" }); + if (typeof reason !== 'string' || reason.length > 500) { + return res.status(400).json({ error: 'Invalid or too long reason' }); } const user = req.user; - if (!user) return res.status(401).json({ error: "Unauthorized" }); + if (!user) return res.status(401).json({ error: 'Unauthorized' }); const messageId = Number(req.params.messageId); - if (isNaN(messageId)) return res.status(400).json({ error: "Invalid message ID" }); + if (isNaN(messageId)) + return res.status(400).json({ error: 'Invalid message ID' }); await reportGlobalChatMessage(messageId, user.userId, reason); capture(req, { distinctId: user.userId, - event: "chat_message_reported", - properties: { message_id: messageId, type: "aatc-global" }, + event: 'chat_message_reported', + properties: { message_id: messageId, type: 'aatc-global' }, }); res.status(201).json({ success: true }); } catch (error) { - console.error("AATC chat report error:", error); - res.status(500).json({ error: "Failed to report message" }); + console.error('AATC chat report error:', error); + res.status(500).json({ error: 'Failed to report message' }); } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/data.ts b/server/routes/data.ts index 73a9dc5a..5b08d40d 100644 --- a/server/routes/data.ts +++ b/server/routes/data.ts @@ -1,18 +1,18 @@ -import express from "express"; -import path from "path"; -import fs from "fs"; -import { fileURLToPath } from "url"; -import { getTesterSettings } from "../db/testers.js"; -import { getActiveNotifications } from "../db/notifications.js"; -import { mainDb, redisConnection } from "../db/connection.js"; -import { getTopUsers, STATS_KEYS, getUserRank } from "../db/leaderboard.js"; -import { getUserById } from "../db/users.js"; -import { getFlightLogsCount } from "../db/flightLogs.js"; -import { getWaypointData, getAirportData } from "../utils/getData.js"; -import { findPath, extractFixFromProcedure } from "../utils/findRoute.js"; -import { sql } from "kysely"; -import { applyPublicCache } from "../utils/httpCache.js"; -import { resolveAviationMetar } from "../utils/metarAviationWeather.js"; +import express from 'express'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { getTesterSettings } from '../db/testers.js'; +import { getActiveNotifications } from '../db/notifications.js'; +import { mainDb, redisConnection } from '../db/connection.js'; +import { getTopUsers, STATS_KEYS, getUserRank } from '../db/leaderboard.js'; +import { getUserById } from '../db/users.js'; +import { getFlightLogsCount } from '../db/flightLogs.js'; +import { getWaypointData, getAirportData } from '../utils/getData.js'; +import { findPath, extractFixFromProcedure } from '../utils/findRoute.js'; +import { sql } from 'kysely'; +import { applyPublicCache } from '../utils/httpCache.js'; +import { resolveAviationMetar } from '../utils/metarAviationWeather.js'; import { DATA_STATIC_BROWSER_SEC, DATA_STATIC_EDGE_SEC, @@ -23,21 +23,30 @@ import { LEADERBOARD_BROWSER_SEC, LEADERBOARD_EDGE_SEC, prefixKey, -} from "../utils/cacheTtl.js"; +} from '../utils/cacheTtl.js'; -import dotenv from "dotenv"; -const envFile = process.env.NODE_ENV === "production" ? ".env.production" : ".env.development"; +import dotenv from 'dotenv'; +const envFile = + process.env.NODE_ENV === 'production' + ? '.env.production' + : '.env.development'; dotenv.config({ path: envFile }); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const airportsPath = path.join(__dirname, "..", "data", "airportData.json"); -const aircraftPath = path.join(__dirname, "..", "data", "aircraftData.json"); -const airlinesPath = path.join(__dirname, "..", "data", "airlineData.json"); -const waypointsPath = path.join(__dirname, "..", "data", "waypointData.json"); -const islandsPath = path.join(__dirname, "..", "data", "islandData.json"); -const backgroundsPath = path.join(process.cwd(), "public", "assets", "app", "backgrounds"); +const airportsPath = path.join(__dirname, '..', 'data', 'airportData.json'); +const aircraftPath = path.join(__dirname, '..', 'data', 'aircraftData.json'); +const airlinesPath = path.join(__dirname, '..', 'data', 'airlineData.json'); +const waypointsPath = path.join(__dirname, '..', 'data', 'waypointData.json'); +const islandsPath = path.join(__dirname, '..', 'data', 'islandData.json'); +const backgroundsPath = path.join( + process.cwd(), + 'public', + 'assets', + 'app', + 'backgrounds' +); if ( !fs.existsSync(airportsPath) || @@ -78,8 +87,8 @@ interface Airport { const router = express.Router(); // GET: /api/data/airports -router.get("/airports", async (req, res) => { - const cacheKey = prefixKey("data:airports"); +router.get('/airports', async (req, res) => { + const cacheKey = prefixKey('data:airports'); try { const cached = await redisConnection.get(cacheKey); @@ -92,22 +101,30 @@ router.get("/airports", async (req, res) => { } } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to read cache for airports:", error.message); + console.warn('[Redis] Failed to read cache for airports:', error.message); } } try { if (!fs.existsSync(airportsPath)) { - return res.status(404).json({ error: "Airport data not found" }); + return res.status(404).json({ error: 'Airport data not found' }); } - const data: Airport[] = JSON.parse(fs.readFileSync(airportsPath, "utf8")); + const data: Airport[] = JSON.parse(fs.readFileSync(airportsPath, 'utf8')); try { - await redisConnection.set(cacheKey, JSON.stringify(data), "EX", DATA_STATIC_REDIS_SEC); + await redisConnection.set( + cacheKey, + JSON.stringify(data), + 'EX', + DATA_STATIC_REDIS_SEC + ); } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to set cache for airports:", error.message); + console.warn( + '[Redis] Failed to set cache for airports:', + error.message + ); } } @@ -117,17 +134,17 @@ router.get("/airports", async (req, res) => { }); res.json(data); } catch (error) { - console.error("Error reading airport data:", error); + console.error('Error reading airport data:', error); res.status(500).json({ - error: "Internal server error", - message: "Error reading airport data", + error: 'Internal server error', + message: 'Error reading airport data', }); } }); // GET: /api/data/aircrafts -router.get("/aircrafts", async (req, res) => { - const cacheKey = prefixKey("data:aircrafts"); +router.get('/aircrafts', async (req, res) => { + const cacheKey = prefixKey('data:aircrafts'); try { const cached = await redisConnection.get(cacheKey); @@ -140,22 +157,33 @@ router.get("/aircrafts", async (req, res) => { } } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to read cache for aircrafts:", error.message); + console.warn( + '[Redis] Failed to read cache for aircrafts:', + error.message + ); } } try { if (!fs.existsSync(aircraftPath)) { - return res.status(404).json({ error: "Aircraft data not found" }); + return res.status(404).json({ error: 'Aircraft data not found' }); } - const data = JSON.parse(fs.readFileSync(aircraftPath, "utf8")); + const data = JSON.parse(fs.readFileSync(aircraftPath, 'utf8')); try { - await redisConnection.set(cacheKey, JSON.stringify(data), "EX", DATA_STATIC_REDIS_SEC); + await redisConnection.set( + cacheKey, + JSON.stringify(data), + 'EX', + DATA_STATIC_REDIS_SEC + ); } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to set cache for aircrafts:", error.message); + console.warn( + '[Redis] Failed to set cache for aircrafts:', + error.message + ); } } @@ -165,17 +193,17 @@ router.get("/aircrafts", async (req, res) => { }); res.json(data); } catch (error) { - console.error("Error reading aircraft data:", error); + console.error('Error reading aircraft data:', error); res.status(500).json({ - error: "Internal server error", - message: "Error reading aircraft data", + error: 'Internal server error', + message: 'Error reading aircraft data', }); } }); // GET: /api/data/airlines -router.get("/airlines", async (req, res) => { - const cacheKey = prefixKey("data:airlines"); +router.get('/airlines', async (req, res) => { + const cacheKey = prefixKey('data:airlines'); try { const cached = await redisConnection.get(cacheKey); @@ -188,22 +216,30 @@ router.get("/airlines", async (req, res) => { } } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to read cache for airlines:", error.message); + console.warn('[Redis] Failed to read cache for airlines:', error.message); } } try { if (!fs.existsSync(airlinesPath)) { - return res.status(404).json({ error: "Airline data not found" }); + return res.status(404).json({ error: 'Airline data not found' }); } - const data = JSON.parse(fs.readFileSync(airlinesPath, "utf8")); + const data = JSON.parse(fs.readFileSync(airlinesPath, 'utf8')); try { - await redisConnection.set(cacheKey, JSON.stringify(data), "EX", DATA_STATIC_REDIS_SEC); + await redisConnection.set( + cacheKey, + JSON.stringify(data), + 'EX', + DATA_STATIC_REDIS_SEC + ); } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to set cache for airlines:", error.message); + console.warn( + '[Redis] Failed to set cache for airlines:', + error.message + ); } } @@ -213,17 +249,17 @@ router.get("/airlines", async (req, res) => { }); res.json(data); } catch (error) { - console.error("Error reading airline data:", error); + console.error('Error reading airline data:', error); res.status(500).json({ - error: "Internal server error", - message: "Error reading airline data", + error: 'Internal server error', + message: 'Error reading airline data', }); } }); // GET: /api/data/waypoints -router.get("/waypoints", async (req, res) => { - const cacheKey = prefixKey("data:waypoints"); +router.get('/waypoints', async (req, res) => { + const cacheKey = prefixKey('data:waypoints'); try { const cached = await redisConnection.get(cacheKey); @@ -236,22 +272,33 @@ router.get("/waypoints", async (req, res) => { } } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to read cache for waypoints:", error.message); + console.warn( + '[Redis] Failed to read cache for waypoints:', + error.message + ); } } try { if (!fs.existsSync(waypointsPath)) { - return res.status(404).json({ error: "Waypoint data not found" }); + return res.status(404).json({ error: 'Waypoint data not found' }); } - const data = JSON.parse(fs.readFileSync(waypointsPath, "utf8")); + const data = JSON.parse(fs.readFileSync(waypointsPath, 'utf8')); try { - await redisConnection.set(cacheKey, JSON.stringify(data), "EX", DATA_STATIC_REDIS_SEC); + await redisConnection.set( + cacheKey, + JSON.stringify(data), + 'EX', + DATA_STATIC_REDIS_SEC + ); } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to set cache for waypoints:", error.message); + console.warn( + '[Redis] Failed to set cache for waypoints:', + error.message + ); } } @@ -261,17 +308,17 @@ router.get("/waypoints", async (req, res) => { }); res.json(data); } catch (error) { - console.error("Error reading waypoint data:", error); + console.error('Error reading waypoint data:', error); res.status(500).json({ - error: "Internal server error", - message: "Error reading waypoint data", + error: 'Internal server error', + message: 'Error reading waypoint data', }); } }); // GET: /api/data/islands -router.get("/islands", async (req, res) => { - const cacheKey = prefixKey("data:islands"); +router.get('/islands', async (req, res) => { + const cacheKey = prefixKey('data:islands'); try { const cached = await redisConnection.get(cacheKey); @@ -284,22 +331,27 @@ router.get("/islands", async (req, res) => { } } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to read cache for islands:", error.message); + console.warn('[Redis] Failed to read cache for islands:', error.message); } } try { if (!fs.existsSync(islandsPath)) { - return res.status(404).json({ error: "Island data not found" }); + return res.status(404).json({ error: 'Island data not found' }); } - const data = JSON.parse(fs.readFileSync(islandsPath, "utf8")); + const data = JSON.parse(fs.readFileSync(islandsPath, 'utf8')); try { - await redisConnection.set(cacheKey, JSON.stringify(data), "EX", DATA_STATIC_REDIS_SEC); + await redisConnection.set( + cacheKey, + JSON.stringify(data), + 'EX', + DATA_STATIC_REDIS_SEC + ); } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to set cache for islands:", error.message); + console.warn('[Redis] Failed to set cache for islands:', error.message); } } @@ -309,17 +361,17 @@ router.get("/islands", async (req, res) => { }); res.json(data); } catch (error) { - console.error("Error reading island data:", error); + console.error('Error reading island data:', error); res.status(500).json({ - error: "Internal server error", - message: "Error reading island data", + error: 'Internal server error', + message: 'Error reading island data', }); } }); // GET: /api/data/frequencies -router.get("/frequencies", async (req, res) => { - const cacheKey = prefixKey("data:frequencies"); +router.get('/frequencies', async (req, res) => { + const cacheKey = prefixKey('data:frequencies'); try { const cached = await redisConnection.get(cacheKey); @@ -332,25 +384,28 @@ router.get("/frequencies", async (req, res) => { } } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to read cache for frequencies:", error.message); + console.warn( + '[Redis] Failed to read cache for frequencies:', + error.message + ); } } try { if (!fs.existsSync(airportsPath)) { - return res.status(404).json({ error: "Airport data not found" }); + return res.status(404).json({ error: 'Airport data not found' }); } - const freqOrder = ["APP", "TWR", "GND", "DEL"]; + const freqOrder = ['APP', 'TWR', 'GND', 'DEL']; const freqMapping = { - clearanceDelivery: "DEL", - departure: "DEP", - ground: "GND", - tower: "TWR", - approach: "APP", + clearanceDelivery: 'DEL', + departure: 'DEP', + ground: 'GND', + tower: 'TWR', + approach: 'APP', }; - const data: Airport[] = JSON.parse(fs.readFileSync(airportsPath, "utf8")); + const data: Airport[] = JSON.parse(fs.readFileSync(airportsPath, 'utf8')); const frequencies = data.map((airport: Airport) => { const allFreqs = airport.allFrequencies || {}; const displayFreqs = freqOrder @@ -364,7 +419,7 @@ router.get("/frequencies", async (req, res) => { } } } - return freq && freq.toLowerCase() !== "n/a" ? { type, freq } : null; + return freq && freq.toLowerCase() !== 'n/a' ? { type, freq } : null; }) .filter(Boolean); @@ -375,12 +430,15 @@ router.get("/frequencies", async (req, res) => { !usedTypes.has(key) && !Object.keys(freqMapping).includes(key) && value && - value.toLowerCase() !== "n/a", + value.toLowerCase() !== 'n/a' ) .slice(0, 4 - displayFreqs.length) .map(([type, freq]) => ({ type: type.toUpperCase(), freq })); - const allDisplayFreqs = [...displayFreqs.filter(Boolean), ...remainingFreqs].slice(0, 4); + const allDisplayFreqs = [ + ...displayFreqs.filter(Boolean), + ...remainingFreqs, + ].slice(0, 4); return { icao: airport.icao, @@ -390,10 +448,18 @@ router.get("/frequencies", async (req, res) => { }); try { - await redisConnection.set(cacheKey, JSON.stringify(frequencies), "EX", DATA_STATIC_REDIS_SEC); + await redisConnection.set( + cacheKey, + JSON.stringify(frequencies), + 'EX', + DATA_STATIC_REDIS_SEC + ); } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to set cache for frequencies:", error.message); + console.warn( + '[Redis] Failed to set cache for frequencies:', + error.message + ); } } @@ -403,23 +469,31 @@ router.get("/frequencies", async (req, res) => { }); res.json(frequencies); } catch (error) { - console.error("Error reading airport frequencies:", error); + console.error('Error reading airport frequencies:', error); res.status(500).json({ - error: "Internal server error", - message: "Error reading airport frequencies", + error: 'Internal server error', + message: 'Error reading airport frequencies', }); } }); // GET: /api/data/backgrounds -router.get("/backgrounds", (req, res) => { +router.get('/backgrounds', (req, res) => { try { if (!fs.existsSync(backgroundsPath)) { - return res.status(404).json({ error: "Backgrounds directory not found" }); + return res.status(404).json({ error: 'Backgrounds directory not found' }); } const files = fs.readdirSync(backgroundsPath); - const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"]; + const imageExtensions = [ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.bmp', + '.webp', + '.svg', + ]; const backgroundImages = files .filter((file) => { @@ -435,25 +509,25 @@ router.get("/backgrounds", (req, res) => { applyPublicCache(res, { browserMaxAge: 120, edgeMaxAge: 3600 }); res.json(backgroundImages); } catch (error) { - console.error("Error reading background images:", error); + console.error('Error reading background images:', error); res.status(500).json({ - error: "Internal server error", - message: "Error reading background images", + error: 'Internal server error', + message: 'Error reading background images', }); } }); // GET: /api/data/airports/:icao/runways -router.get("/airports/:icao/runways", (req, res) => { +router.get('/airports/:icao/runways', (req, res) => { try { if (!fs.existsSync(airportsPath)) { - return res.status(404).json({ error: "Airport data not found" }); + return res.status(404).json({ error: 'Airport data not found' }); } - const data: Airport[] = JSON.parse(fs.readFileSync(airportsPath, "utf8")); + const data: Airport[] = JSON.parse(fs.readFileSync(airportsPath, 'utf8')); const airport = data.find((a: Airport) => a.icao === req.params.icao); if (!airport) { - return res.status(404).json({ error: "Airport not found" }); + return res.status(404).json({ error: 'Airport not found' }); } applyPublicCache(res, { browserMaxAge: DATA_STATIC_BROWSER_SEC, @@ -461,25 +535,25 @@ router.get("/airports/:icao/runways", (req, res) => { }); res.json(airport.runways || []); } catch (error) { - console.error("Error reading airport data:", error); + console.error('Error reading airport data:', error); res.status(500).json({ - error: "Internal server error", - message: "Error reading airport data", + error: 'Internal server error', + message: 'Error reading airport data', }); } }); // GET: /api/data/airports/:icao/sids -router.get("/airports/:icao/sids", (req, res) => { +router.get('/airports/:icao/sids', (req, res) => { try { if (!fs.existsSync(airportsPath)) { - return res.status(404).json({ error: "Airport data not found" }); + return res.status(404).json({ error: 'Airport data not found' }); } - const data: Airport[] = JSON.parse(fs.readFileSync(airportsPath, "utf8")); + const data: Airport[] = JSON.parse(fs.readFileSync(airportsPath, 'utf8')); const airport = data.find((a: Airport) => a.icao === req.params.icao); if (!airport) { - return res.status(404).json({ error: "Airport not found" }); + return res.status(404).json({ error: 'Airport not found' }); } applyPublicCache(res, { browserMaxAge: DATA_STATIC_BROWSER_SEC, @@ -487,25 +561,25 @@ router.get("/airports/:icao/sids", (req, res) => { }); res.json(airport.sids || []); } catch (error) { - console.error("Error reading airport data:", error); + console.error('Error reading airport data:', error); res.status(500).json({ - error: "Internal server error", - message: "Error reading airport data", + error: 'Internal server error', + message: 'Error reading airport data', }); } }); // GET: /api/data/airports/:icao/stars -router.get("/airports/:icao/stars", (req, res) => { +router.get('/airports/:icao/stars', (req, res) => { try { if (!fs.existsSync(airportsPath)) { - return res.status(404).json({ error: "Airport data not found" }); + return res.status(404).json({ error: 'Airport data not found' }); } - const data: Airport[] = JSON.parse(fs.readFileSync(airportsPath, "utf8")); + const data: Airport[] = JSON.parse(fs.readFileSync(airportsPath, 'utf8')); const airport = data.find((a: Airport) => a.icao === req.params.icao); if (!airport) { - return res.status(404).json({ error: "Airport not found" }); + return res.status(404).json({ error: 'Airport not found' }); } applyPublicCache(res, { browserMaxAge: DATA_STATIC_BROWSER_SEC, @@ -513,17 +587,17 @@ router.get("/airports/:icao/stars", (req, res) => { }); res.json(airport.stars || []); } catch (error) { - console.error("Error reading airport data:", error); + console.error('Error reading airport data:', error); res.status(500).json({ - error: "Internal server error", - message: "Error reading airport data", + error: 'Internal server error', + message: 'Error reading airport data', }); } }); // GET: /api/data/statistics -router.get("/statistics", async (req, res) => { - const cacheKey = "homepage:stats"; +router.get('/statistics', async (req, res) => { + const cacheKey = 'homepage:stats'; try { const cached = await redisConnection.get(cacheKey); @@ -536,7 +610,10 @@ router.get("/statistics", async (req, res) => { } } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to read cache for homepage stats:", error.message); + console.warn( + '[Redis] Failed to read cache for homepage stats:', + error.message + ); } } @@ -544,21 +621,21 @@ router.get("/statistics", async (req, res) => { const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const sessionsCreated = await mainDb - .selectFrom("sessions") - .select(({ fn }) => fn.countAll().as("count")) - .where("created_at", ">=", thirtyDaysAgo) + .selectFrom('sessions') + .select(({ fn }) => fn.countAll().as('count')) + .where('created_at', '>=', thirtyDaysAgo) .executeTakeFirst(); const registeredUsers = await mainDb - .selectFrom("users") - .select(({ fn }) => fn.countAll().as("count")) + .selectFrom('users') + .select(({ fn }) => fn.countAll().as('count')) .executeTakeFirst(); const flightLogsCount = await getFlightLogsCount(); const flightCountRow = await mainDb - .selectFrom("flights") - .select(sql`count(*)`.as("count")) + .selectFrom('flights') + .select(sql`count(*)`.as('count')) .executeTakeFirst(); const flightsLogged = parseInt(flightCountRow?.count as string, 10) || 0; @@ -570,10 +647,18 @@ router.get("/statistics", async (req, res) => { }; try { - await redisConnection.set(cacheKey, JSON.stringify(result), "EX", HOMEPAGE_STATS_REDIS_SEC); + await redisConnection.set( + cacheKey, + JSON.stringify(result), + 'EX', + HOMEPAGE_STATS_REDIS_SEC + ); } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to set cache for homepage stats:", error.message); + console.warn( + '[Redis] Failed to set cache for homepage stats:', + error.message + ); } } @@ -583,40 +668,40 @@ router.get("/statistics", async (req, res) => { }); res.json(result); } catch (error) { - console.error("Error fetching statistics:", error); + console.error('Error fetching statistics:', error); res.status(500).json({ - error: "Internal server error", - message: "Failed to fetch statistics", + error: 'Internal server error', + message: 'Failed to fetch statistics', }); } }); // GET: /api/data/settings -router.get("/settings", async (req, res) => { +router.get('/settings', async (req, res) => { try { const settings = await getTesterSettings(); applyPublicCache(res, { browserMaxAge: 30, edgeMaxAge: 120 }); res.json(settings); } catch (error) { - console.error("Error fetching tester settings:", error); - res.status(500).json({ error: "Failed to fetch tester settings" }); + console.error('Error fetching tester settings:', error); + res.status(500).json({ error: 'Failed to fetch tester settings' }); } }); // GET: /api/data/notifications/active -router.get("/notifications/active", async (req, res) => { +router.get('/notifications/active', async (req, res) => { try { const notifications = await getActiveNotifications(); applyPublicCache(res, { browserMaxAge: 0, edgeMaxAge: 0 }); res.json(notifications); } catch (error) { - console.error("Error fetching active notifications:", error); - res.status(500).json({ error: "Failed to fetch active notifications" }); + console.error('Error fetching active notifications:', error); + res.status(500).json({ error: 'Failed to fetch active notifications' }); } }); // GET: /api/data/leaderboard - Fetch top users for each stat (public) -router.get("/leaderboard", async (req, res) => { +router.get('/leaderboard', async (req, res) => { try { const leaderboard: Record< string, @@ -656,23 +741,23 @@ router.get("/leaderboard", async (req, res) => { }); res.json(leaderboard); } catch (error) { - console.error("Error fetching leaderboard:", error); - res.status(500).json({ error: "Failed to fetch leaderboard" }); + console.error('Error fetching leaderboard:', error); + res.status(500).json({ error: 'Failed to fetch leaderboard' }); } }); // GET: /api/data/ranks/:userId - Fetch leaderboard ranks for a specific user -router.get("/ranks/:userId", async (req, res) => { +router.get('/ranks/:userId', async (req, res) => { try { const { userId } = req.params; if (!userId) { - return res.status(400).json({ error: "Missing userId parameter" }); + return res.status(400).json({ error: 'Missing userId parameter' }); } const user = await getUserById(userId); if (!user) { - return res.status(404).json({ error: "User not found" }); + return res.status(404).json({ error: 'User not found' }); } const ranks: Record = {}; @@ -683,21 +768,21 @@ router.get("/ranks/:userId", async (req, res) => { applyPublicCache(res, { browserMaxAge: 60, edgeMaxAge: 300 }); res.json(ranks); } catch (error) { - console.error("Error fetching user ranks:", error); - res.status(500).json({ error: "Failed to fetch user ranks" }); + console.error('Error fetching user ranks:', error); + res.status(500).json({ error: 'Failed to fetch user ranks' }); } }); // GET: /api/data/tester-settings - get tester gate settings -router.get("/tester-settings", async (req, res) => { +router.get('/tester-settings', async (req, res) => { try { - const host = req.get("host") || req.get("x-forwarded-host") || ""; + const host = req.get('host') || req.get('x-forwarded-host') || ''; - if (host === "pfcontrol.com") { + if (host === 'pfcontrol.com') { applyPublicCache(res, { browserMaxAge: 0, edgeMaxAge: 120, - vary: "Host", + vary: 'Host', }); return res.json({ tester_gate_enabled: false }); } @@ -706,24 +791,28 @@ router.get("/tester-settings", async (req, res) => { applyPublicCache(res, { browserMaxAge: 0, edgeMaxAge: 120, - vary: "Host", + vary: 'Host', }); res.json(settings); } catch (error) { - console.error("Error fetching tester settings:", error); - res.status(500).json({ error: "Failed to fetch tester settings" }); + console.error('Error fetching tester settings:', error); + res.status(500).json({ error: 'Failed to fetch tester settings' }); } }); -router.get("/findRoute", async (req, res) => { - const from = typeof req.query.from === "string" ? req.query.from.toUpperCase() : ""; - const to = typeof req.query.to === "string" ? req.query.to.toUpperCase() : ""; +router.get('/findRoute', async (req, res) => { + const from = + typeof req.query.from === 'string' ? req.query.from.toUpperCase() : ''; + const to = typeof req.query.to === 'string' ? req.query.to.toUpperCase() : ''; // Normalize runway: strip trailing letter suffix so "26L" → "26", "08R" → "08" - const runwayRaw = typeof req.query.runway === "string" ? req.query.runway.toUpperCase() : ""; - const runway = runwayRaw.replace(/[A-Z]+$/, ""); + const runwayRaw = + typeof req.query.runway === 'string' ? req.query.runway.toUpperCase() : ''; + const runway = runwayRaw.replace(/[A-Z]+$/, ''); if (!from || !to) { - return res.status(400).json({ error: "Missing required query parameters: from, to" }); + return res + .status(400) + .json({ error: 'Missing required query parameters: from, to' }); } const cacheKey = prefixKey(`routev2:${from}:${to}:${runway}`); @@ -739,13 +828,13 @@ router.get("/findRoute", async (req, res) => { } } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to read cache for route:", error.message); + console.warn('[Redis] Failed to read cache for route:', error.message); } } try { if (!fs.existsSync(waypointsPath)) { - return res.status(404).json({ error: "Waypoint data not found" }); + return res.status(404).json({ error: 'Waypoint data not found' }); } const waypointData = getWaypointData(); @@ -759,8 +848,8 @@ router.get("/findRoute", async (req, res) => { const allPoints = [...waypointData]; // Look up SID and STAR from airport data (runway-agnostic: use first runway key) - const depAirport = airportData.find(a => a.icao === from); - const arrAirport = airportData.find(a => a.icao === to); + const depAirport = airportData.find((a) => a.icao === from); + const arrAirport = airportData.find((a) => a.icao === to); let sid: string | undefined; let star: string | undefined; @@ -768,12 +857,13 @@ router.get("/findRoute", async (req, res) => { if (depAirport?.departures) { // Use the supplied runway if it matches a key, otherwise fall back to the first available - const runwayKey = (runway && depAirport.departures[runway]) - ? runway - : Object.keys(depAirport.departures)[0]; + const runwayKey = + runway && depAirport.departures[runway] + ? runway + : Object.keys(depAirport.departures)[0]; if (runwayKey) { const procedure = depAirport.departures[runwayKey][to]; - if (procedure === "RADAR VECTORS") { + if (procedure === 'RADAR VECTORS') { isRadarVectors = true; } else if (procedure) { sid = procedure; @@ -782,35 +872,52 @@ router.get("/findRoute", async (req, res) => { } if (arrAirport?.arrivals) { - const firstRunway = arrAirport.runways?.[0] ?? Object.keys(arrAirport.arrivals)[0]; + const firstRunway = + arrAirport.runways?.[0] ?? Object.keys(arrAirport.arrivals)[0]; if (firstRunway) { const procedure = arrAirport.arrivals[firstRunway][from]; - if (procedure && procedure !== "RADAR VECTORS") star = procedure; + if (procedure && procedure !== 'RADAR VECTORS') star = procedure; } } // Calculate bearing for odd/even FL recommendation (shared by both paths) - const depPoint = allPoints.find(p => p.name === from && p.type === "AIRPORT"); - const arrPoint = allPoints.find(p => p.name === to && p.type === "AIRPORT"); - let flParity: "ODD" | "EVEN" | undefined; + const depPoint = allPoints.find( + (p) => p.name === from && p.type === 'AIRPORT' + ); + const arrPoint = allPoints.find( + (p) => p.name === to && p.type === 'AIRPORT' + ); + let flParity: 'ODD' | 'EVEN' | undefined; if (depPoint && arrPoint) { const dx = arrPoint.x - depPoint.x; const dy = depPoint.y - arrPoint.y; // y increases southward so invert let bearing = Math.atan2(dx, dy) * (180 / Math.PI); if (bearing < 0) bearing += 360; - flParity = bearing < 180 ? "ODD" : "EVEN"; + flParity = bearing < 180 ? 'ODD' : 'EVEN'; } // Radar vectors: skip pathfinding, return direct route if (isRadarVectors) { const path = [depPoint, arrPoint].filter(Boolean); - const routeData = { path, distance: 0, route: `${from} ${to}`, sid: undefined, star: undefined, flParity }; + const routeData = { + path, + distance: 0, + route: `${from} ${to}`, + sid: undefined, + star: undefined, + flParity, + }; try { - await redisConnection.set(cacheKey, JSON.stringify(routeData), "EX", DATA_STATIC_REDIS_SEC); + await redisConnection.set( + cacheKey, + JSON.stringify(routeData), + 'EX', + DATA_STATIC_REDIS_SEC + ); } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to set cache for route:", error.message); + console.warn('[Redis] Failed to set cache for route:', error.message); } } @@ -822,19 +929,32 @@ router.get("/findRoute", async (req, res) => { } // Extract exit/entry fixes from SID/STAR names (e.g. KATOK2T → KATOK) - const startFix = sid ? extractFixFromProcedure(sid) ?? undefined : undefined; - const endFix = star ? extractFixFromProcedure(star) ?? undefined : undefined; - - const { path, distance, success } = findPath(from, to, allPoints, 35, 7, 2, startFix, endFix); + const startFix = sid + ? (extractFixFromProcedure(sid) ?? undefined) + : undefined; + const endFix = star + ? (extractFixFromProcedure(star) ?? undefined) + : undefined; + + const { path, distance, success } = findPath( + from, + to, + allPoints, + 35, + 7, + 2, + startFix, + endFix + ); if (!success) { - return res.status(404).json({ error: "Route not found" }); + return res.status(404).json({ error: 'Route not found' }); } // Build formatted route string: DEP SID ...waypoints... STAR ARR const midWaypoints = path - .filter(p => p.type !== "AIRPORT") - .map(p => p.name); + .filter((p) => p.type !== 'AIRPORT') + .map((p) => p.name); const routeParts: string[] = [from]; if (sid) routeParts.push(sid); @@ -842,15 +962,20 @@ router.get("/findRoute", async (req, res) => { if (star) routeParts.push(star); routeParts.push(to); - const route = routeParts.join(" "); + const route = routeParts.join(' '); const routeData = { path, distance, route, sid, star, flParity }; try { - await redisConnection.set(cacheKey, JSON.stringify(routeData), "EX", DATA_STATIC_REDIS_SEC); + await redisConnection.set( + cacheKey, + JSON.stringify(routeData), + 'EX', + DATA_STATIC_REDIS_SEC + ); } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to set cache for route:", error.message); + console.warn('[Redis] Failed to set cache for route:', error.message); } } @@ -860,40 +985,45 @@ router.get("/findRoute", async (req, res) => { }); res.json(routeData); } catch (error) { - console.error("Error finding route:", error); + console.error('Error finding route:', error); res.status(500).json({ - error: "Internal server error", - message: "Error finding route", + error: 'Internal server error', + message: 'Error finding route', }); } }); // GET: /api/data/airports/:icao/status - Get airport status with active controller, flights, runway, and METAR // Query param: ?network=pfatc (default) | ?network=aatc -router.get("/airports/:icao/status", async (req, res) => { +router.get('/airports/:icao/status', async (req, res) => { try { const icao = req.params.icao.toUpperCase(); - const network = typeof req.query.network === "string" ? req.query.network.toLowerCase() : "pfatc"; - - if (network !== "pfatc" && network !== "aatc") { - return res.status(400).json({ error: "Invalid network. Must be 'pfatc' or 'aatc'." }); + const network = + typeof req.query.network === 'string' + ? req.query.network.toLowerCase() + : 'pfatc'; + + if (network !== 'pfatc' && network !== 'aatc') { + return res + .status(400) + .json({ error: "Invalid network. Must be 'pfatc' or 'aatc'." }); } - const networkLabel = network === "aatc" ? "Advanced ATC" : "PFATC"; - const networkCol = network === "aatc" ? "is_advanced_atc" : "is_pfatc"; + const networkLabel = network === 'aatc' ? 'Advanced ATC' : 'PFATC'; + const networkCol = network === 'aatc' ? 'is_advanced_atc' : 'is_pfatc'; const sessions = await mainDb - .selectFrom("sessions") - .select(["session_id", "created_by", "active_runway", "created_at"]) - .where("airport_icao", "=", icao) - .where(networkCol, "=", true) - .orderBy("created_at", "desc") + .selectFrom('sessions') + .select(['session_id', 'created_by', 'active_runway', 'created_at']) + .where('airport_icao', '=', icao) + .where(networkCol, '=', true) + .orderBy('created_at', 'desc') .limit(10) .execute(); if (sessions.length === 0) { return res.status(404).json({ - error: "No network session found", + error: 'No network session found', message: `No ${networkLabel} controller is currently online at ${icao}`, }); } @@ -904,9 +1034,9 @@ router.get("/airports/:icao/status", async (req, res) => { for (const session of sessions) { const sessionController = await mainDb - .selectFrom("users") - .select(["id", "username", "avatar"]) - .where("id", "=", session.created_by) + .selectFrom('users') + .select(['id', 'username', 'avatar']) + .where('id', '=', session.created_by) .executeTakeFirst(); if (!sessionController) { @@ -916,9 +1046,9 @@ router.get("/airports/:icao/status", async (req, res) => { let sessionFlightCount = 0; try { const result = await mainDb - .selectFrom("flights") - .select(sql`count(*)`.as("count")) - .where("session_id", "=", session.session_id) + .selectFrom('flights') + .select(sql`count(*)`.as('count')) + .where('session_id', '=', session.session_id) .executeTakeFirst(); sessionFlightCount = parseInt(result?.count as string, 10) || 0; } catch { @@ -935,7 +1065,7 @@ router.get("/airports/:icao/status", async (req, res) => { if (!validSession || !controller) { return res.status(404).json({ - error: "No active network session found", + error: 'No active network session found', message: `No ${networkLabel} controller with active flights is currently online at ${icao}`, }); } @@ -963,12 +1093,12 @@ router.get("/airports/:icao/status", async (req, res) => { metar, }); } catch (error) { - console.error("Error fetching airport status:", error); + console.error('Error fetching airport status:', error); res.status(500).json({ - error: "Internal server error", - message: "Failed to fetch airport status", + error: 'Internal server error', + message: 'Failed to fetch airport status', }); } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/developer.ts b/server/routes/developer.ts index d79c5ede..f6197b82 100644 --- a/server/routes/developer.ts +++ b/server/routes/developer.ts @@ -1,16 +1,16 @@ -import express from "express"; -import requireAuth from "../middleware/auth.js"; -import { verifyDeveloperNotificationUnsubscribeToken } from "../developer/developerNotificationUnsubscribeToken.js"; +import express from 'express'; +import requireAuth from '../middleware/auth.js'; +import { verifyDeveloperNotificationUnsubscribeToken } from '../developer/developerNotificationUnsubscribeToken.js'; import { buildNewDeveloperKeyCredentials, newPendingDeveloperKeyPrefix, -} from "../developer/apiKeySecret.js"; -import { buildDeveloperApiPublicSpec } from "../developer/apiDocumentation.js"; +} from '../developer/apiKeySecret.js'; +import { buildDeveloperApiPublicSpec } from '../developer/apiDocumentation.js'; import { DEVELOPER_SCOPE_CATALOG, isScopeSubset, isValidScopeList, -} from "../developer/scopeRegistry.js"; +} from '../developer/scopeRegistry.js'; import { createDeveloperApiKey, createDeveloperApplication, @@ -23,60 +23,60 @@ import { listDeveloperKeysForUser, revokeDeveloperApiKey, rotateDeveloperApiKey, -} from "../db/developer.js"; +} from '../db/developer.js'; import { getDeveloperRecentUsage, getDeveloperUsageByScope, getDeveloperUsageDailyCounts, getDeveloperUsageHourlyCounts, -} from "../db/developerDashboard.js"; -import { getDeveloperApiDefaultRateLimitPerMinute } from "../middleware/developerExtApi.js"; -import { mainDb } from "../db/connection.js"; +} from '../db/developerDashboard.js'; +import { getDeveloperApiDefaultRateLimitPerMinute } from '../middleware/developerExtApi.js'; +import { mainDb } from '../db/connection.js'; const router = express.Router(); -router.get("/docs", (_req, res) => { - res.setHeader("Cache-Control", "public, max-age=300"); +router.get('/docs', (_req, res) => { + res.setHeader('Cache-Control', 'public, max-age=300'); res.json(buildDeveloperApiPublicSpec()); }); -const FRONTEND_BASE = (process.env.FRONTEND_URL ?? "").replace(/\/$/, ""); +const FRONTEND_BASE = (process.env.FRONTEND_URL ?? '').replace(/\/$/, ''); -router.get("/notification-unsubscribe", async (req, res) => { +router.get('/notification-unsubscribe', async (req, res) => { const redirect = (query: string) => { - const base = FRONTEND_BASE || ""; + const base = FRONTEND_BASE || ''; res.redirect(302, `${base}/developers?notifyEmailRemoved=${query}`); }; try { - const token = typeof req.query.token === "string" ? req.query.token : ""; + const token = typeof req.query.token === 'string' ? req.query.token : ''; if (!token) { - redirect("invalid"); + redirect('invalid'); return; } const parsed = verifyDeveloperNotificationUnsubscribeToken(token); if (!parsed) { - redirect("invalid"); + redirect('invalid'); return; } const profile = await getDeveloperProfile(parsed.userId); if (!profile) { - redirect("invalid"); + redirect('invalid'); return; } - const current = (profile.notification_email ?? "").trim().toLowerCase(); + const current = (profile.notification_email ?? '').trim().toLowerCase(); if (!current || current !== parsed.email) { - redirect("stale"); + redirect('stale'); return; } const row = await updateDeveloperNotificationEmail(parsed.userId, null); if (!row) { - redirect("invalid"); + redirect('invalid'); return; } - redirect("1"); + redirect('1'); } catch (e) { - console.error("[developer/notification-unsubscribe]", e); - redirect("invalid"); + console.error('[developer/notification-unsubscribe]', e); + redirect('invalid'); } }); @@ -94,65 +94,67 @@ function isValidNotificationEmail(s: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t); } -router.get("/catalog", (_req, res) => { +router.get('/catalog', (_req, res) => { res.json({ scopes: DEVELOPER_SCOPE_CATALOG }); }); -router.patch("/profile/notification-email", async (req, res) => { +router.patch('/profile/notification-email', async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const userId = req.user.userId; const profile = await getDeveloperProfile(userId); - if (!profile || profile.status !== "active") { - return res.status(403).json({ error: "Developer access not active" }); + if (!profile || profile.status !== 'active') { + return res.status(403).json({ error: 'Developer access not active' }); } const { email } = req.body ?? {}; let next: string | null; if (email === undefined || email === null) { next = null; - } else if (typeof email !== "string") { - return res.status(400).json({ error: "email must be a string or null" }); + } else if (typeof email !== 'string') { + return res.status(400).json({ error: 'email must be a string or null' }); } else { const t = email.trim(); if (t.length === 0) { next = null; } else if (!isValidNotificationEmail(t)) { - return res.status(400).json({ error: "email invalid" }); + return res.status(400).json({ error: 'email invalid' }); } else { next = t; } } const row = await updateDeveloperNotificationEmail(userId, next); - if (!row) return res.status(404).json({ error: "Developer profile not found" }); + if (!row) + return res.status(404).json({ error: 'Developer profile not found' }); const saved = - typeof row.notification_email === "string" && row.notification_email.trim() + typeof row.notification_email === 'string' && + row.notification_email.trim() ? row.notification_email.trim() : null; res.json({ ok: true, notificationEmail: saved }); } catch (e) { - console.error("[developer/profile notification-email]", e); - res.status(500).json({ error: "Failed to update notification email" }); + console.error('[developer/profile notification-email]', e); + res.status(500).json({ error: 'Failed to update notification email' }); } }); -router.post("/notice/dismiss", async (req, res) => { +router.post('/notice/dismiss', async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const profile = await getDeveloperProfile(req.user.userId); - if (!profile || profile.status !== "active") { - return res.status(403).json({ error: "Developer access not active" }); + if (!profile || profile.status !== 'active') { + return res.status(403).json({ error: 'Developer access not active' }); } await dismissDeveloperAdminNotice(req.user.userId); res.json({ ok: true }); } catch (e) { - console.error("[developer/notice dismiss]", e); - res.status(500).json({ error: "Failed to dismiss notice" }); + console.error('[developer/notice dismiss]', e); + res.status(500).json({ error: 'Failed to dismiss notice' }); } }); -router.get("/application", async (req, res) => { +router.get('/application', async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const userId = req.user.userId; const [profile, latestApplication] = await Promise.all([ getDeveloperProfile(userId), @@ -164,16 +166,22 @@ router.get("/application", async (req, res) => { status: profile.status, approvedScopes: normalizeScopes(profile.approved_scopes), defaultRateLimitPerMinute: (() => { - const raw = (profile as { default_rate_limit_per_minute?: number | null }) - .default_rate_limit_per_minute; - return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? raw : null; + const raw = ( + profile as { default_rate_limit_per_minute?: number | null } + ).default_rate_limit_per_minute; + return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 + ? raw + : null; })(), adminNoticeSeq: Number(profile.admin_notice_seq ?? 0), noticeDismissedSeq: Number(profile.notice_dismissed_seq ?? 0), adminNoticeDetail: - typeof profile.admin_notice_detail === "string" ? profile.admin_notice_detail : null, + typeof profile.admin_notice_detail === 'string' + ? profile.admin_notice_detail + : null, notificationEmail: - typeof profile.notification_email === "string" && profile.notification_email.trim() + typeof profile.notification_email === 'string' && + profile.notification_email.trim() ? profile.notification_email.trim() : null, } @@ -184,7 +192,9 @@ router.get("/application", async (req, res) => { status: latestApplication.status, whoText: latestApplication.who_text, whyText: latestApplication.why_text, - requestedScopes: normalizeScopes(latestApplication.requested_scopes), + requestedScopes: normalizeScopes( + latestApplication.requested_scopes + ), approvedScopes: latestApplication.approved_scopes ? normalizeScopes(latestApplication.approved_scopes) : null, @@ -195,23 +205,23 @@ router.get("/application", async (req, res) => { : null, }); } catch (e) { - console.error("[developer/application GET]", e); - res.status(500).json({ error: "Failed to load application" }); + console.error('[developer/application GET]', e); + res.status(500).json({ error: 'Failed to load application' }); } }); function normalizeScopes(raw: unknown): string[] { if (!Array.isArray(raw)) return []; - return raw.filter((x): x is string => typeof x === "string"); + return raw.filter((x): x is string => typeof x === 'string'); } -router.post("/application", async (req, res) => { +router.post('/application', async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const userId = req.user.userId; const { who, why, requestedScopes } = req.body ?? {}; - if (typeof who !== "string" || typeof why !== "string") { - return res.status(400).json({ error: "who and why must be strings" }); + if (typeof who !== 'string' || typeof why !== 'string') { + return res.status(400).json({ error: 'who and why must be strings' }); } const whoTrim = who.trim(); const whyTrim = why.trim(); @@ -221,32 +231,36 @@ router.post("/application", async (req, res) => { whyTrim.length < WHY_LEN.min || whyTrim.length > WHY_LEN.max ) { - return res.status(400).json({ error: "who / why length out of range" }); + return res.status(400).json({ error: 'who / why length out of range' }); } if (!isValidScopeList(requestedScopes)) { - return res - .status(400) - .json({ error: "requestedScopes must be a non-empty array of valid scope ids" }); + return res.status(400).json({ + error: 'requestedScopes must be a non-empty array of valid scope ids', + }); } const profile = await getDeveloperProfile(userId); - if (profile?.status === "active") { - return res.status(400).json({ error: "You already have an active developer account" }); - } - if (profile?.status === "suspended") { + if (profile?.status === 'active') { return res .status(400) - .json({ error: "Your developer access is suspended. Contact an administrator." }); + .json({ error: 'You already have an active developer account' }); + } + if (profile?.status === 'suspended') { + return res.status(400).json({ + error: 'Your developer access is suspended. Contact an administrator.', + }); } const pending = await mainDb - .selectFrom("developer_applications") - .select("id") - .where("user_id", "=", userId) - .where("status", "=", "pending") + .selectFrom('developer_applications') + .select('id') + .where('user_id', '=', userId) + .where('status', '=', 'pending') .executeTakeFirst(); if (pending) { - return res.status(400).json({ error: "You already have a pending application" }); + return res + .status(400) + .json({ error: 'You already have a pending application' }); } const row = await createDeveloperApplication({ @@ -257,25 +271,27 @@ router.post("/application", async (req, res) => { }); res.status(201).json({ id: row?.id, - status: row?.status ?? "pending", + status: row?.status ?? 'pending', }); } catch (e: unknown) { const err = e as { code?: string }; - if (err.code === "23505") { - return res.status(400).json({ error: "You already have a pending application" }); + if (err.code === '23505') { + return res + .status(400) + .json({ error: 'You already have a pending application' }); } - console.error("[developer/application POST]", e); - res.status(500).json({ error: "Failed to submit application" }); + console.error('[developer/application POST]', e); + res.status(500).json({ error: 'Failed to submit application' }); } }); -router.post("/application/scope-expansion", async (req, res) => { +router.post('/application/scope-expansion', async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const userId = req.user.userId; const { who, why, additionalScopes } = req.body ?? {}; - if (typeof who !== "string" || typeof why !== "string") { - return res.status(400).json({ error: "who and why must be strings" }); + if (typeof who !== 'string' || typeof why !== 'string') { + return res.status(400).json({ error: 'who and why must be strings' }); } const whoTrim = who.trim(); const whyTrim = why.trim(); @@ -285,37 +301,41 @@ router.post("/application/scope-expansion", async (req, res) => { whyTrim.length < WHY_LEN.min || whyTrim.length > WHY_LEN.max ) { - return res.status(400).json({ error: "who / why length out of range" }); + return res.status(400).json({ error: 'who / why length out of range' }); } if (!Array.isArray(additionalScopes)) { - return res.status(400).json({ error: "additionalScopes must be an array" }); + return res + .status(400) + .json({ error: 'additionalScopes must be an array' }); } if (!isValidScopeList(additionalScopes)) { return res.status(400).json({ - error: "additionalScopes must be a non-empty array of valid scope ids", + error: 'additionalScopes must be a non-empty array of valid scope ids', }); } const profile = await getDeveloperProfile(userId); - if (!profile || profile.status !== "active") { - return res.status(403).json({ error: "Developer access not active" }); + if (!profile || profile.status !== 'active') { + return res.status(403).json({ error: 'Developer access not active' }); } const current = normalizeScopes(profile.approved_scopes); const additional = additionalScopes.filter((s) => !current.includes(s)); if (additional.length === 0) { return res.status(400).json({ - error: "Select at least one scope you do not already have approved", + error: 'Select at least one scope you do not already have approved', }); } const pending = await mainDb - .selectFrom("developer_applications") - .select("id") - .where("user_id", "=", userId) - .where("status", "=", "pending") + .selectFrom('developer_applications') + .select('id') + .where('user_id', '=', userId) + .where('status', '=', 'pending') .executeTakeFirst(); if (pending) { - return res.status(400).json({ error: "You already have a pending application" }); + return res + .status(400) + .json({ error: 'You already have a pending application' }); } const requestedUnion = [...new Set([...current, ...additional])]; @@ -327,30 +347,35 @@ router.post("/application/scope-expansion", async (req, res) => { }); res.status(201).json({ id: row?.id, - status: row?.status ?? "pending", + status: row?.status ?? 'pending', }); } catch (e: unknown) { const err = e as { code?: string }; - if (err.code === "23505") { - return res.status(400).json({ error: "You already have a pending application" }); + if (err.code === '23505') { + return res + .status(400) + .json({ error: 'You already have a pending application' }); } - console.error("[developer/application scope-expansion POST]", e); - res.status(500).json({ error: "Failed to submit scope request" }); + console.error('[developer/application scope-expansion POST]', e); + res.status(500).json({ error: 'Failed to submit scope request' }); } }); -router.get("/keys", async (req, res) => { +router.get('/keys', async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const profile = await getDeveloperProfile(req.user.userId); - if (!profile || profile.status !== "active") { - return res.status(403).json({ error: "Developer access not active" }); + if (!profile || profile.status !== 'active') { + return res.status(403).json({ error: 'Developer access not active' }); } const keys = await listDeveloperKeysForUser(req.user.userId); - const profileDefault = (profile as { default_rate_limit_per_minute?: number | null }) - .default_rate_limit_per_minute; + const profileDefault = ( + profile as { default_rate_limit_per_minute?: number | null } + ).default_rate_limit_per_minute; const effectiveDefault = - typeof profileDefault === "number" && Number.isFinite(profileDefault) && profileDefault > 0 + typeof profileDefault === 'number' && + Number.isFinite(profileDefault) && + profileDefault > 0 ? profileDefault : getDeveloperApiDefaultRateLimitPerMinute(); res.json({ @@ -359,9 +384,11 @@ router.get("/keys", async (req, res) => { id: String(k.id), name: k.name, prefix: k.prefix, - status: k.status ?? "active", + status: k.status ?? 'active', scopes: normalizeScopes(k.scopes), - requestedScopes: k.requested_scopes ? normalizeScopes(k.requested_scopes) : [], + requestedScopes: k.requested_scopes + ? normalizeScopes(k.requested_scopes) + : [], rateLimitPerMinute: k.rate_limit_per_minute, reviewedAt: k.reviewed_at, reviewerNote: k.reviewer_note, @@ -371,37 +398,45 @@ router.get("/keys", async (req, res) => { })), }); } catch (e) { - console.error("[developer/keys GET]", e); - res.status(500).json({ error: "Failed to list keys" }); + console.error('[developer/keys GET]', e); + res.status(500).json({ error: 'Failed to list keys' }); } }); -router.post("/keys", async (req, res) => { +router.post('/keys', async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const userId = req.user.userId; const profile = await getDeveloperProfile(userId); - if (!profile || profile.status !== "active") { - return res.status(403).json({ error: "Developer access not active" }); + if (!profile || profile.status !== 'active') { + return res.status(403).json({ error: 'Developer access not active' }); } const approved = normalizeScopes(profile.approved_scopes); const { name, scopes } = req.body ?? {}; - if (typeof name !== "string") { - return res.status(400).json({ error: "name is required" }); + if (typeof name !== 'string') { + return res.status(400).json({ error: 'name is required' }); } const nameTrim = name.trim(); - if (nameTrim.length < KEY_NAME_LEN.min || nameTrim.length > KEY_NAME_LEN.max) { - return res.status(400).json({ error: "invalid name length" }); + if ( + nameTrim.length < KEY_NAME_LEN.min || + nameTrim.length > KEY_NAME_LEN.max + ) { + return res.status(400).json({ error: 'invalid name length' }); } if (!isValidScopeList(scopes)) { - return res.status(400).json({ error: "scopes must be a non-empty array of valid scope ids" }); + return res + .status(400) + .json({ error: 'scopes must be a non-empty array of valid scope ids' }); } if (isScopeSubset(scopes, approved)) { - const profileDefault = (profile as { default_rate_limit_per_minute?: number | null }) - .default_rate_limit_per_minute; + const profileDefault = ( + profile as { default_rate_limit_per_minute?: number | null } + ).default_rate_limit_per_minute; const keyRpm = - typeof profileDefault === "number" && Number.isFinite(profileDefault) && profileDefault > 0 + typeof profileDefault === 'number' && + Number.isFinite(profileDefault) && + profileDefault > 0 ? Math.floor(profileDefault) : null; const { secret, prefix, secretHash } = buildNewDeveloperKeyCredentials(); @@ -414,13 +449,13 @@ router.post("/keys", async (req, res) => { rateLimitPerMinute: keyRpm, }); if (!row) { - return res.status(500).json({ error: "Failed to create key" }); + return res.status(500).json({ error: 'Failed to create key' }); } res.status(201).json({ id: String(row.id), name: row.name, prefix: row.prefix, - status: "active", + status: 'active', scopes: normalizeScopes(row.scopes), secret, createdAt: row.created_at, @@ -435,34 +470,34 @@ router.post("/keys", async (req, res) => { requestedScopes: scopes, }); if (!row) { - return res.status(500).json({ error: "Failed to submit key request" }); + return res.status(500).json({ error: 'Failed to submit key request' }); } res.status(202).json({ id: String(row.id), name: row.name, prefix: row.prefix, - status: "pending", + status: 'pending', requestedScopes: scopes, message: - "This key requests scopes outside your current allowance. An administrator must approve it before a secret is issued.", + 'This key requests scopes outside your current allowance. An administrator must approve it before a secret is issued.', createdAt: row.created_at, }); } catch (e) { - console.error("[developer/keys POST]", e); - res.status(500).json({ error: "Failed to create key" }); + console.error('[developer/keys POST]', e); + res.status(500).json({ error: 'Failed to create key' }); } }); -router.post("/keys/:id/rotate", async (req, res) => { +router.post('/keys/:id/rotate', async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const userId = req.user.userId; const profile = await getDeveloperProfile(userId); - if (!profile || profile.status !== "active") { - return res.status(403).json({ error: "Developer access not active" }); + if (!profile || profile.status !== 'active') { + return res.status(403).json({ error: 'Developer access not active' }); } const id = req.params.id; - if (!id) return res.status(400).json({ error: "Missing id" }); + if (!id) return res.status(400).json({ error: 'Missing id' }); const { secret, prefix, secretHash } = buildNewDeveloperKeyCredentials(); const row = await rotateDeveloperApiKey({ @@ -472,7 +507,9 @@ router.post("/keys/:id/rotate", async (req, res) => { secretHash, }); if (!row) { - return res.status(404).json({ error: "Key not found or already revoked" }); + return res + .status(404) + .json({ error: 'Key not found or already revoked' }); } res.status(200).json({ id: String(row.id), @@ -483,58 +520,62 @@ router.post("/keys/:id/rotate", async (req, res) => { createdAt: row.created_at, }); } catch (e) { - console.error("[developer/keys rotate]", e); - res.status(500).json({ error: "Failed to rotate key" }); + console.error('[developer/keys rotate]', e); + res.status(500).json({ error: 'Failed to rotate key' }); } }); -router.post("/keys/:id/revoke", async (req, res) => { +router.post('/keys/:id/revoke', async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const profile = await getDeveloperProfile(req.user.userId); - if (!profile || profile.status !== "active") { - return res.status(403).json({ error: "Developer access not active" }); + if (!profile || profile.status !== 'active') { + return res.status(403).json({ error: 'Developer access not active' }); } const id = req.params.id; - if (!id) return res.status(400).json({ error: "Missing id" }); + if (!id) return res.status(400).json({ error: 'Missing id' }); const row = await revokeDeveloperApiKey(id, req.user.userId); if (!row) { - return res.status(404).json({ error: "Key not found or already revoked" }); + return res + .status(404) + .json({ error: 'Key not found or already revoked' }); } res.json({ ok: true, id: String(row.id) }); } catch (e) { - console.error("[developer/keys revoke]", e); - res.status(500).json({ error: "Failed to revoke key" }); + console.error('[developer/keys revoke]', e); + res.status(500).json({ error: 'Failed to revoke key' }); } }); -router.delete("/keys/:id", async (req, res) => { +router.delete('/keys/:id', async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const profile = await getDeveloperProfile(req.user.userId); - if (!profile || profile.status !== "active") { - return res.status(403).json({ error: "Developer access not active" }); + if (!profile || profile.status !== 'active') { + return res.status(403).json({ error: 'Developer access not active' }); } const id = req.params.id; - if (!id) return res.status(400).json({ error: "Missing id" }); + if (!id) return res.status(400).json({ error: 'Missing id' }); const row = await deleteRevokedDeveloperApiKey(id, req.user.userId); if (!row) { - return res.status(404).json({ error: "Key not found or not yet revoked" }); + return res + .status(404) + .json({ error: 'Key not found or not yet revoked' }); } res.json({ ok: true }); } catch (e) { - console.error("[developer/keys DELETE]", e); - res.status(500).json({ error: "Failed to delete key" }); + console.error('[developer/keys DELETE]', e); + res.status(500).json({ error: 'Failed to delete key' }); } }); -router.get("/dashboard/summary", async (req, res) => { +router.get('/dashboard/summary', async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const userId = req.user.userId; const profile = await getDeveloperProfile(userId); - if (!profile || profile.status !== "active") { - return res.status(403).json({ error: "Developer access not active" }); + if (!profile || profile.status !== 'active') { + return res.status(403).json({ error: 'Developer access not active' }); } const hoursParam = req.query.hours; const daysParam = req.query.days; @@ -543,11 +584,11 @@ router.get("/dashboard/summary", async (req, res) => { let byScope: { scope_id: string; count: number }[]; let days: number | undefined; let hours: number | undefined; - let granularity: "day" | "hour" = "day"; + let granularity: 'day' | 'hour' = 'day'; let recent: Awaited>; - if (typeof hoursParam === "string") { + if (typeof hoursParam === 'string') { const h = Math.min(168, Math.max(1, parseInt(hoursParam, 10) || 24)); hours = h; const since = new Date(Date.now() - h * 60 * 60 * 1000); @@ -559,10 +600,10 @@ router.get("/dashboard/summary", async (req, res) => { daily = hourly; byScope = scopeRows; recent = recentRows; - granularity = "hour"; + granularity = 'hour'; } else { const d = - typeof daysParam === "string" + typeof daysParam === 'string' ? Math.min(90, Math.max(1, parseInt(daysParam, 10) || 14)) : 14; days = d; @@ -598,9 +639,9 @@ router.get("/dashboard/summary", async (req, res) => { totalInRange, }); } catch (e) { - console.error("[developer/dashboard]", e); - res.status(500).json({ error: "Failed to load dashboard" }); + console.error('[developer/dashboard]', e); + res.status(500).json({ error: 'Failed to load dashboard' }); } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/ext/developerExtras.ts b/server/routes/ext/developerExtras.ts index 059dc47b..f3bc1a97 100644 --- a/server/routes/ext/developerExtras.ts +++ b/server/routes/ext/developerExtras.ts @@ -1,37 +1,40 @@ -import express from "express"; -import type { Request, Response } from "express"; -import { getControllerRatingStats } from "../../db/ratings.js"; -import { getActiveNotifications } from "../../db/notifications.js"; -import { listDeveloperFlightLogsMetadata } from "../../db/flightLogs.js"; +import express from 'express'; +import type { Request, Response } from 'express'; +import { getControllerRatingStats } from '../../db/ratings.js'; +import { getActiveNotifications } from '../../db/notifications.js'; +import { listDeveloperFlightLogsMetadata } from '../../db/flightLogs.js'; const router = express.Router(); function extCtx(req: Request) { const ext = req.developerExt; - if (!ext) throw new Error("developerExt missing"); + if (!ext) throw new Error('developerExt missing'); return ext; } -router.get("/ratings/controllers/:controllerId/stats", async (req: Request, res: Response) => { - try { - extCtx(req); - const { controllerId } = req.params; - if (!controllerId?.trim()) { - return res.status(400).json({ error: "controllerId required" }); +router.get( + '/ratings/controllers/:controllerId/stats', + async (req: Request, res: Response) => { + try { + extCtx(req); + const { controllerId } = req.params; + if (!controllerId?.trim()) { + return res.status(400).json({ error: 'controllerId required' }); + } + const stats = await getControllerRatingStats(controllerId.trim()); + res.json({ + controllerId: controllerId.trim(), + averageRating: stats.averageRating, + ratingCount: stats.ratingCount, + }); + } catch (e) { + console.error('[ext/ratings stats]', e); + res.status(500).json({ error: 'Failed to load rating stats' }); } - const stats = await getControllerRatingStats(controllerId.trim()); - res.json({ - controllerId: controllerId.trim(), - averageRating: stats.averageRating, - ratingCount: stats.ratingCount, - }); - } catch (e) { - console.error("[ext/ratings stats]", e); - res.status(500).json({ error: "Failed to load rating stats" }); } -}); +); -router.get("/notifications/active", async (req: Request, res: Response) => { +router.get('/notifications/active', async (req: Request, res: Response) => { try { extCtx(req); const notifications = await getActiveNotifications(); @@ -43,26 +46,37 @@ router.get("/notifications/active", async (req: Request, res: Response) => { show: n.show, customColor: n.custom_color, createdAt: n.created_at, - })), + })) ); } catch (e) { - console.error("[ext/notifications]", e); - res.status(500).json({ error: "Failed to load notifications" }); + console.error('[ext/notifications]', e); + res.status(500).json({ error: 'Failed to load notifications' }); } }); -router.get("/flight-logs", async (req: Request, res: Response) => { +router.get('/flight-logs', async (req: Request, res: Response) => { try { const ext = extCtx(req); - const sessionId = typeof req.query.sessionId === "string" ? req.query.sessionId : undefined; - const page = typeof req.query.page === "string" ? parseInt(req.query.page, 10) || 1 : 1; - const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) || 50 : 50; - const data = await listDeveloperFlightLogsMetadata(ext.userId, { sessionId, page, limit }); + const sessionId = + typeof req.query.sessionId === 'string' ? req.query.sessionId : undefined; + const page = + typeof req.query.page === 'string' + ? parseInt(req.query.page, 10) || 1 + : 1; + const limit = + typeof req.query.limit === 'string' + ? parseInt(req.query.limit, 10) || 50 + : 50; + const data = await listDeveloperFlightLogsMetadata(ext.userId, { + sessionId, + page, + limit, + }); res.json(data); } catch (e) { - console.error("[ext/flight-logs]", e); - res.status(500).json({ error: "Failed to load flight logs" }); + console.error('[ext/flight-logs]', e); + res.status(500).json({ error: 'Failed to load flight logs' }); } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/ext/sessionsFlights.ts b/server/routes/ext/sessionsFlights.ts index 368b5120..e7d30e7b 100644 --- a/server/routes/ext/sessionsFlights.ts +++ b/server/routes/ext/sessionsFlights.ts @@ -1,5 +1,5 @@ -import express from "express"; -import type { Request, Response } from "express"; +import express from 'express'; +import type { Request, Response } from 'express'; import { createSession, getSessionById, @@ -8,42 +8,51 @@ import { listPublicNetworkSessionsForDeveloperApi, type DeveloperPublicNetworkKind, type PublicNetworkSessionDeveloperRow, -} from "../../db/sessions.js"; +} from '../../db/sessions.js'; import { addFlight, getFlightById, getFlightsBySessionForDeveloperApi, sanitizeFlightForClient, updateFlight, -} from "../../db/flights.js"; -import { addSessionToUser } from "../../db/users.js"; -import { generateSessionId, generateAccessId } from "../../utils/ids.js"; -import { recordNewFlight, recordNewSession } from "../../db/statistics.js"; -import { getSessionsByUser } from "../../db/sessions.js"; -import { sessionCreationLimiter, flightCreationLimiter } from "../../middleware/rateLimiting.js"; -import { getUserRoles } from "../../db/roles.js"; -import { isAdmin } from "../../middleware/admin.js"; -import { ExclusiveSessionNetworkFlagsError } from "../../utils/sessionNetworkFlags.js"; -import { validateSessionId, validateFlightId, validateCallsign } from "../../utils/validation.js"; -import { broadcastFlightEvent } from "../../websockets/flightsWebsocket.js"; +} from '../../db/flights.js'; +import { addSessionToUser } from '../../db/users.js'; +import { generateSessionId, generateAccessId } from '../../utils/ids.js'; +import { recordNewFlight, recordNewSession } from '../../db/statistics.js'; +import { getSessionsByUser } from '../../db/sessions.js'; +import { + sessionCreationLimiter, + flightCreationLimiter, +} from '../../middleware/rateLimiting.js'; +import { getUserRoles } from '../../db/roles.js'; +import { isAdmin } from '../../middleware/admin.js'; +import { ExclusiveSessionNetworkFlagsError } from '../../utils/sessionNetworkFlags.js'; +import { + validateSessionId, + validateFlightId, + validateCallsign, +} from '../../utils/validation.js'; +import { broadcastFlightEvent } from '../../websockets/flightsWebsocket.js'; const router = express.Router(); /** Express 5 types dynamic segments as `string | string[]`. */ -function routeParamString(v: string | string[] | undefined): string | undefined { +function routeParamString( + v: string | string[] | undefined +): string | undefined { if (v === undefined) return undefined; return Array.isArray(v) ? v[0] : v; } function extCtx(req: Request) { const ext = req.developerExt; - if (!ext) throw new Error("developerExt missing"); + if (!ext) throw new Error('developerExt missing'); return ext; } function publicNetworkSessionJson( row: PublicNetworkSessionDeveloperRow, - kind: DeveloperPublicNetworkKind, + kind: DeveloperPublicNetworkKind ) { return { sessionId: row.session_id, @@ -51,10 +60,12 @@ function publicNetworkSessionJson( activeRunway: row.active_runway, customName: row.custom_name, createdAt: row.created_at ? new Date(row.created_at).toISOString() : null, - refreshedAt: row.refreshed_at ? new Date(row.refreshed_at).toISOString() : null, + refreshedAt: row.refreshed_at + ? new Date(row.refreshed_at).toISOString() + : null, flightCount: row.flight_count, - isPFATC: kind === "pfatc", - isAdvancedATC: kind === "aatc", + isPFATC: kind === 'pfatc', + isAdvancedATC: kind === 'aatc', controller: { id: row.created_by, username: row.username, @@ -70,8 +81,10 @@ function parsePublicDirectoryPagination(req: Request): { } { const pageRaw = Number(req.query.page); const limitRaw = Number(req.query.limit); - const page = Number.isFinite(pageRaw) && pageRaw >= 1 ? Math.floor(pageRaw) : 1; - let limit = Number.isFinite(limitRaw) && limitRaw >= 1 ? Math.floor(limitRaw) : 50; + const page = + Number.isFinite(pageRaw) && pageRaw >= 1 ? Math.floor(pageRaw) : 1; + let limit = + Number.isFinite(limitRaw) && limitRaw >= 1 ? Math.floor(limitRaw) : 50; limit = Math.min(100, Math.max(1, limit)); const offset = (page - 1) * limit; return { page, limit, offset }; @@ -90,7 +103,7 @@ function sessionToDeveloperJson( refreshed_at?: Date | null; developer_api_key_id?: string | null; }, - keyId: string, + keyId: string ) { return { sessionId: row.session_id, @@ -101,329 +114,392 @@ function sessionToDeveloperJson( isPFATC: Boolean(row.is_pfatc), isAdvancedATC: Boolean(row.is_advanced_atc), customName: row.custom_name ?? null, - refreshedAt: row.refreshed_at ? new Date(row.refreshed_at).toISOString() : null, - apiManaged: row.developer_api_key_id != null && String(row.developer_api_key_id) === keyId, + refreshedAt: row.refreshed_at + ? new Date(row.refreshed_at).toISOString() + : null, + apiManaged: + row.developer_api_key_id != null && + String(row.developer_api_key_id) === keyId, }; } -router.get("/network/pfatc", async (req: Request, res: Response) => { +router.get('/network/pfatc', async (req: Request, res: Response) => { try { extCtx(req); const { limit, offset } = parsePublicDirectoryPagination(req); const airport = - typeof req.query.airport === "string" && req.query.airport.trim() + typeof req.query.airport === 'string' && req.query.airport.trim() ? req.query.airport.trim() : null; const rows = await listPublicNetworkSessionsForDeveloperApi({ - kind: "pfatc", + kind: 'pfatc', airportIcao: airport, limit, offset, }); - res.json(rows.map((r) => publicNetworkSessionJson(r, "pfatc"))); + res.json(rows.map((r) => publicNetworkSessionJson(r, 'pfatc'))); } catch (e) { - console.error("[ext/sessions] network pfatc list:", e); - res.status(500).json({ error: "Failed to list PFATC sessions" }); + console.error('[ext/sessions] network pfatc list:', e); + res.status(500).json({ error: 'Failed to list PFATC sessions' }); } }); -router.get("/network/pfatc/:sessionId", async (req: Request, res: Response) => { +router.get('/network/pfatc/:sessionId', async (req: Request, res: Response) => { try { extCtx(req); let sid: string; try { sid = validateSessionId(routeParamString(req.params.sessionId)); } catch { - return res.status(404).json({ error: "Not found" }); + return res.status(404).json({ error: 'Not found' }); } - const row = await getPublicNetworkSessionForDeveloperApi(sid, "pfatc"); - if (!row) return res.status(404).json({ error: "Not found" }); - res.json(publicNetworkSessionJson(row, "pfatc")); + const row = await getPublicNetworkSessionForDeveloperApi(sid, 'pfatc'); + if (!row) return res.status(404).json({ error: 'Not found' }); + res.json(publicNetworkSessionJson(row, 'pfatc')); } catch (e) { - console.error("[ext/sessions] network pfatc get:", e); - res.status(500).json({ error: "Failed to load session" }); + console.error('[ext/sessions] network pfatc get:', e); + res.status(500).json({ error: 'Failed to load session' }); } }); -router.get("/network/aatc", async (req: Request, res: Response) => { +router.get('/network/aatc', async (req: Request, res: Response) => { try { extCtx(req); const { limit, offset } = parsePublicDirectoryPagination(req); const airport = - typeof req.query.airport === "string" && req.query.airport.trim() + typeof req.query.airport === 'string' && req.query.airport.trim() ? req.query.airport.trim() : null; const rows = await listPublicNetworkSessionsForDeveloperApi({ - kind: "aatc", + kind: 'aatc', airportIcao: airport, limit, offset, }); - res.json(rows.map((r) => publicNetworkSessionJson(r, "aatc"))); + res.json(rows.map((r) => publicNetworkSessionJson(r, 'aatc'))); } catch (e) { - console.error("[ext/sessions] network aatc list:", e); - res.status(500).json({ error: "Failed to list AATC sessions" }); + console.error('[ext/sessions] network aatc list:', e); + res.status(500).json({ error: 'Failed to list AATC sessions' }); } }); -router.get("/network/aatc/:sessionId", async (req: Request, res: Response) => { +router.get('/network/aatc/:sessionId', async (req: Request, res: Response) => { try { extCtx(req); let sid: string; try { sid = validateSessionId(routeParamString(req.params.sessionId)); } catch { - return res.status(404).json({ error: "Not found" }); + return res.status(404).json({ error: 'Not found' }); } - const row = await getPublicNetworkSessionForDeveloperApi(sid, "aatc"); - if (!row) return res.status(404).json({ error: "Not found" }); - res.json(publicNetworkSessionJson(row, "aatc")); + const row = await getPublicNetworkSessionForDeveloperApi(sid, 'aatc'); + if (!row) return res.status(404).json({ error: 'Not found' }); + res.json(publicNetworkSessionJson(row, 'aatc')); } catch (e) { - console.error("[ext/sessions] network aatc get:", e); - res.status(500).json({ error: "Failed to load session" }); + console.error('[ext/sessions] network aatc get:', e); + res.status(500).json({ error: 'Failed to load session' }); } }); // 404 for missing or sessions the key owner did not create (no enumeration). -async function loadOwnedSessionOr404(sessionId: string | string[] | undefined, userId: string) { +async function loadOwnedSessionOr404( + sessionId: string | string[] | undefined, + userId: string +) { let sid: string; try { sid = validateSessionId(routeParamString(sessionId)); } catch { - return { ok: false as const, status: 404 as const, body: { error: "Not found" } }; + return { + ok: false as const, + status: 404 as const, + body: { error: 'Not found' }, + }; } const session = await getSessionById(sid); if (!session || session.created_by !== userId) { - return { ok: false as const, status: 404 as const, body: { error: "Not found" } }; + return { + ok: false as const, + status: 404 as const, + body: { error: 'Not found' }, + }; } return { ok: true as const, session }; } -router.get("/", async (req: Request, res: Response) => { +router.get('/', async (req: Request, res: Response) => { try { const ext = extCtx(req); const rows = await listDeveloperSessionSummariesForUser(ext.userId); res.json(rows.map((r) => sessionToDeveloperJson(r, ext.keyId))); } catch (e) { - console.error("[ext/sessions] list:", e); - res.status(500).json({ error: "Failed to list sessions" }); + console.error('[ext/sessions] list:', e); + res.status(500).json({ error: 'Failed to list sessions' }); } }); -router.post("/", sessionCreationLimiter, async (req: Request, res: Response) => { - try { - const ext = extCtx(req); - const { - airportIcao, - isPFATC = false, - isAdvancedATC = false, - activeRunway = null, - } = req.body ?? {}; - if (!airportIcao || typeof airportIcao !== "string") { - return res.status(400).json({ error: "Airport ICAO is required" }); - } +router.post( + '/', + sessionCreationLimiter, + async (req: Request, res: Response) => { + try { + const ext = extCtx(req); + const { + airportIcao, + isPFATC = false, + isAdvancedATC = false, + activeRunway = null, + } = req.body ?? {}; + if (!airportIcao || typeof airportIcao !== 'string') { + return res.status(400).json({ error: 'Airport ICAO is required' }); + } - const pfatc = Boolean(isPFATC); - const advancedAtc = Boolean(isAdvancedATC); - if (pfatc && advancedAtc) { - return res.status(400).json({ - error: "Invalid session type", - message: "Choose either PFATC Network or Advanced ATC Session, not both.", - }); - } + const pfatc = Boolean(isPFATC); + const advancedAtc = Boolean(isAdvancedATC); + if (pfatc && advancedAtc) { + return res.status(400).json({ + error: 'Invalid session type', + message: + 'Choose either PFATC Network or Advanced ATC Session, not both.', + }); + } - const userSessions = await getSessionsByUser(ext.userId); - const userRoles = await getUserRoles(ext.userId); - const isTester = - isAdmin(ext.userId) || - userRoles.some((role) => role.name === "Tester" || role.name === "Event Controller"); - const maxSessions = isTester ? 100 : 50; - if (userSessions.length >= maxSessions) { - return res.status(400).json({ - error: "Session limit reached", - message: `You can only have ${maxSessions} active sessions. Please delete an old session first.`, - }); - } + const userSessions = await getSessionsByUser(ext.userId); + const userRoles = await getUserRoles(ext.userId); + const isTester = + isAdmin(ext.userId) || + userRoles.some( + (role) => role.name === 'Tester' || role.name === 'Event Controller' + ); + const maxSessions = isTester ? 100 : 50; + if (userSessions.length >= maxSessions) { + return res.status(400).json({ + error: 'Session limit reached', + message: `You can only have ${maxSessions} active sessions. Please delete an old session first.`, + }); + } - let sessionId = generateSessionId(); - const accessId = generateAccessId(); - let existingSession = await getSessionById(sessionId); - const MAX_TRIES = 3; - let attempt = 0; - while (existingSession && attempt < MAX_TRIES - 1) { - attempt++; - sessionId = generateSessionId(); - existingSession = await getSessionById(sessionId); - } - if (existingSession) { - return res.status(500).json({ error: "Session ID collision, please try again." }); - } + let sessionId = generateSessionId(); + const accessId = generateAccessId(); + let existingSession = await getSessionById(sessionId); + const MAX_TRIES = 3; + let attempt = 0; + while (existingSession && attempt < MAX_TRIES - 1) { + attempt++; + sessionId = generateSessionId(); + existingSession = await getSessionById(sessionId); + } + if (existingSession) { + return res + .status(500) + .json({ error: 'Session ID collision, please try again.' }); + } - await createSession({ - sessionId, - accessId, - activeRunway: activeRunway ?? undefined, - airportIcao, - createdBy: ext.userId, - isPFATC: pfatc, - isAdvancedATC: advancedAtc, - developerApiKeyId: ext.keyId, - }); - await addSessionToUser(ext.userId, sessionId); - await recordNewSession(); + await createSession({ + sessionId, + accessId, + activeRunway: activeRunway ?? undefined, + airportIcao, + createdBy: ext.userId, + isPFATC: pfatc, + isAdvancedATC: advancedAtc, + developerApiKeyId: ext.keyId, + }); + await addSessionToUser(ext.userId, sessionId); + await recordNewSession(); - const created = await getSessionById(sessionId); - if (!created) { - return res.status(500).json({ error: "Failed to load created session" }); - } + const created = await getSessionById(sessionId); + if (!created) { + return res + .status(500) + .json({ error: 'Failed to load created session' }); + } - res.status(201).json(sessionToDeveloperJson(created, ext.keyId)); - } catch (error) { - if (error instanceof ExclusiveSessionNetworkFlagsError) { - return res.status(400).json({ - error: "Invalid session type", - message: "Choose either PFATC Network or Advanced ATC Session, not both.", - }); + res.status(201).json(sessionToDeveloperJson(created, ext.keyId)); + } catch (error) { + if (error instanceof ExclusiveSessionNetworkFlagsError) { + return res.status(400).json({ + error: 'Invalid session type', + message: + 'Choose either PFATC Network or Advanced ATC Session, not both.', + }); + } + console.error('[ext/sessions] create:', error); + res.status(500).json({ error: 'Failed to create session' }); } - console.error("[ext/sessions] create:", error); - res.status(500).json({ error: "Failed to create session" }); } -}); +); -router.get("/:sessionId", async (req: Request, res: Response) => { +router.get('/:sessionId', async (req: Request, res: Response) => { try { const ext = extCtx(req); - const loaded = await loadOwnedSessionOr404(req.params.sessionId, ext.userId); + const loaded = await loadOwnedSessionOr404( + req.params.sessionId, + ext.userId + ); if (!loaded.ok) return res.status(loaded.status).json(loaded.body); res.json(sessionToDeveloperJson(loaded.session, ext.keyId)); } catch (e) { - console.error("[ext/sessions] get:", e); - res.status(500).json({ error: "Failed to load session" }); + console.error('[ext/sessions] get:', e); + res.status(500).json({ error: 'Failed to load session' }); } }); -router.get("/:sessionId/flights", async (req: Request, res: Response) => { +router.get('/:sessionId/flights', async (req: Request, res: Response) => { try { const ext = extCtx(req); - const loaded = await loadOwnedSessionOr404(req.params.sessionId, ext.userId); + const loaded = await loadOwnedSessionOr404( + req.params.sessionId, + ext.userId + ); if (!loaded.ok) return res.status(loaded.status).json(loaded.body); - const flights = await getFlightsBySessionForDeveloperApi(loaded.session.session_id); + const flights = await getFlightsBySessionForDeveloperApi( + loaded.session.session_id + ); res.json(flights); } catch (e) { - console.error("[ext/sessions] list flights:", e); - res.status(500).json({ error: "Failed to list flights" }); + console.error('[ext/sessions] list flights:', e); + res.status(500).json({ error: 'Failed to list flights' }); } }); -router.get("/:sessionId/flights/:flightId", async (req: Request, res: Response) => { - try { - const ext = extCtx(req); - const loaded = await loadOwnedSessionOr404(req.params.sessionId, ext.userId); - if (!loaded.ok) return res.status(loaded.status).json(loaded.body); - let fid: string; +router.get( + '/:sessionId/flights/:flightId', + async (req: Request, res: Response) => { try { - fid = validateFlightId(routeParamString(req.params.flightId)); - } catch { - return res.status(404).json({ error: "Not found" }); + const ext = extCtx(req); + const loaded = await loadOwnedSessionOr404( + req.params.sessionId, + ext.userId + ); + if (!loaded.ok) return res.status(loaded.status).json(loaded.body); + let fid: string; + try { + fid = validateFlightId(routeParamString(req.params.flightId)); + } catch { + return res.status(404).json({ error: 'Not found' }); + } + const flight = await getFlightById(loaded.session.session_id, fid); + if (!flight) return res.status(404).json({ error: 'Not found' }); + res.json(sanitizeFlightForClient(flight)); + } catch (e) { + console.error('[ext/sessions] get flight:', e); + res.status(500).json({ error: 'Failed to load flight' }); } - const flight = await getFlightById(loaded.session.session_id, fid); - if (!flight) return res.status(404).json({ error: "Not found" }); - res.json(sanitizeFlightForClient(flight)); - } catch (e) { - console.error("[ext/sessions] get flight:", e); - res.status(500).json({ error: "Failed to load flight" }); } -}); +); -router.post("/:sessionId/flights", flightCreationLimiter, async (req: Request, res: Response) => { - try { - const ext = extCtx(req); - const loaded = await loadOwnedSessionOr404(req.params.sessionId, ext.userId); - if (!loaded.ok) return res.status(loaded.status).json(loaded.body); - - if (req.body?.callsign) { - try { - req.body.callsign = validateCallsign(String(req.body.callsign)); - } catch (err) { +router.post( + '/:sessionId/flights', + flightCreationLimiter, + async (req: Request, res: Response) => { + try { + const ext = extCtx(req); + const loaded = await loadOwnedSessionOr404( + req.params.sessionId, + ext.userId + ); + if (!loaded.ok) return res.status(loaded.status).json(loaded.body); + + if (req.body?.callsign) { + try { + req.body.callsign = validateCallsign(String(req.body.callsign)); + } catch (err) { + return res.status(400).json({ + error: err instanceof Error ? err.message : 'Invalid callsign', + }); + } + } + if (req.body?.stand && String(req.body.stand).length > 8) { return res .status(400) - .json({ error: err instanceof Error ? err.message : "Invalid callsign" }); + .json({ error: 'Stand must be 8 characters or less' }); } - } - if (req.body?.stand && String(req.body.stand).length > 8) { - return res.status(400).json({ error: "Stand must be 8 characters or less" }); - } - const flightData = { - ...req.body, - user_id: ext.userId, - ip_address: null, - }; - - const ownerView = await addFlight(loaded.session.session_id, flightData); - await recordNewFlight(); + const flightData = { + ...req.body, + user_id: ext.userId, + ip_address: null, + }; - const inserted = ownerView.id - ? await getFlightById(loaded.session.session_id, ownerView.id) - : null; - const payload = inserted ? sanitizeFlightForClient(inserted) : {}; - broadcastFlightEvent(loaded.session.session_id, "flightAdded", payload); + const ownerView = await addFlight(loaded.session.session_id, flightData); + await recordNewFlight(); - res.status(201).json(payload); - } catch (e) { - console.error("[ext/sessions] add flight:", e); - res.status(500).json({ error: "Failed to add flight" }); - } -}); - -router.put("/:sessionId/flights/:flightId", async (req: Request, res: Response) => { - try { - const ext = extCtx(req); - const loaded = await loadOwnedSessionOr404(req.params.sessionId, ext.userId); - if (!loaded.ok) return res.status(loaded.status).json(loaded.body); + const inserted = ownerView.id + ? await getFlightById(loaded.session.session_id, ownerView.id) + : null; + const payload = inserted ? sanitizeFlightForClient(inserted) : {}; + broadcastFlightEvent(loaded.session.session_id, 'flightAdded', payload); - const session = loaded.session; - if (String(session.developer_api_key_id ?? "") !== ext.keyId) { - return res.status(403).json({ - error: - "Flight updates via the developer API are only allowed for sessions created with this API key.", - }); + res.status(201).json(payload); + } catch (e) { + console.error('[ext/sessions] add flight:', e); + res.status(500).json({ error: 'Failed to add flight' }); } + } +); - let fid: string; +router.put( + '/:sessionId/flights/:flightId', + async (req: Request, res: Response) => { try { - fid = validateFlightId(routeParamString(req.params.flightId)); - } catch { - return res.status(404).json({ error: "Not found" }); - } + const ext = extCtx(req); + const loaded = await loadOwnedSessionOr404( + req.params.sessionId, + ext.userId + ); + if (!loaded.ok) return res.status(loaded.status).json(loaded.body); + + const session = loaded.session; + if (String(session.developer_api_key_id ?? '') !== ext.keyId) { + return res.status(403).json({ + error: + 'Flight updates via the developer API are only allowed for sessions created with this API key.', + }); + } - if (req.body?.callsign) { + let fid: string; try { - req.body.callsign = validateCallsign(String(req.body.callsign)); - } catch (err) { - return res - .status(400) - .json({ error: err instanceof Error ? err.message : "Invalid callsign" }); + fid = validateFlightId(routeParamString(req.params.flightId)); + } catch { + return res.status(404).json({ error: 'Not found' }); } - } - if (req.body?.stand && String(req.body.stand).length > 8) { - return res.status(400).json({ error: "Stand too long" }); - } - const flight = await updateFlight(session.session_id, fid, req.body ?? {}); - broadcastFlightEvent(session.session_id, "flightUpdated", flight); - res.json(flight); - } catch (e) { - const msg = e instanceof Error ? e.message : ""; - if (msg === "Flight not found or update failed" || msg === "Flight not found") { - return res.status(404).json({ error: "Not found" }); - } - if (msg === "No valid fields to update") { - return res.status(400).json({ error: msg }); + if (req.body?.callsign) { + try { + req.body.callsign = validateCallsign(String(req.body.callsign)); + } catch (err) { + return res.status(400).json({ + error: err instanceof Error ? err.message : 'Invalid callsign', + }); + } + } + if (req.body?.stand && String(req.body.stand).length > 8) { + return res.status(400).json({ error: 'Stand too long' }); + } + + const flight = await updateFlight( + session.session_id, + fid, + req.body ?? {} + ); + broadcastFlightEvent(session.session_id, 'flightUpdated', flight); + res.json(flight); + } catch (e) { + const msg = e instanceof Error ? e.message : ''; + if ( + msg === 'Flight not found or update failed' || + msg === 'Flight not found' + ) { + return res.status(404).json({ error: 'Not found' }); + } + if (msg === 'No valid fields to update') { + return res.status(400).json({ error: msg }); + } + console.error('[ext/sessions] update flight:', e); + res.status(500).json({ error: 'Failed to update flight' }); } - console.error("[ext/sessions] update flight:", e); - res.status(500).json({ error: "Failed to update flight" }); } -}); +); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/ext/v1.ts b/server/routes/ext/v1.ts index 5aabd1f0..9b8fe7f7 100644 --- a/server/routes/ext/v1.ts +++ b/server/routes/ext/v1.ts @@ -1,13 +1,13 @@ -import express from "express"; -import dataRouter from "../data.js"; +import express from 'express'; +import dataRouter from '../data.js'; import { developerExtApiAuth, developerExtScopeGuard, developerExtRateLimit, developerExtUsageLifecycle, -} from "../../middleware/developerExtApi.js"; -import sessionsFlightsRouter from "./sessionsFlights.js"; -import developerExtrasRouter from "./developerExtras.js"; +} from '../../middleware/developerExtApi.js'; +import sessionsFlightsRouter from './sessionsFlights.js'; +import developerExtrasRouter from './developerExtras.js'; const router = express.Router(); @@ -16,7 +16,7 @@ router.use(developerExtApiAuth); router.use(developerExtRateLimit); router.use(developerExtScopeGuard); router.use(developerExtrasRouter); -router.use("/data", dataRouter); -router.use("/sessions", sessionsFlightsRouter); +router.use('/data', dataRouter); +router.use('/sessions', sessionsFlightsRouter); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/feedback.ts b/server/routes/feedback.ts index ee4626b6..e7a641b4 100644 --- a/server/routes/feedback.ts +++ b/server/routes/feedback.ts @@ -21,7 +21,11 @@ router.post('/', requireAuth, async (req, res) => { comment: comment?.trim() || undefined, }); - capture(req, { distinctId: req.user!.userId, event: 'feedback_submitted', properties: { rating: Number(rating), has_comment: !!comment?.trim() } }); + capture(req, { + distinctId: req.user!.userId, + event: 'feedback_submitted', + properties: { rating: Number(rating), has_comment: !!comment?.trim() }, + }); res.json(feedback); } catch (error) { diff --git a/server/routes/flights.ts b/server/routes/flights.ts index 4e81f986..f28e6043 100644 --- a/server/routes/flights.ts +++ b/server/routes/flights.ts @@ -1,9 +1,9 @@ -import express from "express"; -import multer from "multer"; -import FormData from "form-data"; -import axios from "axios"; -import requireAuth, { optionalAuth } from "../middleware/auth.js"; -import { requireFlightAccess } from "../middleware/flightAccess.js"; +import express from 'express'; +import multer from 'multer'; +import FormData from 'form-data'; +import axios from 'axios'; +import requireAuth, { optionalAuth } from '../middleware/auth.js'; +import { requireFlightAccess } from '../middleware/flightAccess.js'; import { getFlightsByUser, getFlightByIdForUser, @@ -19,35 +19,35 @@ import { addSnapImage, deleteSnapImage, toggleFeaturedOnProfile, -} from "../db/flights.js"; -import { getAllFlightsForSession } from "../realtime/flightsRead.js"; +} from '../db/flights.js'; +import { getAllFlightsForSession } from '../realtime/flightsRead.js'; import { broadcastFlightEvent, broadcastToArrivalSessions, -} from "../websockets/flightsWebsocket.js"; -import { getSessionById } from "../db/sessions.js"; -import { getNetworkKind } from "../utils/advancedNetworkSession.js"; -import { recordNewFlight } from "../db/statistics.js"; -import { getClientIp } from "../utils/getIpAddress.js"; -import { mainDb, redisConnection } from "../db/connection.js"; -import { keys } from "../realtime/keys.js"; +} from '../websockets/flightsWebsocket.js'; +import { getSessionById } from '../db/sessions.js'; +import { getNetworkKind } from '../utils/advancedNetworkSession.js'; +import { recordNewFlight } from '../db/statistics.js'; +import { getClientIp } from '../utils/getIpAddress.js'; +import { mainDb, redisConnection } from '../db/connection.js'; +import { keys } from '../realtime/keys.js'; import { flightCreationLimiter, acarsValidationLimiter, generalApiLimiter, -} from "../middleware/rateLimiting.js"; -import { validateCallsign } from "../utils/validation.js"; +} from '../middleware/rateLimiting.js'; +import { validateCallsign } from '../utils/validation.js'; const snapUpload = multer({ storage: multer.memoryStorage() }); const CEPHIE_API_KEY = process.env.CEPHIE_API_KEY; -const CEPHIE_API_BASE = "https://api.cephie.app"; +const CEPHIE_API_BASE = 'https://api.cephie.app'; const CEPHIE_UPLOAD_URL = `${CEPHIE_API_BASE}/api/v1/images/upload`; function getCephieIdFromUrl(url: string): string | null { - if (!url || !url.startsWith("https://api.cephie.app/")) return null; + if (!url || !url.startsWith('https://api.cephie.app/')) return null; try { - const parts = new URL(url).pathname.split("/").filter(Boolean); - if (parts[0] === "img" && parts.length === 2) return parts[1]; + const parts = new URL(url).pathname.split('/').filter(Boolean); + if (parts[0] === 'img' && parts.length === 2) return parts[1]; return null; } catch { return null; @@ -87,78 +87,78 @@ const cleanupAcarsTerminals = () => { const acarsCleanupInterval = setInterval(cleanupAcarsTerminals, 5 * 60 * 1000); // Cleanup on shutdown -process.on("SIGTERM", () => { - console.log("[ACARS] Cleaning up..."); +process.on('SIGTERM', () => { + console.log('[ACARS] Cleaning up...'); clearInterval(acarsCleanupInterval); activeAcarsTerminals.clear(); }); // GET: /api/flights/me - get flights submitted by current user -router.get("/me/list", requireAuth, async (req, res) => { +router.get('/me/list', requireAuth, async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const flights = await getFlightsByUser(req.user.userId); res.json(flights); } catch { - res.status(500).json({ error: "Failed to fetch your flights" }); + res.status(500).json({ error: 'Failed to fetch your flights' }); } }); // GET: /api/flights/me/:flightId - get one owned flight -router.get("/me/:flightId", requireAuth, async (req, res) => { +router.get('/me/:flightId', requireAuth, async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const flight = await getFlightByIdForUser( req.user.userId, req.params.flightId ); - if (!flight) return res.status(404).json({ error: "Flight not found" }); + if (!flight) return res.status(404).json({ error: 'Flight not found' }); res.json(flight); } catch { - res.status(500).json({ error: "Failed to fetch flight" }); + res.status(500).json({ error: 'Failed to fetch flight' }); } }); // PATCH: /api/flights/me/:flightId/notes - save personal notes for owned flight -router.patch("/me/:flightId/notes", requireAuth, async (req, res) => { +router.patch('/me/:flightId/notes', requireAuth, async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const { notes } = req.body ?? {}; - if (typeof notes !== "string" || notes.length > 2000) { + if (typeof notes !== 'string' || notes.length > 2000) { return res .status(400) - .json({ error: "Notes must be a string under 2000 characters" }); + .json({ error: 'Notes must be a string under 2000 characters' }); } await updateFlightNotes(req.user.userId, req.params.flightId, notes); res.json({ success: true }); } catch { - res.status(500).json({ error: "Failed to save notes" }); + res.status(500).json({ error: 'Failed to save notes' }); } }); // POST: /api/flights/me/:flightId/snap - upload a Cephie Snap photo for owned flight router.post( - "/me/:flightId/snap", + '/me/:flightId/snap', requireAuth, - snapUpload.single("image"), + snapUpload.single('image'), async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const userId = req.user.userId; const { flightId } = req.params; const file = req.file; - if (!file || !file.mimetype.startsWith("image/")) { - return res.status(400).json({ error: "Invalid or missing image file" }); + if (!file || !file.mimetype.startsWith('image/')) { + return res.status(400).json({ error: 'Invalid or missing image file' }); } const formData = new FormData(); - formData.append("image", file.buffer, file.originalname); + formData.append('image', file.buffer, file.originalname); const uploadResponse = await axios.post(CEPHIE_UPLOAD_URL, formData, { headers: { - "x-user-id": userId, - "x-api-key": CEPHIE_API_KEY, + 'x-user-id': userId, + 'x-api-key': CEPHIE_API_KEY, ...formData.getHeaders(), }, maxBodyLength: Infinity, @@ -168,7 +168,7 @@ router.post( if (!imageUrl) { return res .status(500) - .json({ error: "No URL returned from Cephie upload" }); + .json({ error: 'No URL returned from Cephie upload' }); } const cephieId = getCephieIdFromUrl(imageUrl) ?? imageUrl; @@ -178,8 +178,8 @@ router.post( try { await axios.delete(`${CEPHIE_API_BASE}/api/v1/images/${cephieId}`, { headers: { - "Content-Type": "application/json", - "x-api-key": CEPHIE_API_KEY, + 'Content-Type': 'application/json', + 'x-api-key': CEPHIE_API_KEY, }, data: { userId }, }); @@ -188,7 +188,7 @@ router.post( } return res .status(404) - .json({ error: "Flight not found or not owned by you" }); + .json({ error: 'Flight not found or not owned by you' }); } res.json({ @@ -199,24 +199,24 @@ router.post( } catch (error) { if (axios.isAxiosError(error)) { console.error( - "Cephie Snap upload error:", + 'Cephie Snap upload error:', error.response?.data || error.message ); return res.status(500).json({ - error: "Failed to upload snap", + error: 'Failed to upload snap', details: error.response?.data, }); } - console.error("Snap upload error:", error); - res.status(500).json({ error: "Failed to upload snap" }); + console.error('Snap upload error:', error); + res.status(500).json({ error: 'Failed to upload snap' }); } } ); // DELETE: /api/flights/me/:flightId/snap/:cephieId - delete a Cephie Snap photo -router.delete("/me/:flightId/snap/:cephieId", requireAuth, async (req, res) => { +router.delete('/me/:flightId/snap/:cephieId', requireAuth, async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const userId = req.user.userId; const { flightId, cephieId } = req.params; @@ -224,32 +224,32 @@ router.delete("/me/:flightId/snap/:cephieId", requireAuth, async (req, res) => { if (!result.ok) { return res .status(404) - .json({ error: "Snap not found or not owned by you" }); + .json({ error: 'Snap not found or not owned by you' }); } // Delete from Cephie try { await axios.delete(`${CEPHIE_API_BASE}/api/v1/images/${cephieId}`, { headers: { - "Content-Type": "application/json", - "x-api-key": CEPHIE_API_KEY, + 'Content-Type': 'application/json', + 'x-api-key': CEPHIE_API_KEY, }, data: { userId }, }); } catch (err) { - console.warn("Failed to delete image from Cephie (continuing):", err); + console.warn('Failed to delete image from Cephie (continuing):', err); } res.json({ success: true }); } catch { - res.status(500).json({ error: "Failed to delete snap" }); + res.status(500).json({ error: 'Failed to delete snap' }); } }); // PATCH: /api/flights/me/:flightId/feature - toggle featured on profile -router.patch("/me/:flightId/feature", requireAuth, async (req, res) => { +router.patch('/me/:flightId/feature', requireAuth, async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const result = await toggleFeaturedOnProfile( req.user.userId, req.params.flightId @@ -257,40 +257,40 @@ router.patch("/me/:flightId/feature", requireAuth, async (req, res) => { if (!result.ok) { if (result.atCap) return res.status(409).json({ - error: "You can only feature up to 3 flights on your profile", + error: 'You can only feature up to 3 flights on your profile', }); return res .status(404) - .json({ error: "Flight not found or not owned by you" }); + .json({ error: 'Flight not found or not owned by you' }); } res.json({ featured: result.featured }); } catch { - res.status(500).json({ error: "Failed to toggle featured status" }); + res.status(500).json({ error: 'Failed to toggle featured status' }); } }); // GET: /api/flights/me/:flightId/logs - get owned flight logs -router.get("/me/:flightId/logs", requireAuth, async (req, res) => { +router.get('/me/:flightId/logs', requireAuth, async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const logsData = await getFlightLogsForUser( req.user.userId, req.params.flightId ); res.json(logsData); } catch { - res.status(500).json({ error: "Failed to fetch flight logs" }); + res.status(500).json({ error: 'Failed to fetch flight logs' }); } }); // POST: /api/flights/claim - claim a just-submitted guest flight after login -router.post("/claim", requireAuth, async (req, res) => { +router.post('/claim', requireAuth, async (req, res) => { try { - if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const { sessionId, flightId, acarsToken } = req.body ?? {}; if (!sessionId || !flightId || !acarsToken) { - return res.status(400).json({ error: "Missing required fields" }); + return res.status(400).json({ error: 'Missing required fields' }); } const result = await claimFlightForUser( @@ -301,38 +301,38 @@ router.post("/claim", requireAuth, async (req, res) => { ); if (!result.ok) { - if (result.reason === "not_found") { - return res.status(404).json({ error: "Flight not found" }); + if (result.reason === 'not_found') { + return res.status(404).json({ error: 'Flight not found' }); } - if (result.reason === "invalid_token") { - return res.status(403).json({ error: "Invalid claim token" }); + if (result.reason === 'invalid_token') { + return res.status(403).json({ error: 'Invalid claim token' }); } - if (result.reason === "already_claimed") { - return res.status(409).json({ error: "Flight already claimed" }); + if (result.reason === 'already_claimed') { + return res.status(409).json({ error: 'Flight already claimed' }); } - return res.status(400).json({ error: "Unable to claim flight" }); + return res.status(400).json({ error: 'Unable to claim flight' }); } res.json({ success: true }); } catch { - res.status(500).json({ error: "Failed to claim flight" }); + res.status(500).json({ error: 'Failed to claim flight' }); } }); // GET: /api/flights/:sessionId/:flightId/acars-flight - get specific flight for ACARS token router.get( - "/:sessionId/:flightId/acars-flight", + '/:sessionId/:flightId/acars-flight', acarsValidationLimiter, async (req, res) => { try { const { sessionId, flightId } = req.params; const acarsToken = - typeof req.query.acars_token === "string" + typeof req.query.acars_token === 'string' ? req.query.acars_token : undefined; if (!acarsToken) { - return res.status(400).json({ error: "Missing access token" }); + return res.status(400).json({ error: 'Missing access token' }); } const validation = await validateAcarsAccess( @@ -341,12 +341,12 @@ router.get( acarsToken ); if (!validation.valid) { - return res.status(403).json({ error: "Invalid ACARS token" }); + return res.status(403).json({ error: 'Invalid ACARS token' }); } const flight = await getFlightById(sessionId, flightId); if (!flight) { - return res.status(404).json({ error: "Flight not found" }); + return res.status(404).json({ error: 'Flight not found' }); } const { user_id, ip_address, acars_token, ...sanitizedFlight } = flight; @@ -355,17 +355,17 @@ router.get( void acars_token; res.json(sanitizedFlight); } catch { - res.status(500).json({ error: "Failed to load flight" }); + res.status(500).json({ error: 'Failed to load flight' }); } } ); // GET: /api/flights/public/:flightId - get a publicly shared flight (no auth required) -router.get("/public/:flightId", generalApiLimiter, async (req, res) => { +router.get('/public/:flightId', generalApiLimiter, async (req, res) => { try { const flight = await getPublicFlightById(req.params.flightId); if (!flight) { - return res.status(404).json({ error: "Flight not found" }); + return res.status(404).json({ error: 'Flight not found' }); } const { user_id, ip_address, acars_token, ...sanitizedFlight } = flight; void user_id; @@ -373,23 +373,23 @@ router.get("/public/:flightId", generalApiLimiter, async (req, res) => { void acars_token; res.json(sanitizedFlight); } catch { - res.status(500).json({ error: "Failed to load flight" }); + res.status(500).json({ error: 'Failed to load flight' }); } }); // GET: /api/flights/:sessionId - get all flights for a session -router.get("/:sessionId", requireAuth, async (req, res) => { +router.get('/:sessionId', requireAuth, async (req, res) => { try { const flights = await getAllFlightsForSession(req.params.sessionId); res.json(flights); } catch { - res.status(500).json({ error: "Failed to fetch flights" }); + res.status(500).json({ error: 'Failed to fetch flights' }); } }); // POST: /api/flights/:sessionId - add a flight to a session router.post( - "/:sessionId", + '/:sessionId', flightCreationLimiter, optionalAuth, async (req, res) => { @@ -399,7 +399,7 @@ router.post( req.body.callsign = validateCallsign(req.body.callsign); } catch (err) { return res.status(400).json({ - error: err instanceof Error ? err.message : "Invalid callsign", + error: err instanceof Error ? err.message : 'Invalid callsign', }); } } @@ -417,13 +417,13 @@ router.post( const sanitizedFlight = flight ? Object.fromEntries( Object.entries(flight).filter( - ([k]) => !["acars_token", "user_id", "ip_address"].includes(k) + ([k]) => !['acars_token', 'user_id', 'ip_address'].includes(k) ) ) : {}; broadcastFlightEvent( req.params.sessionId, - "flightAdded", + 'flightAdded', sanitizedFlight ); @@ -433,30 +433,32 @@ router.post( .then((session) => { if (session) { if (session.created_by) { - redisConnection.del(keys.userSessions(session.created_by)).catch(() => {}); + redisConnection + .del(keys.userSessions(session.created_by)) + .catch(() => {}); } broadcastToArrivalSessions( flight, getNetworkKind(session), req.params.sessionId ).catch((err) => { - console.error("Failed to broadcast to arrival sessions:", err); + console.error('Failed to broadcast to arrival sessions:', err); }); } }) .catch((err) => { - console.error("Failed to fetch session for arrival broadcast:", err); + console.error('Failed to fetch session for arrival broadcast:', err); }); } catch (err) { - console.error("Failed to add flight:", err); - res.status(500).json({ error: "Failed to add flight" }); + console.error('Failed to add flight:', err); + res.status(500).json({ error: 'Failed to add flight' }); } } ); // PUT: /api/flights/:sessionId/:flightId - update a flight router.put( - "/:sessionId/:flightId", + '/:sessionId/:flightId', requireAuth, requireFlightAccess, async (req, res) => { @@ -466,13 +468,13 @@ router.put( req.body.callsign = validateCallsign(req.body.callsign); } catch (err) { return res.status(400).json({ - error: err instanceof Error ? err.message : "Invalid callsign", + error: err instanceof Error ? err.message : 'Invalid callsign', }); } } if (req.body.stand && req.body.stand.length > 8) { - return res.status(400).json({ error: "Stand too long" }); + return res.status(400).json({ error: 'Stand too long' }); } const flight = await updateFlight( req.params.sessionId, @@ -480,84 +482,86 @@ router.put( req.body ); if (!flight) { - return res.status(404).json({ error: "Flight not found" }); + return res.status(404).json({ error: 'Flight not found' }); } - broadcastFlightEvent(req.params.sessionId, "flightUpdated", flight); + broadcastFlightEvent(req.params.sessionId, 'flightUpdated', flight); res.json(flight); } catch { - res.status(500).json({ error: "Failed to update flight" }); + res.status(500).json({ error: 'Failed to update flight' }); } } ); // DELETE: /api/flights/:sessionId/:flightId - delete a flight router.delete( - "/:sessionId/:flightId", + '/:sessionId/:flightId', requireAuth, requireFlightAccess, async (req, res) => { try { await deleteFlight(req.params.sessionId, req.params.flightId); - broadcastFlightEvent(req.params.sessionId, "flightDeleted", { + broadcastFlightEvent(req.params.sessionId, 'flightDeleted', { flightId: req.params.flightId, }); getSessionById(req.params.sessionId) .then((session) => { if (session?.created_by) { - redisConnection.del(keys.userSessions(session.created_by)).catch(() => {}); + redisConnection + .del(keys.userSessions(session.created_by)) + .catch(() => {}); } }) .catch(() => {}); res.json({ success: true }); } catch { - res.status(500).json({ error: "Failed to delete flight" }); + res.status(500).json({ error: 'Failed to delete flight' }); } } ); // GET: /api/flights/:sessionId/:flightId/validate-acars router.get( - "/:sessionId/:flightId/validate-acars", + '/:sessionId/:flightId/validate-acars', acarsValidationLimiter, async (req, res) => { try { const { sessionId, flightId } = req.params; const acarsToken = - typeof req.query.acars_token === "string" + typeof req.query.acars_token === 'string' ? req.query.acars_token : undefined; if (!acarsToken) { return res .status(400) - .json({ valid: false, error: "Missing access token" }); + .json({ valid: false, error: 'Missing access token' }); } const result = await validateAcarsAccess(sessionId, flightId, acarsToken); res.json(result); } catch { - res.status(500).json({ valid: false, error: "Validation failed" }); + res.status(500).json({ valid: false, error: 'Validation failed' }); } } ); // POST: /api/flights/acars/active - mark ACARS terminal as active -router.post("/acars/active", acarsValidationLimiter, async (req, res) => { +router.post('/acars/active', acarsValidationLimiter, async (req, res) => { try { const { sessionId, flightId, acarsToken } = req.body; if (!sessionId || !flightId || !acarsToken) { - return res.status(400).json({ error: "Missing required fields" }); + return res.status(400).json({ error: 'Missing required fields' }); } const result = await validateAcarsAccess(sessionId, flightId, acarsToken); if (!result.valid) { - return res.status(403).json({ error: "Invalid ACARS token" }); + return res.status(403).json({ error: 'Invalid ACARS token' }); } const key = `${sessionId}:${flightId}`; @@ -570,24 +574,24 @@ router.post("/acars/active", acarsValidationLimiter, async (req, res) => { res.json({ success: true }); } catch { - res.status(500).json({ error: "Failed to mark ACARS as active" }); + res.status(500).json({ error: 'Failed to mark ACARS as active' }); } }); // DELETE: /api/flights/acars/active/:sessionId/:flightId -router.delete("/acars/active/:sessionId/:flightId", async (req, res) => { +router.delete('/acars/active/:sessionId/:flightId', async (req, res) => { try { const { sessionId, flightId } = req.params; const key = `${sessionId}:${flightId}`; activeAcarsTerminals.delete(key); res.json({ success: true }); } catch { - res.status(500).json({ error: "Failed to mark ACARS as inactive" }); + res.status(500).json({ error: 'Failed to mark ACARS as inactive' }); } }); // GET: /api/flights/acars/active - get all active ACARS terminals -router.get("/acars/active", async (req, res) => { +router.get('/acars/active', async (req, res) => { try { interface ActiveFlight { [key: string]: unknown; @@ -600,10 +604,10 @@ router.get("/acars/active", async (req, res) => { try { const result = await mainDb - .selectFrom("flights") + .selectFrom('flights') .selectAll() - .where("session_id", "=", sessionId) - .where("id", "=", flightId) + .where('session_id', '=', sessionId) + .where('id', '=', flightId) .execute(); if (result.length > 0) { @@ -622,13 +626,13 @@ router.get("/acars/active", async (req, res) => { if (userIds.length > 0) { try { const users = await mainDb - .selectFrom("users") + .selectFrom('users') .select([ - "id", - "username as discord_username", - "avatar as discord_avatar_url", + 'id', + 'username as discord_username', + 'avatar as discord_avatar_url', ]) - .where("id", "in", userIds as string[]) + .where('id', 'in', userIds as string[]) .execute(); users.forEach((user) => { @@ -661,8 +665,8 @@ router.get("/acars/active", async (req, res) => { res.json(enrichedFlights); } catch { - res.status(500).json({ error: "Failed to fetch active ACARS terminals" }); + res.status(500).json({ error: 'Failed to fetch active ACARS terminals' }); } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/index.ts b/server/routes/index.ts index ec8580b9..b231e719 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -40,4 +40,4 @@ router.use('/ext/v1', extV1Router); router.use('/seo', seoRouter); router.use('/og', ogImagesRouter); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/metar.ts b/server/routes/metar.ts index 64b9ef10..49a5a9e1 100644 --- a/server/routes/metar.ts +++ b/server/routes/metar.ts @@ -1,11 +1,11 @@ -import express from "express"; +import express from 'express'; -import { resolveAviationMetar } from "../utils/metarAviationWeather.js"; +import { resolveAviationMetar } from '../utils/metarAviationWeather.js'; const router = express.Router(); // GET: /api/metar/:icao - get METAR data for an airport -router.get("/:icao", async (req, res) => { +router.get('/:icao', async (req, res) => { const { icao } = req.params; const result = await resolveAviationMetar(icao); @@ -24,16 +24,16 @@ router.get("/:icao", async (req, res) => { } if (result.stale) { - res.set("X-Metar-Stale", "1"); + res.set('X-Metar-Stale', '1'); } if (result.cacheHit && !result.stale) { - res.set("X-Metar-Cache", "fresh"); + res.set('X-Metar-Cache', 'fresh'); } if (result.cacheHit && result.stale) { - res.set("X-Metar-Cache", "stale"); + res.set('X-Metar-Cache', 'stale'); } res.json(result.body); }); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/ogImages.ts b/server/routes/ogImages.ts index cc517f31..56cdba49 100644 --- a/server/routes/ogImages.ts +++ b/server/routes/ogImages.ts @@ -97,4 +97,4 @@ router.get('/submit/:sessionId', async (req, res) => { } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/pilot.ts b/server/routes/pilot.ts index 65bf1915..f74c5db9 100644 --- a/server/routes/pilot.ts +++ b/server/routes/pilot.ts @@ -24,4 +24,4 @@ router.get('/:username', async (req, res) => { } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/ratings.ts b/server/routes/ratings.ts index 1fa72781..5594947c 100644 --- a/server/routes/ratings.ts +++ b/server/routes/ratings.ts @@ -22,12 +22,20 @@ router.post('/', requireAuth, generalApiLimiter, async (req, res) => { const pilotId = req.user!.userId; if (pilotId === controllerId) { - return res.status(400).json({ error: 'You cannot rate yourself' }); + return res.status(400).json({ error: 'You cannot rate yourself' }); } await addControllerRating(controllerId, pilotId, Number(rating), flightId); - capture(req, { distinctId: pilotId, event: 'controller_rated', properties: { controller_id: controllerId, rating: Number(rating), flight_id: flightId } }); + capture(req, { + distinctId: pilotId, + event: 'controller_rated', + properties: { + controller_id: controllerId, + rating: Number(rating), + flight_id: flightId, + }, + }); res.json({ success: true }); } catch (error) { diff --git a/server/routes/seo.ts b/server/routes/seo.ts index 8daeb998..42bbca66 100644 --- a/server/routes/seo.ts +++ b/server/routes/seo.ts @@ -19,4 +19,4 @@ router.get('/sitemap-profiles', async (_req, res) => { } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/sessions.ts b/server/routes/sessions.ts index 8cadaf3a..6f3511c9 100644 --- a/server/routes/sessions.ts +++ b/server/routes/sessions.ts @@ -95,26 +95,34 @@ router.post( const userRolesForEvent = await getUserRoles(createdBy); const hasPfatcSector = userRolesForEvent.some((r) => { - const p = typeof r.permissions === 'string' ? JSON.parse(r.permissions) : r.permissions; + const p = + typeof r.permissions === 'string' + ? JSON.parse(r.permissions) + : r.permissions; return p?.pfatc_sector === true; }); const hasAatcSector = userRolesForEvent.some((r) => { - const p = typeof r.permissions === 'string' ? JSON.parse(r.permissions) : r.permissions; + const p = + typeof r.permissions === 'string' + ? JSON.parse(r.permissions) + : r.permissions; return p?.aatc_sector === true; }); if (pfatc && eventModeRow?.pfatc_event_mode && !hasPfatcSector) { return res.status(403).json({ error: 'Event mode active', - message: 'PFATC event mode is active. Only PFATC Event Controllers can create PFATC sessions.', + message: + 'PFATC event mode is active. Only PFATC Event Controllers can create PFATC sessions.', }); } if (advancedAtc && eventModeRow?.aatc_event_mode && !hasAatcSector) { return res.status(403).json({ error: 'Event mode active', - message: 'AATC event mode is active. Only AATC Event Controllers can create Advanced ATC sessions.', + message: + 'AATC event mode is active. Only AATC Event Controllers can create Advanced ATC sessions.', }); } } @@ -125,9 +133,14 @@ router.post( const isTester = isAdmin(createdBy) || userRoles.some( - (role) => role.name === 'Tester' || role.name === 'Event Controller' || + (role) => + role.name === 'Tester' || + role.name === 'Event Controller' || (() => { - const p = typeof role.permissions === 'string' ? JSON.parse(role.permissions) : role.permissions; + const p = + typeof role.permissions === 'string' + ? JSON.parse(role.permissions) + : role.permissions; return p?.pfatc_sector === true || p?.aatc_sector === true; })() ); @@ -240,10 +253,7 @@ router.get('/mine', requireAuth, async (req, res) => { if (sessionIds.length > 0) { const counts = await mainDb .selectFrom('flights') - .select([ - 'session_id', - sql`count(*)::int`.as('count'), - ]) + .select(['session_id', sql`count(*)::int`.as('count')]) .where('session_id', 'in', sessionIds) .groupBy('session_id') .execute(); @@ -266,7 +276,11 @@ router.get('/mine', requireAuth, async (req, res) => { })); try { - await redisConnection.setex(cacheKey, TTL.USER_SESSIONS_SEC, JSON.stringify(result)); + await redisConnection.setex( + cacheKey, + TTL.USER_SESSIONS_SEC, + JSON.stringify(result) + ); } catch { // ignore cache errors } @@ -569,4 +583,4 @@ router.get('/', async (_req, res) => { } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/routes/uploads.ts b/server/routes/uploads.ts index cdf9d20b..8155ad42 100644 --- a/server/routes/uploads.ts +++ b/server/routes/uploads.ts @@ -35,13 +35,16 @@ export async function deleteOldImage(url: string | undefined, userId?: string) { return; } try { - const response = await axios.delete(`${CEPHIE_API_BASE}/api/v1/images/${id}`, { - headers: { - 'Content-Type': 'application/json', - 'x-api-key': CEPHIE_API_KEY, - }, - data: userId != null ? { userId } : {}, - }); + const response = await axios.delete( + `${CEPHIE_API_BASE}/api/v1/images/${id}`, + { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': CEPHIE_API_KEY, + }, + data: userId != null ? { userId } : {}, + } + ); if (response.status !== 200) { console.error( 'Failed to delete old image:', @@ -231,12 +234,18 @@ router.get( if (axios.isAxiosError(err)) { const status = err.response?.status ?? 500; const data = err.response?.data; - return res.status(status).json( - data && typeof data === 'object' ? data : { error: 'Failed to load Cephie Snap images' } - ); + return res + .status(status) + .json( + data && typeof data === 'object' + ? data + : { error: 'Failed to load Cephie Snap images' } + ); } console.error('Error fetching Cephie Snap images:', err); - return res.status(500).json({ error: 'Failed to load Cephie Snap images' }); + return res + .status(500) + .json({ error: 'Failed to load Cephie Snap images' }); } } ); diff --git a/server/routes/version.ts b/server/routes/version.ts index b4fd9088..3f1d2fcd 100644 --- a/server/routes/version.ts +++ b/server/routes/version.ts @@ -1,19 +1,19 @@ -import express from "express"; -import { getAppVersion } from "../db/version.js"; -import { redisConnection } from "../db/connection.js"; -import { applyPublicCache } from "../utils/httpCache.js"; +import express from 'express'; +import { getAppVersion } from '../db/version.js'; +import { redisConnection } from '../db/connection.js'; +import { applyPublicCache } from '../utils/httpCache.js'; import { APP_VERSION_BROWSER_SEC, APP_VERSION_EDGE_SEC, APP_VERSION_REDIS_SEC, prefixKey, -} from "../utils/cacheTtl.js"; +} from '../utils/cacheTtl.js'; const router = express.Router(); // GET: /api/version - Get app version (cached) -router.get("/", async (req, res) => { - const cacheKey = prefixKey("app:version"); +router.get('/', async (req, res) => { + const cacheKey = prefixKey('app:version'); try { const cached = await redisConnection.get(cacheKey); @@ -26,7 +26,10 @@ router.get("/", async (req, res) => { } } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to read cache for app version:", error.message); + console.warn( + '[Redis] Failed to read cache for app version:', + error.message + ); } } @@ -34,10 +37,18 @@ router.get("/", async (req, res) => { const versionData = await getAppVersion(); try { - await redisConnection.set(cacheKey, JSON.stringify(versionData), "EX", APP_VERSION_REDIS_SEC); + await redisConnection.set( + cacheKey, + JSON.stringify(versionData), + 'EX', + APP_VERSION_REDIS_SEC + ); } catch (error) { if (error instanceof Error) { - console.warn("[Redis] Failed to set cache for app version:", error.message); + console.warn( + '[Redis] Failed to set cache for app version:', + error.message + ); } } @@ -47,9 +58,9 @@ router.get("/", async (req, res) => { }); res.json(versionData); } catch (error) { - console.error("Error fetching app version:", error); - res.status(500).json({ error: "Failed to fetch app version" }); + console.error('Error fetching app version:', error); + res.status(500).json({ error: 'Failed to fetch app version' }); } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/services/publicPilotProfile.ts b/server/services/publicPilotProfile.ts index 23bcd013..136e453f 100644 --- a/server/services/publicPilotProfile.ts +++ b/server/services/publicPilotProfile.ts @@ -138,4 +138,4 @@ export async function getPublicPilotProfile( privacySettings, featuredFlights, }; -} \ No newline at end of file +} diff --git a/server/services/publicSubmitSession.ts b/server/services/publicSubmitSession.ts index c9afc56f..f0d772f4 100644 --- a/server/services/publicSubmitSession.ts +++ b/server/services/publicSubmitSession.ts @@ -52,4 +52,4 @@ export async function getPublicSubmitSession( atisText, controllerUsername: controller?.username ?? undefined, }; -} \ No newline at end of file +} diff --git a/server/types/express.d.ts b/server/types/express.d.ts index 31e987ba..3aa02e16 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts @@ -1,4 +1,4 @@ -import { JwtPayloadClient } from "./JwtPayload.js"; +import { JwtPayloadClient } from './JwtPayload.js'; declare global { namespace Express { @@ -19,4 +19,4 @@ declare global { } } -export {}; \ No newline at end of file +export {}; diff --git a/server/utils/advancedNetworkSession.ts b/server/utils/advancedNetworkSession.ts index 60f3de02..bb85c809 100644 --- a/server/utils/advancedNetworkSession.ts +++ b/server/utils/advancedNetworkSession.ts @@ -1,4 +1,4 @@ -export type NetworkKind = "pfatc" | "advanced_atc"; +export type NetworkKind = 'pfatc' | 'advanced_atc'; type SessionKindShape = { is_pfatc?: boolean | null; @@ -10,13 +10,16 @@ export function isAdvancedNetworkSession(session: SessionKindShape): boolean { } export function getNetworkKind(session: SessionKindShape): NetworkKind | null { - if (session.is_pfatc) return "pfatc"; - if (session.is_advanced_atc) return "advanced_atc"; + if (session.is_pfatc) return 'pfatc'; + if (session.is_advanced_atc) return 'advanced_atc'; return null; } -export function isSameNetwork(a: SessionKindShape, b: SessionKindShape): boolean { +export function isSameNetwork( + a: SessionKindShape, + b: SessionKindShape +): boolean { const kindA = getNetworkKind(a); const kindB = getNetworkKind(b); return kindA !== null && kindA === kindB; -} \ No newline at end of file +} diff --git a/server/utils/cacheTtl.ts b/server/utils/cacheTtl.ts index 10a80b16..85087b25 100644 --- a/server/utils/cacheTtl.ts +++ b/server/utils/cacheTtl.ts @@ -1,4 +1,4 @@ -export const DEPLOYMENT = process.env.DEPLOYMENT ?? "development"; +export const DEPLOYMENT = process.env.DEPLOYMENT ?? 'development'; export const prefixKey = (key: string) => `${DEPLOYMENT}:${key}`; @@ -19,4 +19,4 @@ export const UPDATE_MODAL_BROWSER_SEC = 0; export const UPDATE_MODAL_EDGE_SEC = 60; export const LEADERBOARD_BROWSER_SEC = 120; -export const LEADERBOARD_EDGE_SEC = 5 * 60; \ No newline at end of file +export const LEADERBOARD_EDGE_SEC = 5 * 60; diff --git a/server/utils/detectVPN.ts b/server/utils/detectVPN.ts index 20beb90f..e8409259 100644 --- a/server/utils/detectVPN.ts +++ b/server/utils/detectVPN.ts @@ -61,9 +61,14 @@ function expandIPv6(ip: string): string { const left = halves[0] ? halves[0].split(':') : []; const right = halves[1] ? halves[1].split(':') : []; const fill = Array(8 - left.length - right.length).fill('0000'); - return [...left, ...fill, ...right].map((g) => g.padStart(4, '0')).join(':'); + return [...left, ...fill, ...right] + .map((g) => g.padStart(4, '0')) + .join(':'); } - return ip.split(':').map((g) => g.padStart(4, '0')).join(':'); + return ip + .split(':') + .map((g) => g.padStart(4, '0')) + .join(':'); } /** @@ -160,7 +165,11 @@ export async function isIpVpn(ip: string): Promise { const promise = queryProxycheck(ip) .then(async (isVpn) => { try { - await redisConnection.setex(cacheKey, VPN_IP_CACHE_TTL, isVpn ? '1' : '0'); + await redisConnection.setex( + cacheKey, + VPN_IP_CACHE_TTL, + isVpn ? '1' : '0' + ); } catch { // Redis unavailable — skip caching } diff --git a/server/utils/encryption.ts b/server/utils/encryption.ts index bcac00af..de38cbbb 100644 --- a/server/utils/encryption.ts +++ b/server/utils/encryption.ts @@ -22,7 +22,10 @@ if (!IP_HASH_SECRET) { const key = Buffer.from(ENCRYPTION_KEY, 'utf8').subarray(0, 32); export function hashIp(plaintextIp: string): string { - return crypto.createHmac('sha256', IP_HASH_SECRET!).update(plaintextIp).digest('hex'); + return crypto + .createHmac('sha256', IP_HASH_SECRET!) + .update(plaintextIp) + .digest('hex'); } export function encrypt(text: unknown) { diff --git a/server/utils/findRoute.ts b/server/utils/findRoute.ts index 43962d8c..62039c6c 100644 --- a/server/utils/findRoute.ts +++ b/server/utils/findRoute.ts @@ -1,4 +1,3 @@ - interface NavPoint { name: string; x: number; @@ -43,7 +42,7 @@ function getNearby( if (p.name !== goal.name) { const atAirport = allPoints.some( - airport => airport.type === 'AIRPORT' && sameSpot(p, airport) + (airport) => airport.type === 'AIRPORT' && sameSpot(p, airport) ); if (atAirport) continue; } @@ -114,8 +113,12 @@ function findPath( startFix?: string, endFix?: string ) { - const startAirport = allPoints.find(p => p.name === startName && p.type === 'AIRPORT'); - const endAirport = allPoints.find(p => p.name === endName && p.type === 'AIRPORT'); + const startAirport = allPoints.find( + (p) => p.name === startName && p.type === 'AIRPORT' + ); + const endAirport = allPoints.find( + (p) => p.name === endName && p.type === 'AIRPORT' + ); if (!startAirport || !endAirport) { return { path: [], distance: 0, success: false }; @@ -123,10 +126,10 @@ function findPath( // Resolve actual start/end nav points for A* (fall back to airport if fix not found) const start = startFix - ? (allPoints.find(p => p.name === startFix) ?? startAirport) + ? (allPoints.find((p) => p.name === startFix) ?? startAirport) : startAirport; const end = endFix - ? (allPoints.find(p => p.name === endFix) ?? endAirport) + ? (allPoints.find((p) => p.name === endFix) ?? endAirport) : endAirport; // When routing fix-to-fix we want the goal for neighbor filtering to be the end fix, @@ -149,7 +152,7 @@ function findPath( const path = buildPath(current); // Relax the waypoint minimum when routing between fixes - const waypointCount = path.filter(p => p.type !== 'AIRPORT').length; + const waypointCount = path.filter((p) => p.type !== 'AIRPORT').length; if (!usingFixes && waypointCount < 2) { continue; } @@ -163,25 +166,28 @@ function findPath( checked.add(current.point.name); - const neighbors = getNearby(current.point, end, allPoints, maxDist, minDist); + const neighbors = getNearby( + current.point, + end, + allPoints, + maxDist, + minDist + ); for (const neighbor of neighbors) { if (checked.has(neighbor.name)) continue; - const newCost = current.cost + getRealisticCost( - current.point, - neighbor, - current.parent, - turnPenalty - ); + const newCost = + current.cost + + getRealisticCost(current.point, neighbor, current.parent, turnPenalty); - const existing = toCheck.find(n => n.point.name === neighbor.name); + const existing = toCheck.find((n) => n.point.name === neighbor.name); if (!existing) { toCheck.push({ point: neighbor, cost: newCost, - parent: current + parent: current, }); } else if (newCost < existing.cost) { existing.cost = newCost; diff --git a/server/utils/getData.ts b/server/utils/getData.ts index 356f9cc3..f33492a4 100644 --- a/server/utils/getData.ts +++ b/server/utils/getData.ts @@ -28,4 +28,4 @@ export function getWaypointData() { throw new Error('Waypoint data not found'); } return JSON.parse(fs.readFileSync(waypointsPath, 'utf8')); -} \ No newline at end of file +} diff --git a/server/utils/getIpAddress.ts b/server/utils/getIpAddress.ts index 9cc409ba..2d02bffa 100644 --- a/server/utils/getIpAddress.ts +++ b/server/utils/getIpAddress.ts @@ -12,7 +12,9 @@ export function getClientIp(req: Request): string { const forwarded = req.headers['x-forwarded-for']; if (forwarded) { - const ip = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0]; + const ip = Array.isArray(forwarded) + ? forwarded[0] + : forwarded.split(',')[0]; if (ip) return ip.trim(); } } diff --git a/server/utils/metarAviationWeather.ts b/server/utils/metarAviationWeather.ts index 56297661..cab66a8e 100644 --- a/server/utils/metarAviationWeather.ts +++ b/server/utils/metarAviationWeather.ts @@ -1,8 +1,8 @@ -import { redisConnection } from "../db/connection.js"; +import { redisConnection } from '../db/connection.js'; const FRESH_CACHE_MS = 3 * 60 * 1000; const STALE_MAX_MS = 45 * 60 * 1000; -const REDIS_METAR_PREFIX = "metar:v1:"; +const REDIS_METAR_PREFIX = 'metar:v1:'; const REDIS_TTL_SEC = Math.ceil(STALE_MAX_MS / 1000); type MetarBody = Record; @@ -44,7 +44,9 @@ function applyWindFallback(metar: MetarBody): MetarBody { }; if ((m.wdir == null || m.wspd == null) && m.rawOb) { - const windMatch = m.rawOb.match(/\b(\d{3})(\d{2,3})(?:G(\d{2,3}))?K(?:T)?\b/); + const windMatch = m.rawOb.match( + /\b(\d{3})(\d{2,3})(?:G(\d{2,3}))?K(?:T)?\b/ + ); if (windMatch) { if (m.wdir == null) m.wdir = parseInt(windMatch[1], 10); if (m.wspd == null) m.wspd = parseInt(windMatch[2], 10); @@ -55,36 +57,56 @@ function applyWindFallback(metar: MetarBody): MetarBody { return m; } -async function getRedisEntry(icaoKey: string): Promise { +async function getRedisEntry( + icaoKey: string +): Promise { try { const raw = await redisConnection.get(redisKey(icaoKey)); if (!raw) return null; const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object" || !("body" in parsed) || !("storedAt" in parsed)) { + if ( + !parsed || + typeof parsed !== 'object' || + !('body' in parsed) || + !('storedAt' in parsed) + ) { return null; } const rec = parsed as { body: MetarBody; storedAt: unknown }; - if (typeof rec.storedAt !== "number" || rec.body == null) return null; + if (typeof rec.storedAt !== 'number' || rec.body == null) return null; return { body: rec.body, storedAt: rec.storedAt }; } catch (e) { - console.warn("[METAR] Redis read failed:", e); + console.warn('[METAR] Redis read failed:', e); return null; } } -async function setRedisEntry(icaoKey: string, body: MetarBody, storedAt: number): Promise { +async function setRedisEntry( + icaoKey: string, + body: MetarBody, + storedAt: number +): Promise { try { const payload: RedisMetarPayload = { body: cloneBody(body), storedAt, }; - await redisConnection.set(redisKey(icaoKey), JSON.stringify(payload), "EX", REDIS_TTL_SEC); + await redisConnection.set( + redisKey(icaoKey), + JSON.stringify(payload), + 'EX', + REDIS_TTL_SEC + ); } catch (e) { - console.warn("[METAR] Redis write failed:", e); + console.warn('[METAR] Redis write failed:', e); } } -async function fetchWithRetry(url: string, maxRetries = 2, timeoutMs = 10000): Promise { +async function fetchWithRetry( + url: string, + maxRetries = 2, + timeoutMs = 10000 +): Promise { let lastError: Error | null = null; for (let attempt = 0; attempt <= maxRetries; attempt++) { @@ -95,8 +117,8 @@ async function fetchWithRetry(url: string, maxRetries = 2, timeoutMs = 10000): P const response = await fetch(url, { signal: controller.signal, headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', }, }); clearTimeout(timeoutId); @@ -108,22 +130,24 @@ async function fetchWithRetry(url: string, maxRetries = 2, timeoutMs = 10000): P lastError = fetchError; if (attempt === maxRetries) { - if (fetchError.name === "AbortError") { + if (fetchError.name === 'AbortError') { throw new Error( - "Request timed out. The weather service is taking too long to respond.", + 'Request timed out. The weather service is taking too long to respond.' ); } throw new Error( - `Failed to connect to weather service after ${maxRetries + 1} attempts: ${fetchError.message}`, + `Failed to connect to weather service after ${maxRetries + 1} attempts: ${fetchError.message}` ); } - await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1))); + await new Promise((resolve) => + setTimeout(resolve, 500 * (attempt + 1)) + ); } } } - throw lastError || new Error("Unknown error during fetch"); + throw lastError || new Error('Unknown error during fetch'); } export type ResolveMetarOk = { @@ -142,7 +166,9 @@ export type ResolveMetarMiss = { export type ResolveMetarResult = ResolveMetarOk | ResolveMetarMiss; -export async function resolveAviationMetar(icao: string): Promise { +export async function resolveAviationMetar( + icao: string +): Promise { const key = normalizeIcao(icao); const now = Date.now(); const redisEntry = await getRedisEntry(key); @@ -156,7 +182,9 @@ export async function resolveAviationMetar(icao: string): Promise { + const staleFrom = ( + e: RedisMetarPayload | null + ): ResolveMetarResult | null => { if (!e) return null; const t = Date.now(); if (t - e.storedAt > STALE_MAX_MS) return null; @@ -170,7 +198,7 @@ export async function resolveAviationMetar(icao: string): Promise {}, @@ -16,7 +19,7 @@ const noop = { const client: PostHog = process.env.POSTHOG_API_KEY ? new PostHog(process.env.POSTHOG_API_KEY, { - host: process.env.POSTHOG_HOST || "https://us.i.posthog.com", + host: process.env.POSTHOG_HOST || 'https://us.i.posthog.com', enableExceptionAutocapture: true, }) : noop; @@ -24,10 +27,10 @@ const client: PostHog = process.env.POSTHOG_API_KEY export const posthogServerEnabled = Boolean(process.env.POSTHOG_API_KEY); if (process.env.POSTHOG_API_KEY) { - process.on("SIGINT", async () => { + process.on('SIGINT', async () => { await client.shutdown(); }); - process.on("SIGTERM", async () => { + process.on('SIGTERM', async () => { await client.shutdown(); }); } @@ -39,7 +42,7 @@ interface CaptureParams { } export function capture(req: Request, params: CaptureParams): void { - const sessionId = req.headers["x-posthog-session-id"]; + const sessionId = req.headers['x-posthog-session-id']; const currentUrl = req.headers.referer || req.headers.origin; client.capture({ ...params, @@ -57,17 +60,18 @@ export function capture(req: Request, params: CaptureParams): void { export function captureRequestException( req: Request, error: unknown, - additional?: Record, + additional?: Record ): void { if (!posthogServerEnabled) return; const distinctId = req.user?.userId ?? - (typeof req.headers["x-posthog-distinct-id"] === "string" - ? req.headers["x-posthog-distinct-id"] + (typeof req.headers['x-posthog-distinct-id'] === 'string' + ? req.headers['x-posthog-distinct-id'] : undefined) ?? - "server-anonymous"; - const sessionId = req.headers["x-posthog-session-id"]; - const currentUrl = req.headers.referer || req.headers.origin || req.originalUrl; + 'server-anonymous'; + const sessionId = req.headers['x-posthog-session-id']; + const currentUrl = + req.headers.referer || req.headers.origin || req.originalUrl; client.captureException(error, distinctId, { ...additional, ...(sessionId ? { $session_id: String(sessionId) } : {}), @@ -79,38 +83,38 @@ export function captureRequestException( // --- OpenTelemetry logging to PostHog --- -type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL"; +type LogLevel = 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL'; let _otelLogger: ReturnType | null = null; export function initTelemetry() { if (!process.env.POSTHOG_API_KEY) return; - const host = process.env.POSTHOG_HOST || "https://us.i.posthog.com"; + const host = process.env.POSTHOG_HOST || 'https://us.i.posthog.com'; const loggerProvider = new LoggerProvider({ resource: resourceFromAttributes({ - "service.name": process.env.SERVICE_NAME || "pfcontrol", + 'service.name': process.env.SERVICE_NAME || 'pfcontrol', }), processors: [ new BatchLogRecordProcessor( new OTLPLogExporter({ url: `${host}/i/v1/logs`, headers: { Authorization: `Bearer ${process.env.POSTHOG_API_KEY}` }, - }), + }) ), ], }); logs.setGlobalLoggerProvider(loggerProvider); - process.on("SIGTERM", () => { + process.on('SIGTERM', () => { loggerProvider.shutdown(); }); - process.on("SIGINT", () => { + process.on('SIGINT', () => { loggerProvider.shutdown(); }); - _otelLogger = logs.getLogger("pfcontrol"); + _otelLogger = logs.getLogger('pfcontrol'); } function emitLog(level: LogLevel, message: string, attributes?: AnyValueMap) { @@ -118,12 +122,12 @@ function emitLog(level: LogLevel, message: string, attributes?: AnyValueMap) { } export const logger = { - trace: (msg: string, attrs?: AnyValueMap) => emitLog("TRACE", msg, attrs), - debug: (msg: string, attrs?: AnyValueMap) => emitLog("DEBUG", msg, attrs), - info: (msg: string, attrs?: AnyValueMap) => emitLog("INFO", msg, attrs), - warn: (msg: string, attrs?: AnyValueMap) => emitLog("WARN", msg, attrs), - error: (msg: string, attrs?: AnyValueMap) => emitLog("ERROR", msg, attrs), - fatal: (msg: string, attrs?: AnyValueMap) => emitLog("FATAL", msg, attrs), + trace: (msg: string, attrs?: AnyValueMap) => emitLog('TRACE', msg, attrs), + debug: (msg: string, attrs?: AnyValueMap) => emitLog('DEBUG', msg, attrs), + info: (msg: string, attrs?: AnyValueMap) => emitLog('INFO', msg, attrs), + warn: (msg: string, attrs?: AnyValueMap) => emitLog('WARN', msg, attrs), + error: (msg: string, attrs?: AnyValueMap) => emitLog('ERROR', msg, attrs), + fatal: (msg: string, attrs?: AnyValueMap) => emitLog('FATAL', msg, attrs), }; -export default client; \ No newline at end of file +export default client; diff --git a/server/utils/publicSessionAtis.ts b/server/utils/publicSessionAtis.ts index b3805340..8367dae5 100644 --- a/server/utils/publicSessionAtis.ts +++ b/server/utils/publicSessionAtis.ts @@ -30,4 +30,4 @@ export function parsePublicSessionAtis(atisRaw: unknown): PublicSessionAtis { } catch { return {}; } -} \ No newline at end of file +} diff --git a/server/utils/sessionNetworkFlags.ts b/server/utils/sessionNetworkFlags.ts index f9f7247a..28c387b9 100644 --- a/server/utils/sessionNetworkFlags.ts +++ b/server/utils/sessionNetworkFlags.ts @@ -1,11 +1,14 @@ export class ExclusiveSessionNetworkFlagsError extends Error { constructor() { - super("is_pfatc and is_advanced_atc cannot both be true"); - this.name = "ExclusiveSessionNetworkFlagsError"; + super('is_pfatc and is_advanced_atc cannot both be true'); + this.name = 'ExclusiveSessionNetworkFlagsError'; } } -export function assertExclusiveSessionNetworkFlags(isPfatc: boolean, isAdvancedAtc: boolean): void { +export function assertExclusiveSessionNetworkFlags( + isPfatc: boolean, + isAdvancedAtc: boolean +): void { if (isPfatc && isAdvancedAtc) { throw new ExclusiveSessionNetworkFlagsError(); } @@ -13,11 +16,11 @@ export function assertExclusiveSessionNetworkFlags(isPfatc: boolean, isAdvancedA export function isPostgresCheckViolation(error: unknown): boolean { const walk = (e: unknown): boolean => { - if (e === null || typeof e !== "object") return false; + if (e === null || typeof e !== 'object') return false; const o = e as { code?: string; cause?: unknown }; - if (o.code === "23514") return true; + if (o.code === '23514') return true; if (o.cause !== undefined) return walk(o.cause); return false; }; return walk(error); -} \ No newline at end of file +} diff --git a/server/utils/statisticsCache.ts b/server/utils/statisticsCache.ts index ef935e68..1d620672 100644 --- a/server/utils/statisticsCache.ts +++ b/server/utils/statisticsCache.ts @@ -97,4 +97,4 @@ if (!process.env.VITEST) { await flushStats(); process.exit(); }); -} \ No newline at end of file +} diff --git a/server/websockets/arrivalsWebsocket.ts b/server/websockets/arrivalsWebsocket.ts index 6849acd7..02c31ef8 100644 --- a/server/websockets/arrivalsWebsocket.ts +++ b/server/websockets/arrivalsWebsocket.ts @@ -1,30 +1,30 @@ -import { Server as SocketServer, Socket } from "socket.io"; -import type { Server as HttpServer } from "http"; -import { updateFlight, type ClientFlight } from "../db/flights.js"; -import { validateSessionAccess } from "../middleware/sessionAccess.js"; -import { getSessionById } from "../db/sessions.js"; -import { getFlightsIO } from "../realtime/socketRegistry.js"; +import { Server as SocketServer, Socket } from 'socket.io'; +import type { Server as HttpServer } from 'http'; +import { updateFlight, type ClientFlight } from '../db/flights.js'; +import { validateSessionAccess } from '../middleware/sessionAccess.js'; +import { getSessionById } from '../db/sessions.js'; +import { getFlightsIO } from '../realtime/socketRegistry.js'; import { validateSessionId, validateAccessId, validateFlightId, -} from "../utils/validation.js"; +} from '../utils/validation.js'; import { sanitizeString, sanitizeSquawk, sanitizeFlightLevel, -} from "../utils/sanitization.js"; -import { mainDb } from "../db/connection.js"; -import { createHandshakeRateLimiter } from "./handshakeRateLimit.js"; +} from '../utils/sanitization.js'; +import { mainDb } from '../db/connection.js'; +import { createHandshakeRateLimiter } from './handshakeRateLimit.js'; import { isAdvancedNetworkSession, getNetworkKind, type NetworkKind, -} from "../utils/advancedNetworkSession.js"; -import { getCachedExternalArrivals } from "../realtime/arrivals.js"; -import { getFlightSourceSessionId } from "../realtime/flightsRead.js"; -import { setArrivalsIO as registerArrivalsIO } from "../realtime/socketRegistry.js"; -import { setSessionMetaFromRow } from "../realtime/activeSessions.js"; +} from '../utils/advancedNetworkSession.js'; +import { getCachedExternalArrivals } from '../realtime/arrivals.js'; +import { getFlightSourceSessionId } from '../realtime/flightsRead.js'; +import { setArrivalsIO as registerArrivalsIO } from '../realtime/socketRegistry.js'; +import { setSessionMetaFromRow } from '../realtime/activeSessions.js'; interface ArrivalUpdateData { flightId: string | number; @@ -34,14 +34,14 @@ interface ArrivalUpdateData { let io: SocketServer; export function setupArrivalsWebsocket(httpServer: HttpServer): SocketServer { io = new SocketServer(httpServer, { - path: "/sockets/arrivals", - allowRequest: createHandshakeRateLimiter({ scope: "arrivals" }), + path: '/sockets/arrivals', + allowRequest: createHandshakeRateLimiter({ scope: 'arrivals' }), cors: { origin: [ - "http://localhost:5173", - "http://localhost:9901", - "https://pfcontrol.com", - "https://canary.pfcontrol.com", + 'http://localhost:5173', + 'http://localhost:9901', + 'https://pfcontrol.com', + 'https://canary.pfcontrol.com', ], credentials: true, }, @@ -50,7 +50,7 @@ export function setupArrivalsWebsocket(httpServer: HttpServer): SocketServer { }, }); - io.on("connection", async (socket: Socket) => { + io.on('connection', async (socket: Socket) => { try { const sessionId = validateSessionId( socket.handshake.query.sessionId as string @@ -86,14 +86,14 @@ export function setupArrivalsWebsocket(httpServer: HttpServer): SocketServer { session.airport_icao, networkKind ); - socket.emit("initialExternalArrivals", externalArrivals); + socket.emit('initialExternalArrivals', externalArrivals); } } catch (error) { - console.error("Error fetching external arrivals:", error); + console.error('Error fetching external arrivals:', error); } socket.on( - "updateArrival", + 'updateArrival', async ({ flightId, updates }: ArrivalUpdateData) => { const sessionId = socket.data.sessionId; const session = socket.data.session; @@ -107,21 +107,21 @@ export function setupArrivalsWebsocket(httpServer: HttpServer): SocketServer { ); if (!sourceSessionId) { - socket.emit("arrivalError", { - action: "update", + socket.emit('arrivalError', { + action: 'update', flightId, - error: "Flight not found in any session", + error: 'Flight not found in any session', }); return; } const allowedFields = [ - "clearedfl", - "status", - "star", - "remark", - "squawk", - "gate", + 'clearedfl', + 'status', + 'star', + 'remark', + 'squawk', + 'gate', ]; const filteredUpdates: Record = {}; @@ -132,29 +132,29 @@ export function setupArrivalsWebsocket(httpServer: HttpServer): SocketServer { } if (Object.keys(filteredUpdates).length === 0) { - socket.emit("arrivalError", { - action: "update", + socket.emit('arrivalError', { + action: 'update', flightId, - error: "No valid fields to update", + error: 'No valid fields to update', }); return; } if ( filteredUpdates.clearedfl && - typeof filteredUpdates.clearedfl === "string" + typeof filteredUpdates.clearedfl === 'string' ) filteredUpdates.clearedfl = sanitizeFlightLevel( filteredUpdates.clearedfl ); if ( filteredUpdates.star && - typeof filteredUpdates.star === "string" + typeof filteredUpdates.star === 'string' ) filteredUpdates.star = sanitizeString(filteredUpdates.star, 16); if ( filteredUpdates.remark && - typeof filteredUpdates.remark === "string" + typeof filteredUpdates.remark === 'string' ) filteredUpdates.remark = sanitizeString( filteredUpdates.remark, @@ -162,12 +162,12 @@ export function setupArrivalsWebsocket(httpServer: HttpServer): SocketServer { ); if ( filteredUpdates.squawk && - typeof filteredUpdates.squawk === "string" + typeof filteredUpdates.squawk === 'string' ) filteredUpdates.squawk = sanitizeSquawk(filteredUpdates.squawk); if ( filteredUpdates.gate && - typeof filteredUpdates.gate === "string" + typeof filteredUpdates.gate === 'string' ) filteredUpdates.gate = sanitizeString(filteredUpdates.gate, 8); @@ -182,23 +182,23 @@ export function setupArrivalsWebsocket(httpServer: HttpServer): SocketServer { if (flightsIO) { flightsIO .to(sourceSessionId) - .emit("flightUpdated", updatedFlight); + .emit('flightUpdated', updatedFlight); } - io.to(sessionId).emit("arrivalUpdated", updatedFlight); + io.to(sessionId).emit('arrivalUpdated', updatedFlight); } else { - socket.emit("arrivalError", { - action: "update", + socket.emit('arrivalError', { + action: 'update', flightId, - error: "Flight not found", + error: 'Flight not found', }); } } catch (error) { - console.error("Error updating arrival via websocket:", error); - socket.emit("arrivalError", { - action: "update", + console.error('Error updating arrival via websocket:', error); + socket.emit('arrivalError', { + action: 'update', flightId, - error: "Failed to update arrival", + error: 'Failed to update arrival', }); } } @@ -222,16 +222,16 @@ async function findFlightSourceSession( try { let query = mainDb - .selectFrom("flights as f") - .innerJoin("sessions as s", "s.session_id", "f.session_id") - .select("f.session_id") - .where("f.id", "=", flightId) - .where("s.airport_icao", "!=", arrivalAirport.toUpperCase()); + .selectFrom('flights as f') + .innerJoin('sessions as s', 's.session_id', 'f.session_id') + .select('f.session_id') + .where('f.id', '=', flightId) + .where('s.airport_icao', '!=', arrivalAirport.toUpperCase()); - if (networkKind === "pfatc") { - query = query.where("s.is_pfatc", "=", true); - } else if (networkKind === "advanced_atc") { - query = query.where("s.is_advanced_atc", "=", true); + if (networkKind === 'pfatc') { + query = query.where('s.is_pfatc', '=', true); + } else if (networkKind === 'advanced_atc') { + query = query.where('s.is_advanced_atc', '=', true); } else { return null; } @@ -239,7 +239,7 @@ async function findFlightSourceSession( const row = await query.executeTakeFirst(); return row?.session_id ?? null; } catch (error) { - console.error("Error finding flight source session:", error); + console.error('Error finding flight source session:', error); return null; } } @@ -256,4 +256,4 @@ export function broadcastArrivalEvent( if (io) { io.to(sessionId).emit(event, data); } -} \ No newline at end of file +} diff --git a/server/websockets/chatWebsocket.ts b/server/websockets/chatWebsocket.ts index d0736858..e7e50c31 100644 --- a/server/websockets/chatWebsocket.ts +++ b/server/websockets/chatWebsocket.ts @@ -1,14 +1,14 @@ -import { Server as SocketServer } from "socket.io"; +import { Server as SocketServer } from 'socket.io'; import { addChatMessage, deleteChatMessage, reportChatMessage, -} from "../db/chats.js"; -import { validateSessionAccess } from "../middleware/sessionAccess.js"; -import { validateSessionId, validateAccessId } from "../utils/validation.js"; -import { sanitizeMessage } from "../utils/sanitization.js"; -import type { Server } from "http"; -import { createHandshakeRateLimiter } from "./handshakeRateLimit.js"; +} from '../db/chats.js'; +import { validateSessionAccess } from '../middleware/sessionAccess.js'; +import { validateSessionId, validateAccessId } from '../utils/validation.js'; +import { sanitizeMessage } from '../utils/sanitization.js'; +import type { Server } from 'http'; +import { createHandshakeRateLimiter } from './handshakeRateLimit.js'; const activeChatUsers = new Map>(); let sessionUsersIO: SessionUsersWebsocketIO | null = null; @@ -54,17 +54,17 @@ export function setupChatWebsocket( sessionUsersIO = sessionUsersWebsocketIO; const io = new SocketServer(httpServer, { - path: "/sockets/chat", + path: '/sockets/chat', allowRequest: createHandshakeRateLimiter({ - scope: "chat", + scope: 'chat', maxAttempts: 500, }), cors: { origin: [ - "http://localhost:5173", - "http://localhost:9901", - "https://pfcontrol.com", - "https://canary.pfcontrol.com", + 'http://localhost:5173', + 'http://localhost:9901', + 'https://pfcontrol.com', + 'https://canary.pfcontrol.com', ], credentials: true, }, @@ -73,7 +73,7 @@ export function setupChatWebsocket( }, }); - io.on("connection", async (socket) => { + io.on('connection', async (socket) => { try { const sessionId = validateSessionId( Array.isArray(socket.handshake.query.sessionId) @@ -100,18 +100,18 @@ export function setupChatWebsocket( socket.join(sessionId); - socket.on("typing", ({ username }: { username: string }) => { + socket.on('typing', ({ username }: { username: string }) => { if ( - typeof username !== "string" || + typeof username !== 'string' || username.length === 0 || username.length > 50 ) { return; } - socket.to(sessionId).emit("userTyping", { userId, username }); + socket.to(sessionId).emit('userTyping', { userId, username }); }); - socket.on("chatMessage", async ({ user, message }) => { + socket.on('chatMessage', async ({ user, message }) => { const sessionId = socket.data.sessionId; if (!sessionId || !message || message.length > 500) return; @@ -132,7 +132,7 @@ export function setupChatWebsocket( .map((username) => users.find((u) => u.username === username)?.id) .filter((id): id is string => id !== undefined); } catch (error) { - console.error("Error resolving mentions:", error); + console.error('Error resolving mentions:', error); } } @@ -155,16 +155,16 @@ export function setupChatWebsocket( sent_at: chatMsg.sent_at, }; - io.to(sessionId).emit("chatMessage", formattedMsg); + io.to(sessionId).emit('chatMessage', formattedMsg); const { pushSessionChatMessage } = - await import("../realtime/chatCache.js"); + await import('../realtime/chatCache.js'); if (formattedMsg.id != null && formattedMsg.userId) { void pushSessionChatMessage(sessionId, { id: formattedMsg.id, userId: formattedMsg.userId, - username: formattedMsg.username ?? "", - avatar: formattedMsg.avatar ?? "", + username: formattedMsg.username ?? '', + avatar: formattedMsg.avatar ?? '', message: formattedMsg.message, mentions: formattedMsg.mentions ?? [], sent_at: formattedMsg.sent_at, @@ -172,19 +172,19 @@ export function setupChatWebsocket( } if (chatMsg.automodded && chatMsg.id) { - socket.emit("messageAutomodded", { + socket.emit('messageAutomodded', { messageId: chatMsg.id, - reason: chatMsg.automodReason || "Hate speech detected", + reason: chatMsg.automodReason || 'Hate speech detected', }); try { await reportChatMessage( sessionId, chatMsg.id, - "automod", - chatMsg.automodReason || "Hate speech detected" + 'automod', + chatMsg.automodReason || 'Hate speech detected' ); } catch (error) { - console.error("Error reporting automodded message:", error); + console.error('Error reporting automodded message:', error); } } @@ -193,7 +193,7 @@ export function setupChatWebsocket( mentionedUserIds.length > 0 ) { try { - const messageIdStr = chatMsg.id?.toString() ?? ""; + const messageIdStr = chatMsg.id?.toString() ?? ''; const timestampStr = chatMsg.sent_at ? chatMsg.sent_at.toISOString() : new Date().toISOString(); @@ -208,29 +208,29 @@ export function setupChatWebsocket( }); } } catch (error) { - console.error("Error sending mentions:", error); + console.error('Error sending mentions:', error); } } } catch (error) { - console.error("Error adding chat message:", error); - socket.emit("chatError", { message: "Failed to send message" }); + console.error('Error adding chat message:', error); + socket.emit('chatError', { message: 'Failed to send message' }); } }); - socket.on("deleteMessage", async ({ messageId, userId }) => { + socket.on('deleteMessage', async ({ messageId, userId }) => { const sessionId = socket.data.sessionId; const success = await deleteChatMessage(sessionId, messageId, userId); if (success) { - io.to(sessionId).emit("messageDeleted", { messageId }); + io.to(sessionId).emit('messageDeleted', { messageId }); } else { - socket.emit("deleteError", { + socket.emit('deleteError', { messageId, - error: "Cannot delete this message", + error: 'Cannot delete this message', }); } }); - socket.on("chatOpened", () => { + socket.on('chatOpened', () => { const sessionId = socket.data.sessionId; const userId = socket.data.userId; if (sessionId && userId) { @@ -240,24 +240,24 @@ export function setupChatWebsocket( const userSet = activeChatUsers.get(sessionId); if (userSet) { userSet.add(userId); - io.to(sessionId).emit("activeChatUsers", Array.from(userSet)); + io.to(sessionId).emit('activeChatUsers', Array.from(userSet)); } } }); - socket.on("chatClosed", () => { + socket.on('chatClosed', () => { const sessionId = socket.data.sessionId; const userId = socket.data.userId; if (sessionId && userId && activeChatUsers.has(sessionId)) { const userSet = activeChatUsers.get(sessionId); if (userSet) { userSet.delete(userId); - io.to(sessionId).emit("activeChatUsers", Array.from(userSet)); + io.to(sessionId).emit('activeChatUsers', Array.from(userSet)); } } }); - socket.on("disconnect", () => { + socket.on('disconnect', () => { const sessionId = socket.data.sessionId; const userId = socket.data.userId; if (activeChatUsers.has(sessionId)) { @@ -267,7 +267,7 @@ export function setupChatWebsocket( if (userSet.size === 0) { activeChatUsers.delete(sessionId); } else { - io.to(sessionId).emit("activeChatUsers", Array.from(userSet)); + io.to(sessionId).emit('activeChatUsers', Array.from(userSet)); } } } @@ -278,8 +278,8 @@ export function setupChatWebsocket( }); // Cleanup on shutdown - process.on("SIGTERM", () => { - console.log("[Chat] Cleaning up intervals..."); + process.on('SIGTERM', () => { + console.log('[Chat] Cleaning up intervals...'); clearInterval(chatCleanupInterval); activeChatUsers.clear(); }); @@ -295,4 +295,4 @@ function parseMentions(message: string): string[] { mentions.push(match[1]); } return mentions; -} \ No newline at end of file +} diff --git a/server/websockets/flightsWebsocket.ts b/server/websockets/flightsWebsocket.ts index df2e2e8a..5d1afe76 100644 --- a/server/websockets/flightsWebsocket.ts +++ b/server/websockets/flightsWebsocket.ts @@ -1,36 +1,36 @@ -import jwt from "jsonwebtoken"; -import { Server as SocketIOServer, Socket } from "socket.io"; +import jwt from 'jsonwebtoken'; +import { Server as SocketIOServer, Socket } from 'socket.io'; import { addFlight, updateFlight, deleteFlight, type AddFlightData, type ClientFlight, -} from "../db/flights.js"; -import { validateSessionAccess } from "../middleware/sessionAccess.js"; -import { updateSession } from "../db/sessions.js"; -import { mainDb } from "../db/connection.js"; +} from '../db/flights.js'; +import { validateSessionAccess } from '../middleware/sessionAccess.js'; +import { updateSession } from '../db/sessions.js'; +import { mainDb } from '../db/connection.js'; import { validateSessionId, validateAccessId, validateFlightId, -} from "../utils/validation.js"; +} from '../utils/validation.js'; import { sanitizeCallsign, sanitizeString, sanitizeSquawk, sanitizeFlightLevel, sanitizeRunway, -} from "../utils/sanitization.js"; -import type { Server as HTTPServer } from "http"; -import { incrementStat } from "../utils/statisticsCache.js"; -import { logFlightAction } from "../db/flightLogs.js"; -import { getUserById } from "../db/users.js"; -import { isEventController } from "../middleware/flightAccess.js"; -import { setFlightsIO as registerFlightsIO } from "../realtime/socketRegistry.js"; -import { broadcastArrivalChange } from "../realtime/arrivals.js"; -import { createHandshakeRateLimiter } from "./handshakeRateLimit.js"; -import { getNetworkKind } from "../utils/advancedNetworkSession.js"; +} from '../utils/sanitization.js'; +import type { Server as HTTPServer } from 'http'; +import { incrementStat } from '../utils/statisticsCache.js'; +import { logFlightAction } from '../db/flightLogs.js'; +import { getUserById } from '../db/users.js'; +import { isEventController } from '../middleware/flightAccess.js'; +import { setFlightsIO as registerFlightsIO } from '../realtime/socketRegistry.js'; +import { broadcastArrivalChange } from '../realtime/arrivals.js'; +import { createHandshakeRateLimiter } from './handshakeRateLimit.js'; +import { getNetworkKind } from '../utils/advancedNetworkSession.js'; interface FlightUpdateData { flightId: string | number; @@ -63,37 +63,37 @@ interface SessionUpdateData { let io: SocketIOServer; function getSocketClientIp(socket: Socket): string { - if (socket.handshake.headers["cf-connecting-ip"]) { - const cfIp = socket.handshake.headers["cf-connecting-ip"]; + if (socket.handshake.headers['cf-connecting-ip']) { + const cfIp = socket.handshake.headers['cf-connecting-ip']; return Array.isArray(cfIp) ? cfIp[0] : cfIp; } - if (socket.handshake.headers["x-forwarded-for"]) { - const forwarded = socket.handshake.headers["x-forwarded-for"]; + if (socket.handshake.headers['x-forwarded-for']) { + const forwarded = socket.handshake.headers['x-forwarded-for']; if (Array.isArray(forwarded)) { - return forwarded[0].split(",")[0].trim(); + return forwarded[0].split(',')[0].trim(); } - return (forwarded as string).split(",")[0].trim(); + return (forwarded as string).split(',')[0].trim(); } - if (socket.handshake.headers["x-real-ip"]) { - const realIp = socket.handshake.headers["x-real-ip"]; + if (socket.handshake.headers['x-real-ip']) { + const realIp = socket.handshake.headers['x-real-ip']; return Array.isArray(realIp) ? realIp[0] : realIp; } - return socket.handshake.address || "unknown"; + return socket.handshake.address || 'unknown'; } export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { io = new SocketIOServer(httpServer, { - path: "/sockets/flights", - allowRequest: createHandshakeRateLimiter({ scope: "flights" }), + path: '/sockets/flights', + allowRequest: createHandshakeRateLimiter({ scope: 'flights' }), cors: { origin: [ - "http://localhost:5173", - "http://localhost:9901", - "https://pfcontrol.com", - "https://canary.pfcontrol.com", + 'http://localhost:5173', + 'http://localhost:9901', + 'https://pfcontrol.com', + 'https://canary.pfcontrol.com', ], credentials: true, }, @@ -106,15 +106,15 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { }, }); - io.on("connection", async (socket: Socket) => { + io.on('connection', async (socket: Socket) => { const sessionId = socket.handshake.query.sessionId as string; const accessId = socket.handshake.query.accessId as string; const isEventControllerFlag = - socket.handshake.query.isEventController === "true"; + socket.handshake.query.isEventController === 'true'; let userId = socket.handshake.query.userId as string; try { - const cookieHeader = socket.handshake.headers.cookie ?? ""; + const cookieHeader = socket.handshake.headers.cookie ?? ''; const match = cookieHeader.match(/(?:^|;\s*)auth_token=([^;]+)/); if (match) { const JWT_SECRET = process.env.JWT_SECRET; @@ -131,24 +131,24 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { const validSessionId = validateSessionId(sessionId); socket.data.sessionId = validSessionId; - let role: "pilot" | "controller" = "pilot"; + let role: 'pilot' | 'controller' = 'pilot'; if (isEventControllerFlag && userId) { const hasEventControllerRole = await isEventController(userId); if (hasEventControllerRole) { const session = await mainDb - .selectFrom("sessions") - .select(["is_pfatc", "is_advanced_atc"]) - .where("session_id", "=", validSessionId) + .selectFrom('sessions') + .select(['is_pfatc', 'is_advanced_atc']) + .where('session_id', '=', validSessionId) .executeTakeFirst(); if (session?.is_pfatc || session?.is_advanced_atc) { - role = "controller"; + role = 'controller'; socket.data.isEventController = true; socket.data.networkKind = getNetworkKind(session); } else { console.error( - "Event controller attempted to connect to non-network session:", + 'Event controller attempted to connect to non-network session:', validSessionId ); socket.disconnect(true); @@ -156,7 +156,7 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { } } else { console.error( - "User claimed to be event controller but lacks role:", + 'User claimed to be event controller but lacks role:', userId ); socket.disconnect(true); @@ -172,13 +172,13 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { socket.disconnect(true); return; } - role = "controller"; + role = 'controller'; socket.data.isEventController = false; // Fetch session kind so arrival broadcasts stay within the same network const sessionRow = await mainDb - .selectFrom("sessions") - .select(["is_pfatc", "is_advanced_atc"]) - .where("session_id", "=", validSessionId) + .selectFrom('sessions') + .select(['is_pfatc', 'is_advanced_atc']) + .where('session_id', '=', validSessionId) .executeTakeFirst(); socket.data.networkKind = sessionRow ? getNetworkKind(sessionRow) @@ -192,7 +192,7 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { return; } - socket.on("addFlight", async (flightData: Partial) => { + socket.on('addFlight', async (flightData: Partial) => { const sessionId = socket.data.sessionId; try { const enhancedFlightData = { @@ -206,17 +206,17 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { enhancedFlightData as AddFlightData ); - socket.emit("flightAdded", flight); + socket.emit('flightAdded', flight); const { acars_token: _acars, ...sanitizedFlight } = flight; - socket.to(sessionId).emit("flightAdded", sanitizedFlight); + socket.to(sessionId).emit('flightAdded', sanitizedFlight); setImmediate(() => { void logFlightAction({ - userId: userId || "unknown", - username: (socket.handshake.query.username as string) || "unknown", + userId: userId || 'unknown', + username: (socket.handshake.query.username as string) || 'unknown', sessionId, - action: "add", + action: 'add', flightId: flight.id, newData: { ...sanitizedFlight, @@ -228,79 +228,79 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { }); }); } catch { - socket.emit("flightError", { - action: "add", - error: "Failed to add flight", + socket.emit('flightError', { + action: 'add', + error: 'Failed to add flight', }); } }); socket.on( - "updateFlight", + 'updateFlight', async ({ flightId, updates }: FlightUpdateData) => { const sessionId = socket.data.sessionId; - if (socket.data.role !== "controller") { - socket.emit("flightError", { - action: "update", + if (socket.data.role !== 'controller') { + socket.emit('flightError', { + action: 'update', flightId, - error: "Not authorized", + error: 'Not authorized', }); return; } try { validateFlightId(flightId); - if (Object.prototype.hasOwnProperty.call(updates, "hidden")) { + if (Object.prototype.hasOwnProperty.call(updates, 'hidden')) { return; } const oldFlight = await mainDb - .selectFrom("flights") + .selectFrom('flights') .selectAll() - .where("session_id", "=", sessionId) - .where("id", "=", flightId as string) + .where('session_id', '=', sessionId) + .where('id', '=', flightId as string) .executeTakeFirst(); - socket.emit("flightUpdateAck", { flightId, updates }); + socket.emit('flightUpdateAck', { flightId, updates }); - if (updates.callsign && typeof updates.callsign === "string") + if (updates.callsign && typeof updates.callsign === 'string') updates.callsign = sanitizeCallsign(updates.callsign); - if (updates.remark && typeof updates.remark === "string") + if (updates.remark && typeof updates.remark === 'string') updates.remark = sanitizeString(updates.remark, 500); - if (updates.squawk && typeof updates.squawk === "string") + if (updates.squawk && typeof updates.squawk === 'string') updates.squawk = sanitizeSquawk(updates.squawk); - if (updates.clearedFL && typeof updates.clearedFL === "string") + if (updates.clearedFL && typeof updates.clearedFL === 'string') updates.clearedFL = sanitizeFlightLevel(updates.clearedFL); - if (updates.cruisingFL && typeof updates.cruisingFL === "string") + if (updates.cruisingFL && typeof updates.cruisingFL === 'string') updates.cruisingFL = sanitizeFlightLevel(updates.cruisingFL); - if (updates.runway && typeof updates.runway === "string") + if (updates.runway && typeof updates.runway === 'string') updates.runway = sanitizeRunway(updates.runway); - if (updates.stand && typeof updates.stand === "string") + if (updates.stand && typeof updates.stand === 'string') updates.stand = sanitizeString(updates.stand, 8); - if (updates.gate && typeof updates.gate === "string") + if (updates.gate && typeof updates.gate === 'string') updates.gate = sanitizeString(updates.gate, 8); - if (updates.sid && typeof updates.sid === "string") + if (updates.sid && typeof updates.sid === 'string') updates.sid = sanitizeString(updates.sid, 16); - if (updates.star && typeof updates.star === "string") + if (updates.star && typeof updates.star === 'string') updates.star = sanitizeString(updates.star, 16); if (updates.clearance !== undefined) { - if (typeof updates.clearance === "string") { - updates.clearance = updates.clearance.toLowerCase() === "true"; + if (typeof updates.clearance === 'string') { + updates.clearance = updates.clearance.toLowerCase() === 'true'; } } if ( - socket.data.role === "controller" && + socket.data.role === 'controller' && updates && Object.keys(updates).length > 0 ) { if (userId) { incrementStat( userId, - "total_flight_edits", + 'total_flight_edits', 1, - "total_edit_actions" + 'total_edit_actions' ); } } @@ -311,7 +311,7 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { updates ); if (updatedFlight) { - io.to(sessionId).emit("flightUpdated", updatedFlight); + io.to(sessionId).emit('flightUpdated', updatedFlight); setImmediate(() => { void (async () => { @@ -329,11 +329,11 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { changedData[key] = value; } await logFlightAction({ - userId: userId || "unknown", + userId: userId || 'unknown', username: - (socket.handshake.query.username as string) || "unknown", + (socket.handshake.query.username as string) || 'unknown', sessionId, - action: "update", + action: 'update', flightId: flightId as string, oldData: { ...oldSanitized, @@ -346,41 +346,41 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { })(); }); } else { - socket.emit("flightError", { - action: "update", + socket.emit('flightError', { + action: 'update', flightId, - error: "Flight not found", + error: 'Flight not found', }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error("Error updating flight:", error); - socket.emit("flightError", { - action: "update", + console.error('Error updating flight:', error); + socket.emit('flightError', { + action: 'update', flightId, - error: errorMessage || "Failed to update flight", + error: errorMessage || 'Failed to update flight', }); } } ); - socket.on("deleteFlight", async (flightId: string | number) => { + socket.on('deleteFlight', async (flightId: string | number) => { const sessionId = socket.data.sessionId; - if (socket.data.role !== "controller") { - socket.emit("flightError", { - action: "delete", + if (socket.data.role !== 'controller') { + socket.emit('flightError', { + action: 'delete', flightId, - error: "Not authorized", + error: 'Not authorized', }); return; } try { const flightToDelete = await mainDb - .selectFrom("flights") + .selectFrom('flights') .selectAll() - .where("session_id", "=", sessionId) - .where("id", "=", flightId as string) + .where('session_id', '=', sessionId) + .where('id', '=', flightId as string) .executeTakeFirst(); const { acars_token, @@ -394,14 +394,14 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { : null; await deleteFlight(sessionId, flightId as string); - io.to(sessionId).emit("flightDeleted", { flightId }); + io.to(sessionId).emit('flightDeleted', { flightId }); setImmediate(() => { void logFlightAction({ - userId: userId || "unknown", - username: (socket.handshake.query.username as string) || "unknown", + userId: userId || 'unknown', + username: (socket.handshake.query.username as string) || 'unknown', sessionId, - action: "delete", + action: 'delete', flightId: flightId as string, oldData: { ...sanitizedOldData, @@ -412,18 +412,18 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { }); }); } catch { - socket.emit("flightError", { - action: "delete", + socket.emit('flightError', { + action: 'delete', flightId, - error: "Failed to delete flight", + error: 'Failed to delete flight', }); } }); - socket.on("updateSession", async (updates: SessionUpdateData) => { + socket.on('updateSession', async (updates: SessionUpdateData) => { const sessionId = socket.data.sessionId; - if (socket.data.role !== "controller") { - socket.emit("sessionError", { error: "Not authorized" }); + if (socket.data.role !== 'controller') { + socket.emit('sessionError', { error: 'Not authorized' }); return; } try { @@ -434,36 +434,36 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { const updatedSession = await updateSession(sessionId, dbUpdates); if (updatedSession) { - io.to(sessionId).emit("sessionUpdated", { + io.to(sessionId).emit('sessionUpdated', { activeRunway: updatedSession.active_runway, }); } else { - socket.emit("sessionError", { - error: "Session not found or update failed", + socket.emit('sessionError', { + error: 'Session not found or update failed', }); } } catch { - socket.emit("sessionError", { error: "Failed to update session" }); + socket.emit('sessionError', { error: 'Failed to update session' }); } }); socket.on( - "issuePDC", + 'issuePDC', async ({ flightId, pdcText, targetPilotUserId }: PDCData) => { const sessionId = socket.data.sessionId; - if (socket.data.role !== "controller") { - socket.emit("flightError", { - action: "issuePDC", + if (socket.data.role !== 'controller') { + socket.emit('flightError', { + action: 'issuePDC', flightId, - error: "Not authorized", + error: 'Not authorized', }); return; } try { if (!flightId) { - socket.emit("flightError", { - action: "issuePDC", - error: "Missing flightId", + socket.emit('flightError', { + action: 'issuePDC', + error: 'Missing flightId', }); return; } @@ -479,30 +479,30 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { updates ); if (updatedFlight) { - io.to(sessionId).emit("flightUpdated", updatedFlight); - io.to(sessionId).emit("pdcIssued", { + io.to(sessionId).emit('flightUpdated', updatedFlight); + io.to(sessionId).emit('pdcIssued', { flightId, pdcText: sanitizedPDC, updatedFlight, }); } else { - socket.emit("flightError", { - action: "issuePDC", + socket.emit('flightError', { + action: 'issuePDC', flightId, - error: "Flight not found", + error: 'Flight not found', }); } } catch { - socket.emit("flightError", { - action: "issuePDC", + socket.emit('flightError', { + action: 'issuePDC', flightId, - error: "Failed to issue PDC", + error: 'Failed to issue PDC', }); } } ); - socket.on("requestPDC", ({ flightId, callsign, note }: PDCRequestData) => { + socket.on('requestPDC', ({ flightId, callsign, note }: PDCRequestData) => { const sessionId = socket.data.sessionId; try { if (flightId) { @@ -510,7 +510,7 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { } const sanitizedCallsign = callsign ? sanitizeCallsign(callsign) : null; const sanitizedNote = note ? sanitizeString(note, 200) : null; - io.to(sessionId).emit("pdcRequest", { + io.to(sessionId).emit('pdcRequest', { flightId, callsign: sanitizedCallsign, note: sanitizedNote, @@ -521,25 +521,25 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { ts: new Date().toISOString(), }); } catch { - socket.emit("flightError", { - action: "requestPDC", + socket.emit('flightError', { + action: 'requestPDC', flightId, - error: "Failed to request PDC", + error: 'Failed to request PDC', }); } }); socket.on( - "contactMe", + 'contactMe', async ({ flightId, message, station, position }: ContactMeData) => { const sessionId = socket.data.sessionId; - if (socket.data.role !== "controller") { - socket.emit("flightError", { - action: "contactMe", + if (socket.data.role !== 'controller') { + socket.emit('flightError', { + action: 'contactMe', flightId, station, position, - error: "Not authorized", + error: 'Not authorized', }); return; } @@ -548,9 +548,9 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { let targetSessionId = sessionId; try { const flightRow = await mainDb - .selectFrom("flights") - .select("session_id") - .where("id", "=", flightId as string) + .selectFrom('flights') + .select('session_id') + .where('id', '=', flightId as string) .executeTakeFirst(); if (flightRow) targetSessionId = flightRow.session_id; } catch { @@ -559,8 +559,8 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { const sanitizedMessage = message ? sanitizeString(message, 200) - : "CONTACT CONTROLLER ON FREQUENCY"; - io.to(targetSessionId).emit("contactMe", { + : 'CONTACT CONTROLLER ON FREQUENCY'; + io.to(targetSessionId).emit('contactMe', { flightId, message: sanitizedMessage, station: station ? sanitizeString(station, 50) : undefined, @@ -568,16 +568,16 @@ export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { ts: new Date().toISOString(), }); } catch { - socket.emit("flightError", { - action: "contactMe", + socket.emit('flightError', { + action: 'contactMe', flightId, - error: "Failed to send contact message", + error: 'Failed to send contact message', }); } } ); - socket.on("disconnect", () => {}); + socket.on('disconnect', () => {}); }); registerFlightsIO(io); @@ -610,4 +610,4 @@ export function broadcastFlightEvent( if (io) { io.to(sessionId).emit(event, data); } -} \ No newline at end of file +} diff --git a/server/websockets/globalChatWebsocket.ts b/server/websockets/globalChatWebsocket.ts index 7b43ea44..cc7b725c 100644 --- a/server/websockets/globalChatWebsocket.ts +++ b/server/websockets/globalChatWebsocket.ts @@ -1,16 +1,19 @@ -import { Server as SocketServer } from "socket.io"; -import { mainDb } from "../db/connection.js"; -import { reportGlobalChatMessage } from "../db/chats.js"; -import { sanitizeMessage } from "../utils/sanitization.js"; -import { containsHateSpeech, getHateSpeechReason } from "../utils/hateSpeechFilter.js"; -import { encrypt, decrypt } from "../utils/encryption.js"; -import { sql } from "kysely"; -import type { Server } from "http"; -import { createHandshakeRateLimiter } from "./handshakeRateLimit.js"; +import { Server as SocketServer } from 'socket.io'; +import { mainDb } from '../db/connection.js'; +import { reportGlobalChatMessage } from '../db/chats.js'; +import { sanitizeMessage } from '../utils/sanitization.js'; +import { + containsHateSpeech, + getHateSpeechReason, +} from '../utils/hateSpeechFilter.js'; +import { encrypt, decrypt } from '../utils/encryption.js'; +import { sql } from 'kysely'; +import type { Server } from 'http'; +import { createHandshakeRateLimiter } from './handshakeRateLimit.js'; const activeGlobalChatUsers = new Map>([ - ["pfatc", new Set()], - ["aatc", new Set()], + ['pfatc', new Set()], + ['aatc', new Set()], ]); const connectedGlobalChatUsers = new Map< string, @@ -26,8 +29,8 @@ const connectedGlobalChatUsers = new Map< } > >([ - ["pfatc", new Map()], - ["aatc", new Map()], + ['pfatc', new Map()], + ['aatc', new Map()], ]); let sessionUsersIO: SessionUsersWebsocketIO | null = null; @@ -51,11 +54,14 @@ const cleanupInactiveGlobalUsers = () => { } }; -const globalChatCleanupInterval = setInterval(cleanupInactiveGlobalUsers, 2 * 60 * 1000); +const globalChatCleanupInterval = setInterval( + cleanupInactiveGlobalUsers, + 2 * 60 * 1000 +); interface SessionUsersWebsocketIO { getActiveUsersForSession?( - sessionId: string, + sessionId: string ): Promise>; sendMentionToUser(userId: string, mentionData: MentionData): void; } @@ -87,22 +93,22 @@ interface GlobalChatMessage { export function setupGlobalChatWebsocket( httpServer: Server, - sessionUsersWebsocketIO: SessionUsersWebsocketIO, + sessionUsersWebsocketIO: SessionUsersWebsocketIO ) { sessionUsersIO = sessionUsersWebsocketIO; const io = new SocketServer(httpServer, { - path: "/sockets/global-chat", + path: '/sockets/global-chat', allowRequest: createHandshakeRateLimiter({ - scope: "global-chat", + scope: 'global-chat', maxAttempts: 500, }), cors: { origin: [ - "http://localhost:5173", - "http://localhost:9901", - "https://pfcontrol.com", - "https://canary.pfcontrol.com", + 'http://localhost:5173', + 'http://localhost:9901', + 'https://pfcontrol.com', + 'https://canary.pfcontrol.com', ], credentials: true, }, @@ -115,21 +121,25 @@ export function setupGlobalChatWebsocket( async () => { try { await mainDb - .updateTable("global_chat") + .updateTable('global_chat') .set({ deleted_at: sql`(NOW() AT TIME ZONE 'UTC')` }) .where((eb) => - eb(sql`sent_at`, "<", sql`(NOW() AT TIME ZONE 'UTC') - INTERVAL '30 minutes'`), + eb( + sql`sent_at`, + '<', + sql`(NOW() AT TIME ZONE 'UTC') - INTERVAL '30 minutes'` + ) ) - .where("deleted_at", "is", null) + .where('deleted_at', 'is', null) .execute(); } catch (error) { - console.error("[Global Chat] Error cleaning old messages:", error); + console.error('[Global Chat] Error cleaning old messages:', error); } }, - 5 * 60 * 1000, + 5 * 60 * 1000 ); - io.on("connection", async (socket) => { + io.on('connection', async (socket) => { const userId = Array.isArray(socket.handshake.query.userId) ? socket.handshake.query.userId[0] : socket.handshake.query.userId; @@ -145,8 +155,9 @@ export function setupGlobalChatWebsocket( const rawNetworkKind = Array.isArray(socket.handshake.query.networkKind) ? socket.handshake.query.networkKind[0] : socket.handshake.query.networkKind; - const networkKind: "pfatc" | "aatc" = rawNetworkKind === "aatc" ? "aatc" : "pfatc"; - const roomName = networkKind === "aatc" ? "aatc-chat" : "global-chat"; + const networkKind: 'pfatc' | 'aatc' = + rawNetworkKind === 'aatc' ? 'aatc' : 'pfatc'; + const roomName = networkKind === 'aatc' ? 'aatc-chat' : 'global-chat'; if (!userId) { socket.disconnect(true); @@ -155,14 +166,14 @@ export function setupGlobalChatWebsocket( try { const user = await mainDb - .selectFrom("users") - .select(["username"]) - .where("id", "=", userId) + .selectFrom('users') + .select(['username']) + .where('id', '=', userId) .executeTakeFirst(); - socket.data.username = user?.username || "Unknown"; + socket.data.username = user?.username || 'Unknown'; } catch (error) { - console.error("[Global Chat] Error fetching username:", error); - socket.data.username = "Unknown"; + console.error('[Global Chat] Error fetching username:', error); + socket.data.username = 'Unknown'; } socket.data.userId = userId; @@ -177,9 +188,9 @@ export function setupGlobalChatWebsocket( if (station && !connectedGlobalChatUsers.get(networkKind)!.has(userId)) { try { const user = await mainDb - .selectFrom("users") - .select(["username", "avatar"]) - .where("id", "=", userId) + .selectFrom('users') + .select(['username', 'avatar']) + .where('id', '=', userId) .executeTakeFirst(); let avatarUrl = null; @@ -197,39 +208,52 @@ export function setupGlobalChatWebsocket( lastSeen: Date.now(), }); - io.to(roomName).emit("connectedGlobalChatUsers", Array.from(networkMap.values())); + io.to(roomName).emit( + 'connectedGlobalChatUsers', + Array.from(networkMap.values()) + ); } catch (error) { - console.error("[Global Chat] Error fetching user data:", error); + console.error('[Global Chat] Error fetching user data:', error); const networkMap = connectedGlobalChatUsers.get(networkKind)!; networkMap.set(userId, { id: userId, - username: "Unknown", + username: 'Unknown', avatar: null, station: station, position: position || null, lastSeen: Date.now(), }); - io.to(roomName).emit("connectedGlobalChatUsers", Array.from(networkMap.values())); + io.to(roomName).emit( + 'connectedGlobalChatUsers', + Array.from(networkMap.values()) + ); } } - socket.on("globalTyping", ({ username }: { username: string }) => { + socket.on('globalTyping', ({ username }: { username: string }) => { socket.broadcast .to(roomName) - .emit("globalUserTyping", { userId, username: socket.data.username }); + .emit('globalUserTyping', { userId, username: socket.data.username }); }); - socket.on("globalChatMessage", async ({ user, message }) => { - if (!message || message.length > 500 || user.userId !== socket.data.userId) return; + socket.on('globalChatMessage', async ({ user, message }) => { + if ( + !message || + message.length > 500 || + user.userId !== socket.data.userId + ) + return; const sanitizedMessage = sanitizeMessage(message, 500); if (!sanitizedMessage) return; if (socket.data.station) { - const existingUser = connectedGlobalChatUsers.get(networkKind)!.get(user.userId); + const existingUser = connectedGlobalChatUsers + .get(networkKind)! + .get(user.userId); let avatarUrl = user.avatar; - if (user.avatar && !user.avatar.startsWith("http")) { + if (user.avatar && !user.avatar.startsWith('http')) { avatarUrl = `https://cdn.discordapp.com/avatars/${user.userId}/${user.avatar}.png`; } @@ -242,7 +266,10 @@ export function setupGlobalChatWebsocket( position: socket.data.position || null, lastSeen: Date.now(), }); - io.to(roomName).emit("connectedGlobalChatUsers", Array.from(networkMap.values())); + io.to(roomName).emit( + 'connectedGlobalChatUsers', + Array.from(networkMap.values()) + ); } const parsedAirportMentions = parseAirportMentions(sanitizedMessage); @@ -250,17 +277,19 @@ export function setupGlobalChatWebsocket( const hasHateSpeech = containsHateSpeech(sanitizedMessage); const automodded = hasHateSpeech; - const automodReason = hasHateSpeech ? getHateSpeechReason(sanitizedMessage) : undefined; + const automodReason = hasHateSpeech + ? getHateSpeechReason(sanitizedMessage) + : undefined; const encryptedMsg = encrypt(sanitizedMessage); if (!encryptedMsg) { - socket.emit("chatError", { message: "Failed to encrypt message" }); + socket.emit('chatError', { message: 'Failed to encrypt message' }); return; } try { const result = await mainDb - .insertInto("global_chat") + .insertInto('global_chat') .values({ id: sql`DEFAULT`, user_id: user.userId, @@ -270,41 +299,47 @@ export function setupGlobalChatWebsocket( position: socket.data.position ?? undefined, message: JSON.stringify(encryptedMsg), airport_mentions: - parsedAirportMentions.length > 0 ? JSON.stringify(parsedAirportMentions) : undefined, + parsedAirportMentions.length > 0 + ? JSON.stringify(parsedAirportMentions) + : undefined, user_mentions: - parsedUserMentions.length > 0 ? JSON.stringify(parsedUserMentions) : undefined, + parsedUserMentions.length > 0 + ? JSON.stringify(parsedUserMentions) + : undefined, sent_at: sql`NOW()`, network_kind: networkKind, }) .returning([ - "id", - "user_id", - "username", - "avatar", - "station", - "position", - "message", - "airport_mentions", - "user_mentions", - "sent_at", + 'id', + 'user_id', + 'username', + 'avatar', + 'station', + 'position', + 'message', + 'airport_mentions', + 'user_mentions', + 'sent_at', ]) .executeTakeFirst(); if (!result) { - socket.emit("chatError", { message: "Failed to send message" }); + socket.emit('chatError', { message: 'Failed to send message' }); return; } - let decryptedMessage = ""; + let decryptedMessage = ''; try { if (result.message) { const encryptedData = - typeof result.message === "string" ? JSON.parse(result.message) : result.message; - decryptedMessage = decrypt(encryptedData) || ""; + typeof result.message === 'string' + ? JSON.parse(result.message) + : result.message; + decryptedMessage = decrypt(encryptedData) || ''; } } catch (e) { - console.error("[Global Chat] Error decrypting message:", e); - decryptedMessage = ""; + console.error('[Global Chat] Error decrypting message:', e); + decryptedMessage = ''; } let airportMentions = null; @@ -314,13 +349,13 @@ export function setupGlobalChatWebsocket( if (Array.isArray(result.airport_mentions)) { airportMentions = result.airport_mentions; } else if ( - typeof result.airport_mentions === "string" && + typeof result.airport_mentions === 'string' && result.airport_mentions.trim() ) { try { airportMentions = JSON.parse(result.airport_mentions); } catch (e) { - console.error("[Global Chat] Error parsing airport mentions:", e); + console.error('[Global Chat] Error parsing airport mentions:', e); airportMentions = null; } } @@ -329,11 +364,14 @@ export function setupGlobalChatWebsocket( if (result.user_mentions) { if (Array.isArray(result.user_mentions)) { userMentions = result.user_mentions; - } else if (typeof result.user_mentions === "string" && result.user_mentions.trim()) { + } else if ( + typeof result.user_mentions === 'string' && + result.user_mentions.trim() + ) { try { userMentions = JSON.parse(result.user_mentions); } catch (e) { - console.error("[Global Chat] Error parsing user mentions:", e); + console.error('[Global Chat] Error parsing user mentions:', e); userMentions = null; } } @@ -367,136 +405,142 @@ export function setupGlobalChatWebsocket( automodded, }; - io.to(roomName).emit("globalChatMessage", formattedMsg); + io.to(roomName).emit('globalChatMessage', formattedMsg); if (automodded) { - socket.emit("messageAutomodded", { + socket.emit('messageAutomodded', { messageId: chatMsg.id, - reason: automodReason || "Content violation detected", + reason: automodReason || 'Content violation detected', }); try { await reportGlobalChatMessage( chatMsg.id, - "automod", - automodReason || "Content violation detected", + 'automod', + automodReason || 'Content violation detected' ); } catch (error) { - console.error("[Global Chat] Error reporting automodded message:", error); + console.error( + '[Global Chat] Error reporting automodded message:', + error + ); } } if (userMentions && userMentions.length > 0) { try { const users = await mainDb - .selectFrom("users") - .select(["id", "username"]) - .where("username", "in", userMentions) + .selectFrom('users') + .select(['id', 'username']) + .where('username', 'in', userMentions) .execute(); for (const mentionedUser of users) { io.to(roomName) .to(`user-${mentionedUser.id}`) - .emit("globalChatMention", { + .emit('globalChatMention', { messageId: String(chatMsg.id), mentionedUserId: mentionedUser.id, - mentionerUsername: user.username || "Unknown", + mentionerUsername: user.username || 'Unknown', message: chatMsg.message, timestamp: chatMsg.sent_at.toISOString(), }); } } catch (error) { - console.error("[Global Chat] Error sending user mentions:", error); + console.error('[Global Chat] Error sending user mentions:', error); } } if (airportMentions && airportMentions.length > 0) { for (const airport of airportMentions) { - io.to(roomName).emit("airportMention", { + io.to(roomName).emit('airportMention', { airport: airport.toUpperCase(), messageId: String(chatMsg.id), - mentionerUsername: user.username || "Unknown", + mentionerUsername: user.username || 'Unknown', message: chatMsg.message, timestamp: chatMsg.sent_at.toISOString(), }); } } } catch (error) { - console.error("[Global Chat] Error adding message:", error); - socket.emit("chatError", { message: "Failed to send message" }); + console.error('[Global Chat] Error adding message:', error); + socket.emit('chatError', { message: 'Failed to send message' }); } }); - socket.on("deleteGlobalMessage", async ({ messageId, userId }) => { + socket.on('deleteGlobalMessage', async ({ messageId, userId }) => { try { const result = await mainDb - .updateTable("global_chat") + .updateTable('global_chat') .set({ deleted_at: sql`NOW()` }) - .where("id", "=", messageId) - .where("user_id", "=", userId) - .where("deleted_at", "is", null) + .where('id', '=', messageId) + .where('user_id', '=', userId) + .where('deleted_at', 'is', null) .executeTakeFirst(); if (result.numUpdatedRows > 0) { - io.to(roomName).emit("globalMessageDeleted", { messageId }); + io.to(roomName).emit('globalMessageDeleted', { messageId }); } else { - socket.emit("deleteError", { + socket.emit('deleteError', { messageId, - error: "Cannot delete this message", + error: 'Cannot delete this message', }); } } catch (error) { - console.error("[Global Chat] Error deleting message:", error); - socket.emit("deleteError", { + console.error('[Global Chat] Error deleting message:', error); + socket.emit('deleteError', { messageId, - error: "Failed to delete message", + error: 'Failed to delete message', }); } }); - socket.on("globalChatOpened", () => { + socket.on('globalChatOpened', () => { const userId = socket.data.userId; - const nk: string = socket.data.networkKind || "pfatc"; + const nk: string = socket.data.networkKind || 'pfatc'; if (userId) { activeGlobalChatUsers.get(nk)!.add(userId); io.to(socket.data.roomName).emit( - "activeGlobalChatUsers", - Array.from(activeGlobalChatUsers.get(nk)!), + 'activeGlobalChatUsers', + Array.from(activeGlobalChatUsers.get(nk)!) ); } }); - socket.on("globalChatClosed", () => { + socket.on('globalChatClosed', () => { const userId = socket.data.userId; - const nk: string = socket.data.networkKind || "pfatc"; + const nk: string = socket.data.networkKind || 'pfatc'; if (userId) { activeGlobalChatUsers.get(nk)!.delete(userId); io.to(socket.data.roomName).emit( - "activeGlobalChatUsers", - Array.from(activeGlobalChatUsers.get(nk)!), + 'activeGlobalChatUsers', + Array.from(activeGlobalChatUsers.get(nk)!) ); } }); - socket.on("disconnect", () => { + socket.on('disconnect', () => { const userId = socket.data.userId; - const nk: string = socket.data.networkKind || "pfatc"; - const room: string = socket.data.roomName || "global-chat"; + const nk: string = socket.data.networkKind || 'pfatc'; + const room: string = socket.data.roomName || 'global-chat'; if (userId) { activeGlobalChatUsers.get(nk)!.delete(userId); connectedGlobalChatUsers.get(nk)!.delete(userId); - io.to(room).emit("activeGlobalChatUsers", Array.from(activeGlobalChatUsers.get(nk)!)); io.to(room).emit( - "connectedGlobalChatUsers", - Array.from(connectedGlobalChatUsers.get(nk)!.values()), + 'activeGlobalChatUsers', + Array.from(activeGlobalChatUsers.get(nk)!) + ); + io.to(room).emit( + 'connectedGlobalChatUsers', + Array.from(connectedGlobalChatUsers.get(nk)!.values()) ); } }); }); // Cleanup on shutdown - process.on("SIGTERM", () => { - console.log("[GlobalChat] Cleaning up intervals..."); + process.on('SIGTERM', () => { + console.log('[GlobalChat] Cleaning up intervals...'); clearInterval(globalChatCleanupInterval); for (const m of connectedGlobalChatUsers.values()) m.clear(); for (const s of activeGlobalChatUsers.values()) s.clear(); @@ -526,4 +570,4 @@ function parseUserMentions(message: string): string[] { } } return mentions; -} \ No newline at end of file +} diff --git a/server/websockets/overviewWebsocket.ts b/server/websockets/overviewWebsocket.ts index 18cdddb9..11e825ad 100644 --- a/server/websockets/overviewWebsocket.ts +++ b/server/websockets/overviewWebsocket.ts @@ -1,31 +1,31 @@ -import { Server as SocketServer } from "socket.io"; -import { updateFlight, getFlightById } from "../db/flights.js"; -import { getUserById } from "../db/users.js"; +import { Server as SocketServer } from 'socket.io'; +import { updateFlight, getFlightById } from '../db/flights.js'; +import { getUserById } from '../db/users.js'; import { getOverviewForClient, setOverviewSessionUsersIO, refreshOverviewSnapshot, -} from "../realtime/overview.js"; -import { setOverviewIO as registerOverviewIO } from "../realtime/socketRegistry.js"; -import { getFlightsIO } from "../realtime/socketRegistry.js"; -import { validateFlightId, validateSessionId } from "../utils/validation.js"; +} from '../realtime/overview.js'; +import { setOverviewIO as registerOverviewIO } from '../realtime/socketRegistry.js'; +import { getFlightsIO } from '../realtime/socketRegistry.js'; +import { validateFlightId, validateSessionId } from '../utils/validation.js'; import { sanitizeCallsign, sanitizeString, sanitizeSquawk, sanitizeFlightLevel, sanitizeRunway, -} from "../utils/sanitization.js"; +} from '../utils/sanitization.js'; import { isPFATCSectorController, isAATCSectorController, -} from "../middleware/flightAccess.js"; -import { incrementStat } from "../utils/statisticsCache.js"; -import { logFlightAction } from "../db/flightLogs.js"; -import type { Server as HTTPServer } from "http"; -import type { SessionUsersServer } from "./sessionUsersWebsocket.js"; -import type { Flight } from "../utils/flightUtils.js"; -import { createHandshakeRateLimiter } from "./handshakeRateLimit.js"; +} from '../middleware/flightAccess.js'; +import { incrementStat } from '../utils/statisticsCache.js'; +import { logFlightAction } from '../db/flightLogs.js'; +import type { Server as HTTPServer } from 'http'; +import type { SessionUsersServer } from './sessionUsersWebsocket.js'; +import type { Flight } from '../utils/flightUtils.js'; +import { createHandshakeRateLimiter } from './handshakeRateLimit.js'; let io: SocketServer; const activeOverviewClients = new Set(); @@ -36,34 +36,34 @@ export function setupOverviewWebsocket( sessionUsersIO: SessionUsersServer ) { io = new SocketServer(httpServer, { - path: "/sockets/overview", - allowRequest: createHandshakeRateLimiter({ scope: "overview" }), + path: '/sockets/overview', + allowRequest: createHandshakeRateLimiter({ scope: 'overview' }), cors: { origin: [ - "http://localhost:5173", - "http://localhost:9901", - "https://pfcontrol.com", - "https://canary.pfcontrol.com", + 'http://localhost:5173', + 'http://localhost:9901', + 'https://pfcontrol.com', + 'https://canary.pfcontrol.com', ], credentials: true, }, - transports: ["websocket", "polling"], + transports: ['websocket', 'polling'], allowEIO3: true, perMessageDeflate: { threshold: 1024, }, }); - io.engine.on("connection_error", (err) => { - console.error("[Overview Socket] Engine connection error:", err); + io.engine.on('connection_error', (err) => { + console.error('[Overview Socket] Engine connection error:', err); }); - io.on("connection", async (socket) => { + io.on('connection', async (socket) => { activeOverviewClients.add(socket.id); const userId = socket.handshake.query.userId as string; const isEventControllerFlag = - socket.handshake.query.isEventController === "true"; + socket.handshake.query.isEventController === 'true'; if (isEventControllerFlag && userId) { const [canPfatc, canAatc] = await Promise.all([ @@ -78,20 +78,20 @@ export function setupOverviewWebsocket( socket.data.canEditAatc = canAatc; socket.data.userId = userId; socket.data.username = - (socket.handshake.query.username as string) || "Unknown"; + (socket.handshake.query.username as string) || 'Unknown'; } } try { const overviewData = await getOverviewForClient(sessionUsersIO); - socket.emit("overviewData", overviewData); + socket.emit('overviewData', overviewData); } catch (error) { - console.error("[Overview Socket] Error sending initial data:", error); - socket.emit("overviewError", { error: "Failed to fetch overview data" }); + console.error('[Overview Socket] Error sending initial data:', error); + socket.emit('overviewError', { error: 'Failed to fetch overview data' }); } socket.on( - "updateFlight", + 'updateFlight', async ({ sessionId, flightId, @@ -102,10 +102,10 @@ export function setupOverviewWebsocket( updates: Record; }) => { if (!socket.data.isEventController) { - socket.emit("flightError", { - action: "update", + socket.emit('flightError', { + action: 'update', flightId, - error: "Not authorized", + error: 'Not authorized', }); return; } @@ -115,64 +115,64 @@ export function setupOverviewWebsocket( // Enforce per-network-type access const session = await ( - await import("../db/sessions.js") + await import('../db/sessions.js') ).getSessionById(validSessionId); if (!session) { - socket.emit("flightError", { - action: "update", + socket.emit('flightError', { + action: 'update', flightId, - error: "Session not found", + error: 'Session not found', }); return; } if (session.is_pfatc && !socket.data.canEditPfatc) { - socket.emit("flightError", { - action: "update", + socket.emit('flightError', { + action: 'update', flightId, - error: "Not authorized to edit PFATC flights", + error: 'Not authorized to edit PFATC flights', }); return; } if (session.is_advanced_atc && !socket.data.canEditAatc) { - socket.emit("flightError", { - action: "update", + socket.emit('flightError', { + action: 'update', flightId, - error: "Not authorized to edit AATC flights", + error: 'Not authorized to edit AATC flights', }); return; } validateFlightId(flightId); - if (Object.prototype.hasOwnProperty.call(updates, "hidden")) { + if (Object.prototype.hasOwnProperty.call(updates, 'hidden')) { return; } - if (updates.callsign && typeof updates.callsign === "string") + if (updates.callsign && typeof updates.callsign === 'string') updates.callsign = sanitizeCallsign(updates.callsign); - if (updates.remark && typeof updates.remark === "string") + if (updates.remark && typeof updates.remark === 'string') updates.remark = sanitizeString(updates.remark, 500); - if (updates.squawk && typeof updates.squawk === "string") + if (updates.squawk && typeof updates.squawk === 'string') updates.squawk = sanitizeSquawk(updates.squawk); - if (updates.clearedFL && typeof updates.clearedFL === "string") + if (updates.clearedFL && typeof updates.clearedFL === 'string') updates.clearedFL = sanitizeFlightLevel(updates.clearedFL); - if (updates.cruisingFL && typeof updates.cruisingFL === "string") + if (updates.cruisingFL && typeof updates.cruisingFL === 'string') updates.cruisingFL = sanitizeFlightLevel(updates.cruisingFL); - if (updates.runway && typeof updates.runway === "string") + if (updates.runway && typeof updates.runway === 'string') updates.runway = sanitizeRunway(updates.runway); - if (updates.stand && typeof updates.stand === "string") + if (updates.stand && typeof updates.stand === 'string') updates.stand = sanitizeString(updates.stand, 8); - if (updates.gate && typeof updates.gate === "string") + if (updates.gate && typeof updates.gate === 'string') updates.gate = sanitizeString(updates.gate, 8); - if (updates.sid && typeof updates.sid === "string") + if (updates.sid && typeof updates.sid === 'string') updates.sid = sanitizeString(updates.sid, 16); - if (updates.star && typeof updates.star === "string") + if (updates.star && typeof updates.star === 'string') updates.star = sanitizeString(updates.star, 16); if (updates.req_at !== undefined) { - if (updates.req_at === "" || updates.req_at === null) { + if (updates.req_at === '' || updates.req_at === null) { updates.req_at = null; } else if ( - typeof updates.req_at === "string" && + typeof updates.req_at === 'string' && !isNaN(Date.parse(updates.req_at)) ) { // valid ISO timestamp — keep as-is @@ -181,11 +181,11 @@ export function setupOverviewWebsocket( } } if (updates.req_phase !== undefined) { - if (updates.req_phase === "" || updates.req_phase === null) { + if (updates.req_phase === '' || updates.req_phase === null) { updates.req_phase = null; } else if ( - typeof updates.req_phase === "string" && - ["C", "P", "T", "G"].includes(updates.req_phase) + typeof updates.req_phase === 'string' && + ['C', 'P', 'T', 'G'].includes(updates.req_phase) ) { // valid phase — keep as-is } else { @@ -194,8 +194,8 @@ export function setupOverviewWebsocket( } if (updates.clearance !== undefined) { - if (typeof updates.clearance === "string") { - updates.clearance = updates.clearance.toLowerCase() === "true"; + if (typeof updates.clearance === 'string') { + updates.clearance = updates.clearance.toLowerCase() === 'true'; } } @@ -211,11 +211,11 @@ export function setupOverviewWebsocket( ); if (updatedFlight) { - socket.emit("flightUpdateAck", { flightId, updates }); + socket.emit('flightUpdateAck', { flightId, updates }); const flightsIO = getFlightsIO(); if (flightsIO) { - flightsIO.to(validSessionId).emit("flightUpdated", updatedFlight); + flightsIO.to(validSessionId).emit('flightUpdated', updatedFlight); } setImmediate(() => { @@ -230,10 +230,10 @@ export function setupOverviewWebsocket( ...oldSanitized } = oldFlight || {}; await logFlightAction({ - userId: socket.data.userId || "unknown", - username: socket.data.username || "unknown", + userId: socket.data.userId || 'unknown', + username: socket.data.username || 'unknown', sessionId: validSessionId, - action: "update", + action: 'update', flightId: flightId as string, oldData: { ...oldSanitized, @@ -249,33 +249,33 @@ export function setupOverviewWebsocket( if (socket.data.userId) { incrementStat( socket.data.userId, - "total_flight_edits", + 'total_flight_edits', 1, - "total_edit_actions" + 'total_edit_actions' ); } } else { - socket.emit("flightError", { - action: "update", + socket.emit('flightError', { + action: 'update', flightId, - error: "Flight not found", + error: 'Flight not found', }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error("Error updating flight via overview socket:", error); - socket.emit("flightError", { - action: "update", + console.error('Error updating flight via overview socket:', error); + socket.emit('flightError', { + action: 'update', flightId, - error: errorMessage || "Failed to update flight", + error: errorMessage || 'Failed to update flight', }); } } ); socket.on( - "contactMe", + 'contactMe', async ({ sessionId, flightId, @@ -290,10 +290,10 @@ export function setupOverviewWebsocket( position?: string; }) => { if (!socket.data.isEventController) { - socket.emit("flightError", { - action: "contactMe", + socket.emit('flightError', { + action: 'contactMe', flightId, - error: "Not authorized", + error: 'Not authorized', }); return; } @@ -304,40 +304,40 @@ export function setupOverviewWebsocket( // Enforce per-network-type access for contactMe const session = await ( - await import("../db/sessions.js") + await import('../db/sessions.js') ).getSessionById(validSessionId); if (!session) { - socket.emit("flightError", { - action: "contactMe", + socket.emit('flightError', { + action: 'contactMe', flightId, - error: "Session not found", + error: 'Session not found', }); return; } if (session.is_pfatc && !socket.data.canEditPfatc) { - socket.emit("flightError", { - action: "contactMe", + socket.emit('flightError', { + action: 'contactMe', flightId, - error: "Not authorized for PFATC sessions", + error: 'Not authorized for PFATC sessions', }); return; } if (session.is_advanced_atc && !socket.data.canEditAatc) { - socket.emit("flightError", { - action: "contactMe", + socket.emit('flightError', { + action: 'contactMe', flightId, - error: "Not authorized for AATC sessions", + error: 'Not authorized for AATC sessions', }); return; } const sanitizedMessage = message ? sanitizeString(message, 200) - : "CONTACT CONTROLLER ON FREQUENCY"; + : 'CONTACT CONTROLLER ON FREQUENCY'; const flightsIO = getFlightsIO(); if (flightsIO) { - flightsIO.to(validSessionId).emit("contactMe", { + flightsIO.to(validSessionId).emit('contactMe', { flightId, message: sanitizedMessage, station: station ? sanitizeString(station, 50) : undefined, @@ -346,26 +346,26 @@ export function setupOverviewWebsocket( }); } } catch (error) { - console.error("Error sending contact message:", error); - socket.emit("flightError", { - action: "contactMe", + console.error('Error sending contact message:', error); + socket.emit('flightError', { + action: 'contactMe', flightId, - error: "Failed to send contact message", + error: 'Failed to send contact message', }); } } ); - socket.on("disconnect", () => { + socket.on('disconnect', () => { activeOverviewClients.delete(socket.id); eventControllerClients.delete(socket.id); }); - socket.on("error", (error) => { + socket.on('error', (error) => { console.error( - "[Overview Socket] Socket error for", + '[Overview Socket] Socket error for', socket.id, - ":", + ':', error ); }); @@ -378,9 +378,9 @@ export function setupOverviewWebsocket( if (activeOverviewClients.size === 0) return; try { const overviewData = await refreshOverviewSnapshot(sessionUsersIO); - io.emit("overviewData", overviewData); + io.emit('overviewData', overviewData); } catch (error) { - console.error("Error refreshing overview data:", error); + console.error('Error refreshing overview data:', error); } }, 5000); @@ -390,15 +390,15 @@ export function setupOverviewWebsocket( const overviewData = await getOverviewForClient(sessionUsersIO, { forceRefresh: true, }); - io.emit("overviewData", overviewData); + io.emit('overviewData', overviewData); } catch (error) { - console.error("Error reconciling overview data:", error); + console.error('Error reconciling overview data:', error); } }, 90000); // Cleanup on shutdown - process.on("SIGTERM", () => { - console.log("[Overview] Cleaning up intervals..."); + process.on('SIGTERM', () => { + console.log('[Overview] Cleaning up intervals...'); clearInterval(overviewRefreshInterval); clearInterval(overviewReconcileInterval); activeOverviewClients.clear(); @@ -422,8 +422,8 @@ export function hasOverviewClients() { export function broadcastFlightUpdate(sessionId: string, flight: Flight) { if (!io) { - console.error("Overview IO not initialized"); + console.error('Overview IO not initialized'); return; } - io.emit("flightUpdated", { sessionId, flight }); -} \ No newline at end of file + io.emit('flightUpdated', { sessionId, flight }); +} diff --git a/server/websockets/sectorControllerWebsocket.ts b/server/websockets/sectorControllerWebsocket.ts index 20395cb0..13a773ef 100644 --- a/server/websockets/sectorControllerWebsocket.ts +++ b/server/websockets/sectorControllerWebsocket.ts @@ -1,11 +1,11 @@ -import { Server as SocketServer } from "socket.io"; -import type { Server as HttpServer } from "http"; -import { redisConnection } from "../db/connection.js"; -import { getUserRoles } from "../db/roles.js"; -import { isAdmin } from "../middleware/admin.js"; -import { scheduleOverviewRefresh } from "../realtime/overview.js"; -import type { SessionUsersServer } from "./sessionUsersWebsocket.js"; -import { createHandshakeRateLimiter } from "./handshakeRateLimit.js"; +import { Server as SocketServer } from 'socket.io'; +import type { Server as HttpServer } from 'http'; +import { redisConnection } from '../db/connection.js'; +import { getUserRoles } from '../db/roles.js'; +import { isAdmin } from '../middleware/admin.js'; +import { scheduleOverviewRefresh } from '../realtime/overview.js'; +import type { SessionUsersServer } from './sessionUsersWebsocket.js'; +import { createHandshakeRateLimiter } from './handshakeRateLimit.js'; interface SectorController { id: string; @@ -25,7 +25,7 @@ interface SectorController { // User roles cache with TTL const userRolesCache = new Map< string, - { roles: SectorController["roles"]; timestamp: number } + { roles: SectorController['roles']; timestamp: number } >(); const ROLES_CACHE_TTL = 5 * 60 * 1000; @@ -56,7 +56,7 @@ function checkRateLimit(userId: string): boolean { async function getUserRolesWithCache( userId: string -): Promise { +): Promise { const now = Date.now(); const cached = userRolesCache.get(userId); @@ -64,30 +64,30 @@ async function getUserRolesWithCache( return cached.roles; } - let roles: SectorController["roles"] = []; + let roles: SectorController['roles'] = []; try { const dbRoles = await getUserRoles(userId); roles = dbRoles.map((role) => ({ id: role.id, name: role.name, - color: role.color ?? "#000000", - icon: role.icon ?? "", + color: role.color ?? '#000000', + icon: role.icon ?? '', priority: role.priority ?? 0, })); if (isAdmin(userId)) { roles.unshift({ id: -1, - name: "Developer", - color: "#3B82F6", - icon: "Braces", + name: 'Developer', + color: '#3B82F6', + icon: 'Braces', priority: 999999, }); } userRolesCache.set(userId, { roles, timestamp: now }); } catch (error) { - console.error("Error fetching user roles:", error); + console.error('Error fetching user roles:', error); } return roles; @@ -104,7 +104,7 @@ export function invalidateUserRolesCache(userId?: string): void { export const getActiveSectorControllers = async (): Promise< SectorController[] > => { - const controllers = await redisConnection.hgetall("activeSectorControllers"); + const controllers = await redisConnection.hgetall('activeSectorControllers'); return Object.values(controllers).map( (controllerData) => JSON.parse(controllerData as string) as SectorController ); @@ -115,16 +115,16 @@ const addSectorController = async ( controllerData: SectorController ): Promise => { await redisConnection.hset( - "activeSectorControllers", + 'activeSectorControllers', userId, JSON.stringify(controllerData) ); // Set TTL for auto-cleanup in case of ungraceful disconnect - await redisConnection.expire("activeSectorControllers", REDIS_CONTROLLER_TTL); + await redisConnection.expire('activeSectorControllers', REDIS_CONTROLLER_TTL); }; const removeSectorController = async (userId: string): Promise => { - await redisConnection.hdel("activeSectorControllers", userId); + await redisConnection.hdel('activeSectorControllers', userId); }; export function setupSectorControllerWebsocket( @@ -132,14 +132,14 @@ export function setupSectorControllerWebsocket( sessionUsersIO: SessionUsersServer ) { const io = new SocketServer(httpServer, { - path: "/sockets/sector-controller", - allowRequest: createHandshakeRateLimiter({ scope: "sector-controller" }), + path: '/sockets/sector-controller', + allowRequest: createHandshakeRateLimiter({ scope: 'sector-controller' }), cors: { origin: [ - "http://localhost:5173", - "http://localhost:9901", - "https://pfcontrol.com", - "https://canary.pfcontrol.com", + 'http://localhost:5173', + 'http://localhost:9901', + 'https://pfcontrol.com', + 'https://canary.pfcontrol.com', ], credentials: true, }, @@ -148,7 +148,7 @@ export function setupSectorControllerWebsocket( }, }); - io.on("connection", async (socket) => { + io.on('connection', async (socket) => { let user: { userId?: string; username?: string; avatar?: string | null }; try { @@ -165,7 +165,7 @@ export function setupSectorControllerWebsocket( try { user = JSON.parse(userQuery); } catch (parseError) { - console.error("Invalid user data:", parseError); + console.error('Invalid user data:', parseError); socket.disconnect(true); return; } @@ -181,18 +181,18 @@ export function setupSectorControllerWebsocket( socket.join(`sector-${user.userId}`); // Handle station selection - socket.on("selectStation", async ({ station }) => { + socket.on('selectStation', async ({ station }) => { if (!user.userId) return; if (!checkRateLimit(user.userId)) { - socket.emit("error", { - message: "Too many requests. Please slow down.", + socket.emit('error', { + message: 'Too many requests. Please slow down.', }); return; } - if (!station || typeof station !== "string" || station.length > 10) { - socket.emit("error", { message: "Invalid station format" }); + if (!station || typeof station !== 'string' || station.length > 10) { + socket.emit('error', { message: 'Invalid station format' }); return; } @@ -209,23 +209,23 @@ export function setupSectorControllerWebsocket( await addSectorController(user.userId, sectorController); // Broadcast to all connected clients - io.emit("controllerAdded", sectorController); + io.emit('controllerAdded', sectorController); scheduleOverviewRefresh(); - socket.emit("stationSelected", { station: sectorController.station }); + socket.emit('stationSelected', { station: sectorController.station }); } catch (error) { - console.error("Error selecting station:", error); - socket.emit("error", { message: "Failed to select station" }); + console.error('Error selecting station:', error); + socket.emit('error', { message: 'Failed to select station' }); } }); // Handle station deselection - socket.on("deselectStation", async () => { + socket.on('deselectStation', async () => { if (!user.userId) return; if (!checkRateLimit(user.userId)) { - socket.emit("error", { - message: "Too many requests. Please slow down.", + socket.emit('error', { + message: 'Too many requests. Please slow down.', }); return; } @@ -234,28 +234,28 @@ export function setupSectorControllerWebsocket( await removeSectorController(user.userId); // Broadcast to all connected clients - io.emit("controllerRemoved", { id: user.userId }); + io.emit('controllerRemoved', { id: user.userId }); scheduleOverviewRefresh(); - socket.emit("stationDeselected"); + socket.emit('stationDeselected'); } catch (error) { - console.error("Error deselecting station:", error); + console.error('Error deselecting station:', error); } }); - socket.on("disconnect", async () => { + socket.on('disconnect', async () => { if (!user.userId) return; await removeSectorController(user.userId); // Broadcast to all connected clients - io.emit("controllerRemoved", { id: user.userId }); + io.emit('controllerRemoved', { id: user.userId }); scheduleOverviewRefresh(); rateLimitMap.delete(user.userId); }); } catch (error) { - console.error("Error in sector controller websocket connection:", error); + console.error('Error in sector controller websocket connection:', error); socket.disconnect(true); } }); @@ -275,11 +275,11 @@ export function setupSectorControllerWebsocket( } }, 60000); - io.on("close", () => { + io.on('close', () => { clearInterval(cacheCleanupInterval); userRolesCache.clear(); rateLimitMap.clear(); }); return io; -} \ No newline at end of file +} diff --git a/server/websockets/sessionUsersWebsocket.ts b/server/websockets/sessionUsersWebsocket.ts index e6c24b76..31886bfa 100644 --- a/server/websockets/sessionUsersWebsocket.ts +++ b/server/websockets/sessionUsersWebsocket.ts @@ -1,23 +1,23 @@ -import { Server as SocketServer, Server } from "socket.io"; -import { validateSessionAccess } from "../middleware/sessionAccess.js"; -import { getSessionById, updateSession } from "../db/sessions.js"; -import { getUserRoles } from "../db/roles.js"; -import { isAdmin } from "../middleware/admin.js"; -import { validateSessionId, validateAccessId } from "../utils/validation.js"; -import type { Server as HttpServer } from "http"; -import { incrementStat } from "../utils/statisticsCache.js"; +import { Server as SocketServer, Server } from 'socket.io'; +import { validateSessionAccess } from '../middleware/sessionAccess.js'; +import { getSessionById, updateSession } from '../db/sessions.js'; +import { getUserRoles } from '../db/roles.js'; +import { isAdmin } from '../middleware/admin.js'; +import { validateSessionId, validateAccessId } from '../utils/validation.js'; +import type { Server as HttpServer } from 'http'; +import { incrementStat } from '../utils/statisticsCache.js'; import { registerActiveSession, onSessionUsersChanged as syncActiveSessionRegistry, setSessionMetaFromRow, -} from "../realtime/activeSessions.js"; +} from '../realtime/activeSessions.js'; import { onSessionUsersChangedInvalidate, onAtisChanged, -} from "../realtime/invalidate.js"; -import { encrypt, decrypt } from "../utils/encryption.js"; -import { redisConnection } from "../db/connection.js"; -import { createHandshakeRateLimiter } from "./handshakeRateLimit.js"; +} from '../realtime/invalidate.js'; +import { encrypt, decrypt } from '../utils/encryption.js'; +import { redisConnection } from '../db/connection.js'; +import { createHandshakeRateLimiter } from './handshakeRateLimit.js'; interface SessionUser { id: string; @@ -53,7 +53,7 @@ const addUserToSession = async ( userId, JSON.stringify(userData) ); - const { keys } = await import("../realtime/keys.js"); + const { keys } = await import('../realtime/keys.js'); await redisConnection.sadd(keys.activeUsersIndex(), sessionId); }; @@ -112,12 +112,12 @@ const userActivity = new Map< const cleanupOldSessions = async () => { try { const activeSessionIds = new Set(); - const { keys: rtKeys } = await import("../realtime/keys.js"); + const { keys: rtKeys } = await import('../realtime/keys.js'); let sessionIds = await redisConnection.smembers(rtKeys.activeUsersIndex()); if (sessionIds.length === 0) { - const legacyKeys = await redisConnection.keys("activeUsers:*"); - sessionIds = legacyKeys.map((key) => key.replace("activeUsers:", "")); + const legacyKeys = await redisConnection.keys('activeUsers:*'); + sessionIds = legacyKeys.map((key) => key.replace('activeUsers:', '')); } for (const sessionId of sessionIds) { @@ -148,13 +148,13 @@ const cleanupOldSessions = async () => { } for (const userKey of userActivity.keys()) { - const sessionId = userKey.split("-")[1]; + const sessionId = userKey.split('-')[1]; if (sessionId && !activeSessionIds.has(sessionId)) { userActivity.delete(userKey); } } } catch (error) { - console.error("[Cleanup] Error cleaning up old sessions:", error); + console.error('[Cleanup] Error cleaning up old sessions:', error); } }; @@ -183,39 +183,39 @@ async function generateAutoATIS( if (!session?.atis) return; const storedAtis = - typeof session.atis === "string" + typeof session.atis === 'string' ? JSON.parse(session.atis) : session.atis; const currentAtis = decrypt(storedAtis); - const currentLetter = currentAtis.letter || "A"; - const identOptions = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); + const currentLetter = currentAtis.letter || 'A'; + const identOptions = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); const currentIndex = identOptions.indexOf(currentLetter); const nextIndex = (currentIndex + 1) % identOptions.length; const nextIdent = identOptions[nextIndex]; const formatApproaches = () => { if (!config.selectedApproaches || config.selectedApproaches.length === 0) - return ""; + return ''; const primaryRunway = config.landingRunways.length > 0 ? config.landingRunways[0] : config.departingRunways.length > 0 ? config.departingRunways[0] - : ""; + : ''; if (config.selectedApproaches.length === 1) { return `EXPECT ${config.selectedApproaches[0]} APPROACH RUNWAY ${primaryRunway}`; } if (config.selectedApproaches.length === 2) { - return `EXPECT SIMULTANEOUS ${config.selectedApproaches.join(" AND ")} APPROACH RUNWAY ${primaryRunway}`; + return `EXPECT SIMULTANEOUS ${config.selectedApproaches.join(' AND ')} APPROACH RUNWAY ${primaryRunway}`; } const lastApproach = config.selectedApproaches[config.selectedApproaches.length - 1]; const otherApproaches = config.selectedApproaches.slice(0, -1); - return `EXPECT SIMULTANEOUS ${otherApproaches.join(", ")} AND ${lastApproach} APPROACH RUNWAY ${primaryRunway}`; + return `EXPECT SIMULTANEOUS ${otherApproaches.join(', ')} AND ${lastApproach} APPROACH RUNWAY ${primaryRunway}`; }; const approachText = formatApproaches(); @@ -232,16 +232,16 @@ async function generateAutoATIS( remarks2: {}, landing_runways: config.landingRunways, departing_runways: config.departingRunways, - "output-type": "atis", + 'output-type': 'atis', override_runways: false, }; const response = await fetch( `https://atisgenerator.com/api/v1/airports/${config.icao}/atis`, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), } @@ -257,13 +257,13 @@ async function generateAutoATIS( data?: { text: string }; }; - if (data.status !== "success") { - throw new Error(data.message || "Failed to generate ATIS"); + if (data.status !== 'success') { + throw new Error(data.message || 'Failed to generate ATIS'); } const generatedAtis = data.data?.text; if (!generatedAtis) { - throw new Error("No ATIS data in response"); + throw new Error('No ATIS data in response'); } const atisData = { @@ -275,26 +275,26 @@ async function generateAutoATIS( const encryptedAtis = encrypt(atisData); await updateSession(sessionId, { atis: JSON.stringify(encryptedAtis) }); - io.to(sessionId).emit("atisUpdate", { + io.to(sessionId).emit('atisUpdate', { atis: atisData, - updatedBy: "System", + updatedBy: 'System', isAutoGenerated: true, }); } catch (error) { - console.error("Error in auto ATIS generation:", error); + console.error('Error in auto ATIS generation:', error); } } export function setupSessionUsersWebsocket(httpServer: HttpServer) { const io = new SocketServer(httpServer, { - path: "/sockets/session-users", - allowRequest: createHandshakeRateLimiter({ scope: "session-users" }), + path: '/sockets/session-users', + allowRequest: createHandshakeRateLimiter({ scope: 'session-users' }), cors: { origin: [ - "http://localhost:5173", - "http://localhost:9901", - "https://pfcontrol.com", - "https://canary.pfcontrol.com", + 'http://localhost:5173', + 'http://localhost:9901', + 'https://pfcontrol.com', + 'https://canary.pfcontrol.com', ], credentials: true, }, @@ -313,7 +313,7 @@ export function setupSessionUsersWebsocket(httpServer: HttpServer) { try { await generateAutoATIS(sessionId, config as ATISConfig, io); } catch (error) { - console.error("Error auto-generating ATIS:", error); + console.error('Error auto-generating ATIS:', error); } }, 30 * 60 * 1000 @@ -326,7 +326,7 @@ export function setupSessionUsersWebsocket(httpServer: HttpServer) { const sessionEditingStates = fieldEditingStates.get(sessionId); if (sessionEditingStates) { const editingArray = Array.from(sessionEditingStates.values()); - io.to(sessionId).emit("fieldEditingUpdate", editingArray); + io.to(sessionId).emit('fieldEditingUpdate', editingArray); } }; @@ -379,7 +379,7 @@ export function setupSessionUsersWebsocket(httpServer: HttpServer) { } }; - io.on("connection", async (socket) => { + io.on('connection', async (socket) => { try { const sessionId = validateSessionId( Array.isArray(socket.handshake.query.sessionId) @@ -394,7 +394,7 @@ export function setupSessionUsersWebsocket(httpServer: HttpServer) { const user = JSON.parse( Array.isArray(socket.handshake.query.user) ? socket.handshake.query.user[0] - : socket.handshake.query.user || "{}" + : socket.handshake.query.user || '{}' ); socket.data.sessionId = sessionId; @@ -418,20 +418,20 @@ export function setupSessionUsersWebsocket(httpServer: HttpServer) { userRoles = (await getUserRoles(user.userId)).map((role) => ({ id: role.id, name: role.name, - color: role.color ?? "#000000", - icon: role.icon ?? "", + color: role.color ?? '#000000', + icon: role.icon ?? '', priority: role.priority ?? 0, })); } catch (error) { - console.error("Error fetching user roles:", error); + console.error('Error fetching user roles:', error); } if (isAdmin(user.userId)) { userRoles.unshift({ id: -1, - name: "Developer", - color: "#3B82F6", - icon: "Braces", + name: 'Developer', + color: '#3B82F6', + icon: 'Braces', priority: 999999, }); } @@ -440,9 +440,9 @@ export function setupSessionUsersWebsocket(httpServer: HttpServer) { ? socket.handshake.query.position[0] : socket.handshake.query.position; const position = - typeof rawPosition === "string" && rawPosition.length > 0 + typeof rawPosition === 'string' && rawPosition.length > 0 ? rawPosition - : "POSITION"; + : 'POSITION'; const sessionUser = { id: user.userId, @@ -467,23 +467,23 @@ export function setupSessionUsersWebsocket(httpServer: HttpServer) { socket.join(sessionId); socket.join(`user-${user.userId}`); - io.to(sessionId).emit("sessionUsersUpdate", users); + io.to(sessionId).emit('sessionUsersUpdate', users); try { const session = await getSessionById(sessionId); if (session?.atis) { const encryptedAtis = - typeof session.atis === "string" + typeof session.atis === 'string' ? JSON.parse(session.atis) : session.atis; const decryptedAtis = decrypt(encryptedAtis); - socket.emit("atisUpdate", decryptedAtis); + socket.emit('atisUpdate', decryptedAtis); } } catch (error) { - console.error("Error sending ATIS data:", error); + console.error('Error sending ATIS data:', error); } - socket.on("atisGenerated", async (atisData) => { + socket.on('atisGenerated', async (atisData) => { try { const encryptedAtis = encrypt(atisData.atis); await updateSession(sessionId, { @@ -501,29 +501,29 @@ export function setupSessionUsersWebsocket(httpServer: HttpServer) { scheduleATISGeneration(sessionId, sessionATISConfigs.get(sessionId)); - io.to(sessionId).emit("atisUpdate", { + io.to(sessionId).emit('atisUpdate', { atis: atisData.atis, updatedBy: user.username, isAutoGenerated: false, }); void onAtisChanged(sessionId); } catch (error) { - console.error("Error handling ATIS update:", error); + console.error('Error handling ATIS update:', error); } }); - socket.on("fieldEditingStart", ({ flightId, fieldName }) => { + socket.on('fieldEditingStart', ({ flightId, fieldName }) => { addFieldEditingState(sessionId, user, flightId, fieldName); }); - socket.on("fieldEditingStop", ({ flightId, fieldName }) => { + socket.on('fieldEditingStop', ({ flightId, fieldName }) => { removeFieldEditingState(sessionId, user.userId, flightId, fieldName); }); - socket.on("positionChange", async ({ position }) => { + socket.on('positionChange', async ({ position }) => { await updateUserInSession(sessionId, user.userId, { position }); const updatedUsers = await getActiveUsersForSession(sessionId); - io.to(sessionId).emit("sessionUsersUpdate", updatedUsers); + io.to(sessionId).emit('sessionUsersUpdate', updatedUsers); void onSessionUsersChangedInvalidate(sessionId, updatedUsers.length); }); @@ -534,12 +534,12 @@ export function setupSessionUsersWebsocket(httpServer: HttpServer) { totalActive: 0, }); - socket.on("activityPing", () => { + socket.on('activityPing', () => { const entry = userActivity.get(userKey); if (entry) entry.lastActive = Date.now(); }); - socket.on("disconnect", async () => { + socket.on('disconnect', async () => { const entry = userActivity.get(userKey); if (entry) { const now = Date.now(); @@ -550,7 +550,7 @@ export function setupSessionUsersWebsocket(httpServer: HttpServer) { entry.totalActive += remainingActiveTime; incrementStat( user.userId, - "total_time_controlling_minutes", + 'total_time_controlling_minutes', entry.totalActive ); userActivity.delete(userKey); @@ -560,7 +560,7 @@ export function setupSessionUsersWebsocket(httpServer: HttpServer) { const updatedUsers = await getActiveUsersForSession(sessionId); await syncActiveSessionRegistry(sessionId, updatedUsers.length); void onSessionUsersChangedInvalidate(sessionId, updatedUsers.length); - io.to(sessionId).emit("sessionUsersUpdate", updatedUsers); + io.to(sessionId).emit('sessionUsersUpdate', updatedUsers); const sessionStates = fieldEditingStates.get(sessionId); if (sessionStates) { @@ -573,23 +573,23 @@ export function setupSessionUsersWebsocket(httpServer: HttpServer) { } }); } catch (error) { - const msg = error instanceof Error ? error.message : ""; - if (!msg.startsWith("Invalid") && !msg.endsWith("is required")) { - console.error("Error in websocket connection:", error); + const msg = error instanceof Error ? error.message : ''; + if (!msg.startsWith('Invalid') && !msg.endsWith('is required')) { + console.error('Error in websocket connection:', error); } socket.disconnect(true); } }); io.sendMentionToUser = (userId: string, mention: unknown) => { - io.to(`user-${userId}`).emit("chatMention", mention); + io.to(`user-${userId}`).emit('chatMention', mention); }; io.getActiveUsersForSession = getActiveUsersForSession; // Cleanup on shutdown - process.on("SIGTERM", () => { - console.log("[SessionUsers] Cleaning up timers..."); + process.on('SIGTERM', () => { + console.log('[SessionUsers] Cleaning up timers...'); clearInterval(sessionCleanupInterval); for (const timer of atisTimers.values()) { clearInterval(timer); @@ -601,4 +601,4 @@ export function setupSessionUsersWebsocket(httpServer: HttpServer) { }); return io; -} \ No newline at end of file +} diff --git a/server/websockets/voiceChatWebsocket.ts b/server/websockets/voiceChatWebsocket.ts index 44247f3e..51c570eb 100644 --- a/server/websockets/voiceChatWebsocket.ts +++ b/server/websockets/voiceChatWebsocket.ts @@ -51,14 +51,14 @@ export function setupVoiceChatWebsocket(httpServer: Server) { const getSessionUsersData = (sessionId: string) => { const users = voiceUsers.get(sessionId); if (!users) return []; - return Array.from(users.values()).map(u => ({ + return Array.from(users.values()).map((u) => ({ userId: u.userId, username: u.username, avatar: u.avatar, isMuted: u.isMuted, isDeafened: u.isDeafened, isTalking: u.isTalking, - audioLevel: u.audioLevel + audioLevel: u.audioLevel, })); }; @@ -67,7 +67,11 @@ export function setupVoiceChatWebsocket(httpServer: Server) { }; io.on('connection', async (socket) => { - const { sessionId: rawSessionId, accessId: rawAccessId, userId } = socket.handshake.query; + const { + sessionId: rawSessionId, + accessId: rawAccessId, + userId, + } = socket.handshake.query; try { if (!userId || typeof userId !== 'string') { @@ -75,8 +79,12 @@ export function setupVoiceChatWebsocket(httpServer: Server) { return; } - const sessionId = validateSessionId(Array.isArray(rawSessionId) ? rawSessionId[0] : rawSessionId as string); - const accessId = validateAccessId(Array.isArray(rawAccessId) ? rawAccessId[0] : rawAccessId as string); + const sessionId = validateSessionId( + Array.isArray(rawSessionId) ? rawSessionId[0] : (rawSessionId as string) + ); + const accessId = validateAccessId( + Array.isArray(rawAccessId) ? rawAccessId[0] : (rawAccessId as string) + ); const valid = await validateSessionAccess(sessionId, accessId); if (!valid) { @@ -88,7 +96,8 @@ export function setupVoiceChatWebsocket(httpServer: Server) { socket.data.userId = userId; socket.join(sessionId); - if (!sessionSocketMap.has(sessionId)) sessionSocketMap.set(sessionId, new Map()); + if (!sessionSocketMap.has(sessionId)) + sessionSocketMap.set(sessionId, new Map()); sessionSocketMap.get(sessionId)!.set(userId, socket.id); socket.on('get-voice-users', () => { @@ -110,7 +119,8 @@ export function setupVoiceChatWebsocket(httpServer: Server) { } if (!voiceUsers.has(sessionId)) voiceUsers.set(sessionId, new Map()); - if (!sessionSocketMap.has(sessionId)) sessionSocketMap.set(sessionId, new Map()); + if (!sessionSocketMap.has(sessionId)) + sessionSocketMap.set(sessionId, new Map()); const usersInSession = voiceUsers.get(sessionId)!; const socketsInSession = sessionSocketMap.get(sessionId)!; @@ -152,23 +162,35 @@ export function setupVoiceChatWebsocket(httpServer: Server) { }); socket.on('voice-offer', ({ targetUserId, offer }) => { - const targetSocketId = sessionSocketMap.get(sessionId)?.get(targetUserId); + const targetSocketId = sessionSocketMap + .get(sessionId) + ?.get(targetUserId); if (targetSocketId) { - socket.to(targetSocketId).emit('voice-offer', { fromUserId: userId, offer }); + socket + .to(targetSocketId) + .emit('voice-offer', { fromUserId: userId, offer }); } }); socket.on('voice-answer', ({ targetUserId, answer }) => { - const targetSocketId = sessionSocketMap.get(sessionId)?.get(targetUserId); + const targetSocketId = sessionSocketMap + .get(sessionId) + ?.get(targetUserId); if (targetSocketId) { - socket.to(targetSocketId).emit('voice-answer', { fromUserId: userId, answer }); + socket + .to(targetSocketId) + .emit('voice-answer', { fromUserId: userId, answer }); } }); socket.on('ice-candidate', ({ targetUserId, candidate }) => { - const targetSocketId = sessionSocketMap.get(sessionId)?.get(targetUserId); + const targetSocketId = sessionSocketMap + .get(sessionId) + ?.get(targetUserId); if (targetSocketId) { - socket.to(targetSocketId).emit('ice-candidate', { fromUserId: userId, candidate }); + socket + .to(targetSocketId) + .emit('ice-candidate', { fromUserId: userId, candidate }); } }); @@ -197,7 +219,9 @@ export function setupVoiceChatWebsocket(httpServer: Server) { user.lastActivity = Date.now(); if (stateChanged) { - socket.to(sessionId).emit('user-talking-state', { userId, isTalking }); + socket + .to(sessionId) + .emit('user-talking-state', { userId, isTalking }); } } }); @@ -208,7 +232,9 @@ export function setupVoiceChatWebsocket(httpServer: Server) { if (users?.has(userId)) { // Reconnection guard: only clean up if this socket is the active one if (users.get(userId)?.socketId !== socket.id) { - console.log(`[VoiceChat] User ${userId} leaving voice from stale socket ${socket.id}, ignoring.`); + console.log( + `[VoiceChat] User ${userId} leaving voice from stale socket ${socket.id}, ignoring.` + ); return; } @@ -235,12 +261,15 @@ export function setupVoiceChatWebsocket(httpServer: Server) { socket.on('disconnect', handleDisconnect); socket.on('request-reconnection', ({ targetUserId }) => { - const targetSocketId = sessionSocketMap.get(sessionId)?.get(targetUserId); + const targetSocketId = sessionSocketMap + .get(sessionId) + ?.get(targetUserId); if (targetSocketId) { - io.to(targetSocketId).emit('reconnection-requested', { fromUserId: userId }); + io.to(targetSocketId).emit('reconnection-requested', { + fromUserId: userId, + }); } }); - } catch (err) { const msg = err instanceof Error ? err.message : ''; if (!msg.startsWith('Invalid') && !msg.endsWith('is required')) { diff --git a/src/App.tsx b/src/App.tsx index fc28ae5d..0d5816cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,59 +3,59 @@ import { Route, Routes, Navigate, -} from "react-router-dom"; -import { lazy, Suspense } from "react"; -import { useAuth } from "./hooks/auth/useAuth"; +} from 'react-router-dom'; +import { lazy, Suspense } from 'react'; +import { useAuth } from './hooks/auth/useAuth'; -import Home from "./pages/Home"; -import Create from "./pages/Create"; -import Sessions from "./pages/Sessions"; -import Submit from "./pages/Submit"; -import Flights from "./pages/Flights"; -import MyFlights from "./pages/MyFlights"; -import MyFlightDetail from "./pages/MyFlightDetail"; -import Settings from "./pages/Settings"; -import PFATCFlights from "./pages/PFATCFlights"; -import ACARS from "./pages/ACARS"; -import PilotProfile from "./pages/PilotProfile"; -import PublicFlightView from "./pages/PublicFlightView"; +import Home from './pages/Home'; +import Create from './pages/Create'; +import Sessions from './pages/Sessions'; +import Submit from './pages/Submit'; +import Flights from './pages/Flights'; +import MyFlights from './pages/MyFlights'; +import MyFlightDetail from './pages/MyFlightDetail'; +import Settings from './pages/Settings'; +import PFATCFlights from './pages/PFATCFlights'; +import ACARS from './pages/ACARS'; +import PilotProfile from './pages/PilotProfile'; +import PublicFlightView from './pages/PublicFlightView'; -import Login from "./pages/Login"; -import VatsimCallback from "./pages/VatsimCallback"; -import NotFound from "./pages/NotFound"; +import Login from './pages/Login'; +import VatsimCallback from './pages/VatsimCallback'; +import NotFound from './pages/NotFound'; -import ProtectedRoute from "./components/ProtectedRoute"; -import AccessDenied from "./components/AccessDenied"; -import Loader from "./components/common/Loader"; -import AppOverlays from "./components/AppOverlays"; -import PostHogPageView from "./components/PostHogPageView"; +import ProtectedRoute from './components/ProtectedRoute'; +import AccessDenied from './components/AccessDenied'; +import Loader from './components/common/Loader'; +import AppOverlays from './components/AppOverlays'; +import PostHogPageView from './components/PostHogPageView'; -const Admin = lazy(() => import("./pages/Admin")); -const AdminUsers = lazy(() => import("./pages/admin/AdminUsers")); -const AdminAudit = lazy(() => import("./pages/admin/AdminAudit")); -const AdminBan = lazy(() => import("./pages/admin/AdminBan")); -const AdminSessions = lazy(() => import("./pages/admin/AdminSessions")); -const AdminTesters = lazy(() => import("./pages/admin/AdminTesters")); +const Admin = lazy(() => import('./pages/Admin')); +const AdminUsers = lazy(() => import('./pages/admin/AdminUsers')); +const AdminAudit = lazy(() => import('./pages/admin/AdminAudit')); +const AdminBan = lazy(() => import('./pages/admin/AdminBan')); +const AdminSessions = lazy(() => import('./pages/admin/AdminSessions')); +const AdminTesters = lazy(() => import('./pages/admin/AdminTesters')); const AdminNotifications = lazy( - () => import("./pages/admin/AdminNotifications") + () => import('./pages/admin/AdminNotifications') ); -const AdminRoles = lazy(() => import("./pages/admin/AdminRoles")); -const AdminChatReports = lazy(() => import("./pages/admin/AdminChatReports")); -const AdminFlightLogs = lazy(() => import("./pages/admin/AdminFlightLogs")); -const AdminFeedback = lazy(() => import("./pages/admin/AdminFeedback")); -const AdminApiLogs = lazy(() => import("./pages/admin/AdminApiLogs")); -const AdminRatings = lazy(() => import("./pages/admin/AdminRatings")); -const AdminAltDetection = lazy(() => import("./pages/admin/AdminAltDetection")); -const AdminDevelopers = lazy(() => import("./pages/admin/AdminDevelopers")); -const AdminWebsockets = lazy(() => import("./pages/admin/AdminWebsockets")); -const AdminDatabase = lazy(() => import("./pages/admin/AdminDatabase")); +const AdminRoles = lazy(() => import('./pages/admin/AdminRoles')); +const AdminChatReports = lazy(() => import('./pages/admin/AdminChatReports')); +const AdminFlightLogs = lazy(() => import('./pages/admin/AdminFlightLogs')); +const AdminFeedback = lazy(() => import('./pages/admin/AdminFeedback')); +const AdminApiLogs = lazy(() => import('./pages/admin/AdminApiLogs')); +const AdminRatings = lazy(() => import('./pages/admin/AdminRatings')); +const AdminAltDetection = lazy(() => import('./pages/admin/AdminAltDetection')); +const AdminDevelopers = lazy(() => import('./pages/admin/AdminDevelopers')); +const AdminWebsockets = lazy(() => import('./pages/admin/AdminWebsockets')); +const AdminDatabase = lazy(() => import('./pages/admin/AdminDatabase')); const DeveloperLayout = lazy( - () => import("./pages/developers/DeveloperLayout") + () => import('./pages/developers/DeveloperLayout') ); -const DeveloperOverview = lazy(() => import("./pages/developers/Overview")); -const DeveloperConsole = lazy(() => import("./pages/developers/Console")); -const DeveloperKeys = lazy(() => import("./pages/developers/Keys")); -const DeveloperDocs = lazy(() => import("./pages/developers/Docs")); +const DeveloperOverview = lazy(() => import('./pages/developers/Overview')); +const DeveloperConsole = lazy(() => import('./pages/developers/Console')); +const DeveloperKeys = lazy(() => import('./pages/developers/Keys')); +const DeveloperDocs = lazy(() => import('./pages/developers/Docs')); export default function App() { const { user } = useAuth(); @@ -311,4 +311,4 @@ export default function App() { )} ); -} \ No newline at end of file +} diff --git a/src/components/AccessDenied.tsx b/src/components/AccessDenied.tsx index ee449079..eedd79a7 100644 --- a/src/components/AccessDenied.tsx +++ b/src/components/AccessDenied.tsx @@ -151,4 +151,4 @@ export default function AccessDenied({ ); -} \ No newline at end of file +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 05500b0e..ff643f94 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -274,4 +274,4 @@ export default function Footer() { ); -} \ No newline at end of file +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 8568e85f..f935f1e4 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -657,4 +657,4 @@ export default function Navbar({ )} ); -} \ No newline at end of file +} diff --git a/src/components/PostHogErrorFallback.tsx b/src/components/PostHogErrorFallback.tsx index d025c81b..c9cb80c6 100644 --- a/src/components/PostHogErrorFallback.tsx +++ b/src/components/PostHogErrorFallback.tsx @@ -1,13 +1,18 @@ -import type { PostHogErrorBoundaryFallbackProps } from "@posthog/react"; +import type { PostHogErrorBoundaryFallbackProps } from '@posthog/react'; -export default function PostHogErrorFallback({ error }: PostHogErrorBoundaryFallbackProps) { - const message = error instanceof Error ? error.message : "An unexpected error occurred"; +export default function PostHogErrorFallback({ + error, +}: PostHogErrorBoundaryFallbackProps) { + const message = + error instanceof Error ? error.message : 'An unexpected error occurred'; return (
-

Something went wrong

+

+ Something went wrong +

- This error was reported automatically. Try refreshing the page. If it keeps happening, - contact support. + This error was reported automatically. Try refreshing the page. If it + keeps happening, contact support.

{import.meta.env.DEV ? (
@@ -16,4 +21,4 @@ export default function PostHogErrorFallback({ error }: PostHogErrorBoundaryFall
       ) : null}
     
); -} \ No newline at end of file +} diff --git a/src/components/Settings/BackgroundImageSettings.tsx b/src/components/Settings/BackgroundImageSettings.tsx index 549313fa..cde8a017 100644 --- a/src/components/Settings/BackgroundImageSettings.tsx +++ b/src/components/Settings/BackgroundImageSettings.tsx @@ -150,7 +150,9 @@ export default function BackgroundImageSettings({ onChange, }: BackgroundImageSettingsProps) { const [availableImages, setAvailableImages] = useState([]); - const [cephieSnapImages, setCephieSnapImages] = useState([]); + const [cephieSnapImages, setCephieSnapImages] = useState( + [] + ); const [loadingImages, setLoadingImages] = useState(false); const [loadingCephieSnap, setLoadingCephieSnap] = useState(false); const [uploading, setUploading] = useState(false); @@ -167,9 +169,12 @@ export default function BackgroundImageSettings({ const loadCephieSnapImages = async () => { try { setLoadingCephieSnap(true); - const res = await fetch(`${API_BASE_URL}/api/uploads/cephie-snap-images`, { - credentials: 'include', - }); + const res = await fetch( + `${API_BASE_URL}/api/uploads/cephie-snap-images`, + { + credentials: 'include', + } + ); if (!res.ok) throw new Error('Failed to load'); const data = await res.json(); setCephieSnapImages(data.images ?? []); @@ -524,12 +529,16 @@ export default function BackgroundImageSettings({ {loadingCephieSnap ? (
- Loading your Snap pictures... + + Loading your Snap pictures... +
) : cephieSnapImages.length === 0 ? (
-

No Cephie Snap pictures yet.

+

+ No Cephie Snap pictures yet. +

- {cephieSnapImages.map((img) => { - const isSelected = selectedImage === img.url; - return ( -
handleSelectImage(img.url)} - > -
- Cephie Snap - {isSelected && ( -
- -
- )} -
+ {cephieSnapImages.map((img) => { + const isSelected = selectedImage === img.url; + return ( +
handleSelectImage(img.url)} + > +
+ Cephie Snap + {isSelected && ( +
+ +
+ )} +
+
-
- ); - })} + ); + })}
)} diff --git a/src/components/admin/AdminChart.tsx b/src/components/admin/AdminChart.tsx index 6879ea04..b05f6b9f 100644 --- a/src/components/admin/AdminChart.tsx +++ b/src/components/admin/AdminChart.tsx @@ -1,4 +1,4 @@ -import { useId, useMemo } from "react"; +import { useId, useMemo } from 'react'; import { Area, AreaChart, @@ -8,10 +8,10 @@ import { Tooltip, XAxis, YAxis, -} from "recharts"; +} from 'recharts'; const TOOLTIP_PANEL = - "rounded-xl border border-zinc-700 bg-zinc-950 px-3.5 py-2.5 shadow-2xl shadow-black/50 text-sm text-zinc-200"; + 'rounded-xl border border-zinc-700 bg-zinc-950 px-3.5 py-2.5 shadow-2xl shadow-black/50 text-sm text-zinc-200'; export type AdminChartPoint = { label: string; @@ -55,9 +55,9 @@ function shortDateLabel(raw: string | Date | number): string { const d = new Date(`${s}T12:00:00`); if (!Number.isNaN(d.getTime())) { return d.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", + month: 'short', + day: 'numeric', + year: 'numeric', }); } } @@ -67,32 +67,32 @@ function shortDateLabel(raw: string | Date | number): string { const d = new Date(`${ymd}T12:00:00`); if (!Number.isNaN(d.getTime())) { return d.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", + month: 'short', + day: 'numeric', + year: 'numeric', }); } } - if (s.includes("T") || /^\d{4}-\d{2}-\d{2}/.test(s)) { + if (s.includes('T') || /^\d{4}-\d{2}-\d{2}/.test(s)) { const d = new Date(s); if (!Number.isNaN(d.getTime())) { return d.toLocaleString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', }); } } - if (s.includes(":")) { + if (s.includes(':')) { const d = new Date(s); if (!Number.isNaN(d.getTime())) { return d.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", + hour: '2-digit', + minute: '2-digit', }); } } @@ -112,7 +112,7 @@ function ChartLegend({ series }: { series: AdminChartSeries[] }) { className="inline-block w-4 shrink-0 border-t-2" style={{ borderColor: s.color, - borderStyle: s.strokeDasharray ? "dashed" : "solid", + borderStyle: s.strokeDasharray ? 'dashed' : 'solid', }} /> {s.label} @@ -124,21 +124,21 @@ function ChartLegend({ series }: { series: AdminChartSeries[] }) { export function AdminAreaChart({ data, - dataKey = "value", - color = "#60a5fa", + dataKey = 'value', + color = '#60a5fa', height = 280, - emptyLabel = "No data for this period", - valueLabel = "Count", + emptyLabel = 'No data for this period', + valueLabel = 'Count', hideAxes = true, }: AdminAreaChartProps) { - const gid = useId().replace(/:/g, ""); + const gid = useId().replace(/:/g, ''); const gradientId = `adminFill-${gid}`; const points = useMemo( () => data.map((d) => ({ ...d, - label: d.label ?? String(d[dataKey] ?? ""), + label: d.label ?? String(d[dataKey] ?? ''), })), [data, dataKey] ); @@ -185,14 +185,14 @@ export function AdminAreaChart({ shortDateLabel(String(v))} />

- {valueLabel}:{" "} + {valueLabel}:{' '} {Number(payload[0]?.value ?? 0).toLocaleString()}

@@ -223,7 +223,7 @@ export function AdminAreaChart({ strokeWidth={2} fill={`url(#${gradientId})`} dot={false} - activeDot={{ r: 4, fill: color, stroke: "#18181b", strokeWidth: 2 }} + activeDot={{ r: 4, fill: color, stroke: '#18181b', strokeWidth: 2 }} /> @@ -234,14 +234,14 @@ export function AdminAreaChart({ export function AdminMultiSeriesAreaChart({ data, series, - xKey = "label", + xKey = 'label', height = 280, - emptyLabel = "No data for this period", + emptyLabel = 'No data for this period', hideAxes = true, showLegend = false, filled = true, }: AdminMultiSeriesChartProps) { - const gid = useId().replace(/:/g, ""); + const gid = useId().replace(/:/g, ''); const gradientPrefix = `adminMultiFill-${gid}`; const Chart = filled ? AreaChart : LineChart; const margin = { @@ -291,14 +291,14 @@ export function AdminMultiSeriesAreaChart({ shortDateLabel(String(v))} /> @@ -357,7 +357,7 @@ export function AdminMultiSeriesAreaChart({ activeDot={{ r: 3, strokeWidth: 2, - stroke: "#18181b", + stroke: '#18181b', }} /> ) @@ -372,7 +372,7 @@ export function AdminMultiSeriesAreaChart({ export function AdminSparkline({ data, - color = "#60a5fa", + color = '#60a5fa', height = 48, }: { data: number[]; @@ -406,4 +406,4 @@ export function AdminSparkline({
); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminDeveloperApplicationReviewModal.tsx b/src/components/admin/AdminDeveloperApplicationReviewModal.tsx index f98443d9..adf44681 100644 --- a/src/components/admin/AdminDeveloperApplicationReviewModal.tsx +++ b/src/components/admin/AdminDeveloperApplicationReviewModal.tsx @@ -1,15 +1,15 @@ -import { useEffect, useState } from "react"; -import { MdRefresh } from "react-icons/md"; -import AdminModal from "./AdminModal"; -import AdminSectionTitle from "./AdminSectionTitle"; -import { adminDownsizeButtonSize, adminSectionClass } from "./adminConstants"; -import Button from "../common/Button"; -import ScopeTagSelector from "../developers/ScopeTagSelector"; +import { useEffect, useState } from 'react'; +import { MdRefresh } from 'react-icons/md'; +import AdminModal from './AdminModal'; +import AdminSectionTitle from './AdminSectionTitle'; +import { adminDownsizeButtonSize, adminSectionClass } from './adminConstants'; +import Button from '../common/Button'; +import ScopeTagSelector from '../developers/ScopeTagSelector'; import { fetchAdminDeveloperCatalog, type AdminDeveloperApplication, type AdminScopeCatalogEntry, -} from "../../utils/fetch/adminDevelopers"; +} from '../../utils/fetch/adminDevelopers'; type Props = { application: AdminDeveloperApplication; @@ -37,8 +37,8 @@ export default function AdminDeveloperApplicationReviewModal({ () => new Set(application.requestedScopes) ); const [touchDefaultRpm, setTouchDefaultRpm] = useState(false); - const [rpmText, setRpmText] = useState(""); - const [note, setNote] = useState(""); + const [rpmText, setRpmText] = useState(''); + const [note, setNote] = useState(''); const [localError, setLocalError] = useState(null); useEffect(() => { @@ -56,15 +56,15 @@ export default function AdminDeveloperApplicationReviewModal({ useEffect(() => { setApprovedScopes(new Set(application.requestedScopes)); setTouchDefaultRpm(false); - setRpmText(""); - setNote(""); + setRpmText(''); + setNote(''); setLocalError(null); }, [application.id, application.requestedScopes]); const submitApprove = async () => { setLocalError(null); if (approvedScopes.size === 0) { - setLocalError("Select at least one scope to approve."); + setLocalError('Select at least one scope to approve.'); return; } const body: { @@ -76,13 +76,13 @@ export default function AdminDeveloperApplicationReviewModal({ note: note.trim() ? note.trim() : undefined, }; if (touchDefaultRpm) { - if (rpmText.trim() === "") { + if (rpmText.trim() === '') { body.rateLimitPerMinute = null; } else { const n = Math.floor(Number(rpmText.trim())); if (!Number.isFinite(n) || n < 0) { setLocalError( - "Default RPM must be a non-negative number, or leave blank for site default." + 'Default RPM must be a non-negative number, or leave blank for site default.' ); return; } @@ -92,7 +92,7 @@ export default function AdminDeveloperApplicationReviewModal({ try { await onApprove(body); } catch (e) { - setLocalError(e instanceof Error ? e.message : "Approve failed"); + setLocalError(e instanceof Error ? e.message : 'Approve failed'); } }; @@ -107,7 +107,7 @@ export default function AdminDeveloperApplicationReviewModal({
- {developer.status === "active" && onProfileSuspend && ( + {developer.status === 'active' && onProfileSuspend && ( - {tab === "ceiling" && ( + {tab === 'ceiling' && (

Scopes checked here are the maximum this developer can assign to @@ -383,15 +383,15 @@ export default function AdminDeveloperEditModal({ ) : ( <> - {" "} - {ceilingBusy ? "Saving…" : "Save ceiling"} + {' '} + {ceilingBusy ? 'Saving…' : 'Save ceiling'} )}

)} - {tab === "keys" && ( + {tab === 'keys' && (
{keysLoading ? (
@@ -414,7 +414,7 @@ export default function AdminDeveloperEditModal({ {keys.map((k) => { - const st = k.revokedAt ? "revoked" : (k.status ?? "active"); + const st = k.revokedAt ? 'revoked' : (k.status ?? 'active'); return ( @@ -433,24 +433,24 @@ export default function AdminDeveloperEditModal({ - {k.rateLimitPerMinute ?? "—"} + {k.rateLimitPerMinute ?? '—'} {k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() - : "—"} + : '—'} {k.revokedAt ? ( Revoked - ) : k.status === "pending" ? ( + ) : k.status === 'pending' ? (
- ) : k.status === "active" ? ( + ) : k.status === 'active' ? (
)} - {developer.status !== "active" && onProfileReactivate && ( + {developer.status !== 'active' && onProfileReactivate && (
- ) : k.status === "active" ? ( + ) : k.status === 'active' ? (
); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminDurationPresets.tsx b/src/components/admin/AdminDurationPresets.tsx index 71a41a9e..657cd8f2 100644 --- a/src/components/admin/AdminDurationPresets.tsx +++ b/src/components/admin/AdminDurationPresets.tsx @@ -2,27 +2,27 @@ import { ADMIN_SEGMENT_ACTIVE, ADMIN_SEGMENT_INACTIVE, ADMIN_TOOLBAR_HEIGHT, -} from "./adminConstants"; -import { ADMIN_DURATION_PRESETS } from "./adminDurationPresetConfig"; -import type { AdminDurationPresetId } from "./adminDurationPresetConfig"; +} from './adminConstants'; +import { ADMIN_DURATION_PRESETS } from './adminDurationPresetConfig'; +import type { AdminDurationPresetId } from './adminDurationPresetConfig'; type AdminDurationPresetsProps = { label?: string; activePreset: AdminDurationPresetId | null; onPreset: ( durationMs: number, - presetId: (typeof ADMIN_DURATION_PRESETS)[number]["id"] + presetId: (typeof ADMIN_DURATION_PRESETS)[number]['id'] ) => void; onPermanent: () => void; className?: string; }; export default function AdminDurationPresets({ - label = "Quick duration", + label = 'Quick duration', activePreset, onPreset, onPermanent, - className = "", + className = '', }: AdminDurationPresetsProps) { return (
@@ -51,10 +51,10 @@ export default function AdminDurationPresets({ ))}
); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminIconInput.tsx b/src/components/admin/AdminIconInput.tsx index 67abaffd..01d6cc3d 100644 --- a/src/components/admin/AdminIconInput.tsx +++ b/src/components/admin/AdminIconInput.tsx @@ -1,24 +1,24 @@ -import type { ReactNode } from "react"; +import type { ReactNode } from 'react'; import { ADMIN_DATETIME_INPUT, ADMIN_DATETIME_INPUT_ICON, ADMIN_FIELD_INPUT, ADMIN_FIELD_INPUT_ICON, ADMIN_INPUT_ICON_CLASS, -} from "./adminConstants"; +} from './adminConstants'; type AdminIconInputProps = { icon: ReactNode; value: string; onChange: (value: string) => void; placeholder?: string; - type?: "text" | "date" | "datetime-local" | "search"; + type?: 'text' | 'date' | 'datetime-local' | 'search'; disabled?: boolean; className?: string; inputClassName?: string; label?: string; required?: boolean; - "aria-label"?: string; + 'aria-label'?: string; }; export default function AdminIconInput({ @@ -26,22 +26,22 @@ export default function AdminIconInput({ value, onChange, placeholder, - type = "text", + type = 'text', disabled = false, - className = "", - inputClassName = "", + className = '', + inputClassName = '', label, required = false, - "aria-label": ariaLabel, + 'aria-label': ariaLabel, }: AdminIconInputProps) { - const isDate = type === "date" || type === "datetime-local"; + const isDate = type === 'date' || type === 'datetime-local'; const inputClass = isDate ? `${ADMIN_DATETIME_INPUT_ICON} ${inputClassName}`.trim() : `${ADMIN_FIELD_INPUT_ICON} ${inputClassName}`.trim(); const field = (
{icon} @@ -74,4 +74,4 @@ export default function AdminIconInput({ ); } -export { ADMIN_FIELD_INPUT, ADMIN_DATETIME_INPUT }; \ No newline at end of file +export { ADMIN_FIELD_INPUT, ADMIN_DATETIME_INPUT }; diff --git a/src/components/admin/AdminLayout.tsx b/src/components/admin/AdminLayout.tsx index 94646931..33dee008 100644 --- a/src/components/admin/AdminLayout.tsx +++ b/src/components/admin/AdminLayout.tsx @@ -1,12 +1,12 @@ -import { useState, type ReactNode } from "react"; -import { MdMenu } from "react-icons/md"; -import Navbar from "../Navbar"; -import AdminSidebar from "./AdminSidebar"; -import Toast from "../common/Toast"; +import { useState, type ReactNode } from 'react'; +import { MdMenu } from 'react-icons/md'; +import Navbar from '../Navbar'; +import AdminSidebar from './AdminSidebar'; +import Toast from '../common/Toast'; export type AdminToast = { message: string; - type: "success" | "error" | "info"; + type: 'success' | 'error' | 'info'; } | null; type AdminLayoutProps = { @@ -45,7 +45,7 @@ export default function AdminLayout({
void; title: string; children: ReactNode; - size?: "sm" | "md" | "lg" | "xl" | "full"; + size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'; footer?: ReactNode; }; const SIZE_CLASS = { - sm: "max-w-md", - md: "max-w-lg", - lg: "max-w-2xl", - xl: "max-w-4xl", - full: "max-w-6xl", + sm: 'max-w-md', + md: 'max-w-lg', + lg: 'max-w-2xl', + xl: 'max-w-4xl', + full: 'max-w-6xl', }; export default function AdminModal({ @@ -23,19 +23,19 @@ export default function AdminModal({ onClose, title, children, - size = "lg", + size = 'lg', footer, }: AdminModalProps) { useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === 'Escape') onClose(); }; - document.addEventListener("keydown", onKey); - document.body.style.overflow = "hidden"; + document.addEventListener('keydown', onKey); + document.body.style.overflow = 'hidden'; return () => { - document.removeEventListener("keydown", onKey); - document.body.style.overflow = ""; + document.removeEventListener('keydown', onKey); + document.body.style.overflow = ''; }; }, [open, onClose]); @@ -81,4 +81,4 @@ export default function AdminModal({
); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminPageHeader.tsx b/src/components/admin/AdminPageHeader.tsx index 2468acba..e494bfb2 100644 --- a/src/components/admin/AdminPageHeader.tsx +++ b/src/components/admin/AdminPageHeader.tsx @@ -1,10 +1,10 @@ -import type { ReactNode } from "react"; -import type { IconType } from "react-icons"; +import type { ReactNode } from 'react'; +import type { IconType } from 'react-icons'; import { adminPageIconClass, adminPageTitleClass, type AdminPageAccent, -} from "./adminConstants"; +} from './adminConstants'; type AdminPageHeaderProps = { title: string; @@ -19,18 +19,22 @@ type AdminPageHeaderProps = { export default function AdminPageHeader({ title, icon: Icon, - accent = "blue", + accent = 'blue', iconClassName, titleClassName, actions, - actionsClassName = "", + actionsClassName = '', }: AdminPageHeaderProps) { const iconCls = iconClassName ?? adminPageIconClass(accent); const titleCls = titleClassName ?? adminPageTitleClass(accent); return (
- {Icon && } + {Icon && ( + + )}

{title}

{actions && (
); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminRefreshButton.tsx b/src/components/admin/AdminRefreshButton.tsx index f9b78ef5..342591cc 100644 --- a/src/components/admin/AdminRefreshButton.tsx +++ b/src/components/admin/AdminRefreshButton.tsx @@ -1,6 +1,6 @@ -import { MdRefresh } from "react-icons/md"; -import Button from "../common/Button"; -import { ADMIN_TOOLBAR_HEIGHT } from "./adminConstants"; +import { MdRefresh } from 'react-icons/md'; +import Button from '../common/Button'; +import { ADMIN_TOOLBAR_HEIGHT } from './adminConstants'; type AdminRefreshButtonProps = { onClick: () => void; @@ -15,9 +15,9 @@ export default function AdminRefreshButton({ onClick, disabled, loading = false, - label = "Refresh", + label = 'Refresh', iconOnly = false, - className = "", + className = '', }: AdminRefreshButtonProps) { return ( ); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminSearchInput.tsx b/src/components/admin/AdminSearchInput.tsx index 00a4b6a2..51b9fee2 100644 --- a/src/components/admin/AdminSearchInput.tsx +++ b/src/components/admin/AdminSearchInput.tsx @@ -1,5 +1,5 @@ -import { MdRefresh, MdSearch } from "react-icons/md"; -import { ADMIN_INPUT_ICON_CLASS, ADMIN_SEARCH_INPUT } from "./adminConstants"; +import { MdRefresh, MdSearch } from 'react-icons/md'; +import { ADMIN_INPUT_ICON_CLASS, ADMIN_SEARCH_INPUT } from './adminConstants'; type AdminSearchInputProps = { value: string; @@ -13,14 +13,14 @@ type AdminSearchInputProps = { export default function AdminSearchInput({ value, onChange, - placeholder = "Search…", + placeholder = 'Search…', loading = false, - className = "", + className = '', grow = true, }: AdminSearchInputProps) { return (
@@ -37,8 +37,8 @@ export default function AdminSearchInput({ value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} - className={`${ADMIN_SEARCH_INPUT} ${loading ? "!pr-10" : ""}`} + className={`${ADMIN_SEARCH_INPUT} ${loading ? '!pr-10' : ''}`} />
); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminSectionTitle.tsx b/src/components/admin/AdminSectionTitle.tsx index b338e8c4..c1e6a753 100644 --- a/src/components/admin/AdminSectionTitle.tsx +++ b/src/components/admin/AdminSectionTitle.tsx @@ -1,8 +1,8 @@ -import { ADMIN_SECTION_TITLE } from "./adminConstants"; +import { ADMIN_SECTION_TITLE } from './adminConstants'; export default function AdminSectionTitle({ children, - className = "", + className = '', }: { children: React.ReactNode; className?: string; @@ -10,4 +10,4 @@ export default function AdminSectionTitle({ return (

{children}

); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminSidebar.tsx b/src/components/admin/AdminSidebar.tsx index e0474a90..8554e18a 100644 --- a/src/components/admin/AdminSidebar.tsx +++ b/src/components/admin/AdminSidebar.tsx @@ -1,4 +1,4 @@ -import { Link, useLocation } from "react-router-dom"; +import { Link, useLocation } from 'react-router-dom'; import { MdBarChart, MdPeople, @@ -23,18 +23,18 @@ import { MdCode, MdCable, MdQueryStats, -} from "react-icons/md"; -import type { IconType } from "react-icons"; -import { useState, useEffect } from "react"; -import { useAuth } from "../../hooks/auth/useAuth"; -import { navActiveClass } from "./adminConstants"; +} from 'react-icons/md'; +import type { IconType } from 'react-icons'; +import { useState, useEffect } from 'react'; +import { useAuth } from '../../hooks/auth/useAuth'; +import { navActiveClass } from './adminConstants'; interface AdminSidebarProps { collapsed?: boolean; onToggle?: () => void; } -const SIDEBAR_STORAGE_KEY = "admin-sidebar-collapsed"; +const SIDEBAR_STORAGE_KEY = 'admin-sidebar-collapsed'; type NavItem = { icon: IconType; @@ -97,24 +97,24 @@ export default function AdminSidebar({ if (user?.isAdmin) return true; const perms = (user?.rolePermissions ?? {}) as Record; const checkVal = (v: unknown) => - v === true || v === "true" || v === "1" || v === 1; + v === true || v === 'true' || v === '1' || v === 1; if (checkVal(perms[permission])) return true; const aliases: Record = { - admin: ["admin", "overview"], - users: ["users", "user_management"], - sessions: ["sessions", "session_management"], - notifications: ["notifications", "update_notifications", "update_modals"], - update_modals: ["update_modals", "update_notifications"], - feedback: ["feedback", "user_feedback"], - chat_reports: ["chat_reports", "chatReports", "reports"], - audit: ["audit", "api_logs", "flight_logs", "audit_logs"], - api_logs: ["api_logs", "audit", "audit_logs"], - flight_logs: ["flight_logs", "audit", "flightArchive", "flight_logs"], - bans: ["bans", "ban_management"], - testers: ["testers", "tester_management"], - roles: ["roles", "role_management"], + admin: ['admin', 'overview'], + users: ['users', 'user_management'], + sessions: ['sessions', 'session_management'], + notifications: ['notifications', 'update_notifications', 'update_modals'], + update_modals: ['update_modals', 'update_notifications'], + feedback: ['feedback', 'user_feedback'], + chat_reports: ['chat_reports', 'chatReports', 'reports'], + audit: ['audit', 'api_logs', 'flight_logs', 'audit_logs'], + api_logs: ['api_logs', 'audit', 'audit_logs'], + flight_logs: ['flight_logs', 'audit', 'flightArchive', 'flight_logs'], + bans: ['bans', 'ban_management'], + testers: ['testers', 'tester_management'], + roles: ['roles', 'role_management'], }; for (const p of aliases[permission] ?? []) { @@ -125,144 +125,144 @@ export default function AdminSidebar({ const sections: NavSection[] = [ { - title: "General", + title: 'General', icon: MdDashboard, items: [ { icon: MdBarChart, - label: "Overview", - path: "/admin", - permission: "admin", + label: 'Overview', + path: '/admin', + permission: 'admin', }, { icon: MdPeople, - label: "Users", - path: "/admin/users", - textColor: "green-400", - permission: "users", + label: 'Users', + path: '/admin/users', + textColor: 'green-400', + permission: 'users', }, { icon: MdStorage, - label: "Sessions", - path: "/admin/sessions", - textColor: "yellow-400", - permission: "sessions", + label: 'Sessions', + path: '/admin/sessions', + textColor: 'yellow-400', + permission: 'sessions', }, { icon: MdNotifications, - label: "Notifications", - path: "/admin/notifications", - textColor: "cyan-400", - permission: "notifications", + label: 'Notifications', + path: '/admin/notifications', + textColor: 'cyan-400', + permission: 'notifications', }, { icon: MdStar, - label: "Feedback", - path: "/admin/feedback", - textColor: "yellow-400", - permission: "admin", + label: 'Feedback', + path: '/admin/feedback', + textColor: 'yellow-400', + permission: 'admin', }, { icon: MdThumbUp, - label: "Ratings", - path: "/admin/ratings", - textColor: "indigo-400", - permission: "admin", + label: 'Ratings', + path: '/admin/ratings', + textColor: 'indigo-400', + permission: 'admin', }, ].filter((item) => hasPermission(item.permission)), }, { - title: "Moderation", + title: 'Moderation', icon: MdVpnKey, items: [ { icon: MdChat, - label: "Chat Reports", - path: "/admin/chat-reports", - textColor: "red-400", - permission: "chat_reports", + label: 'Chat Reports', + path: '/admin/chat-reports', + textColor: 'red-400', + permission: 'chat_reports', }, { icon: MdFlight, - label: "Flight Archive", - path: "/admin/flight-logs", - textColor: "rose-400", - permission: "audit", + label: 'Flight Archive', + path: '/admin/flight-logs', + textColor: 'rose-400', + permission: 'audit', }, { icon: MdBlock, - label: "Bans", - path: "/admin/bans", - textColor: "red-400", - permission: "bans", + label: 'Bans', + path: '/admin/bans', + textColor: 'red-400', + permission: 'bans', }, ], }, { - title: "Security", + title: 'Security', icon: MdSecurity, items: [ { icon: MdMonitorHeart, - label: "API Logs", - path: "/admin/api-logs", - textColor: "blue-400", - permission: "audit", + label: 'API Logs', + path: '/admin/api-logs', + textColor: 'blue-400', + permission: 'audit', }, { icon: MdCode, - label: "Developers", - path: "/admin/developers", - textColor: "cyan-400", - permission: "admin", + label: 'Developers', + path: '/admin/developers', + textColor: 'cyan-400', + permission: 'admin', }, { icon: MdVerifiedUser, - label: "Testers", - path: "/admin/testers", - textColor: "purple-400", - permission: "testers", + label: 'Testers', + path: '/admin/testers', + textColor: 'purple-400', + permission: 'testers', }, { icon: MdAdminPanelSettings, - label: "Roles", - path: "/admin/roles", - textColor: "rose-400", - permission: "roles", + label: 'Roles', + path: '/admin/roles', + textColor: 'rose-400', + permission: 'roles', }, { icon: MdReport, - label: "Audit Log", - path: "/admin/audit", - textColor: "orange-400", - permission: "audit", + label: 'Audit Log', + path: '/admin/audit', + textColor: 'orange-400', + permission: 'audit', }, { icon: MdMergeType, - label: "Alt Detection", - path: "/admin/alts", - textColor: "amber-400", - permission: "admin", + label: 'Alt Detection', + path: '/admin/alts', + textColor: 'amber-400', + permission: 'admin', }, ].filter((item) => hasPermission(item.permission)), }, { - title: "Monitoring", + title: 'Monitoring', icon: MdQueryStats, items: [ { icon: MdCable, - label: "WebSockets", - path: "/admin/websockets", - textColor: "cyan-400", - permission: "admin", + label: 'WebSockets', + path: '/admin/websockets', + textColor: 'cyan-400', + permission: 'admin', }, { icon: MdStorage, - label: "Database", - path: "/admin/database", - textColor: "blue-400", - permission: "admin", + label: 'Database', + path: '/admin/database', + textColor: 'blue-400', + permission: 'admin', }, ].filter((item) => hasPermission(item.permission)), }, @@ -291,17 +291,17 @@ export default function AdminSidebar({ const linkClass = (isActive: boolean, textColor?: string) => `flex items-center gap-2.5 rounded-lg transition-colors duration-150 ${ - collapsed ? "justify-center h-9 w-full px-0" : "h-9 px-3" + collapsed ? 'justify-center h-9 w-full px-0' : 'h-9 px-3' } ${ isActive ? navActiveClass(textColor) - : "text-zinc-400 hover:text-white hover:bg-zinc-800/60" + : 'text-zinc-400 hover:text-white hover:bg-zinc-800/60' }`; return (
@@ -313,7 +313,7 @@ export default function AdminSidebar({

Admin

@@ -324,7 +324,7 @@ export default function AdminSidebar({ type="button" onClick={handleToggle} className="p-1.5 hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-white shrink-0" - aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"} + aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} > {collapsed ? ( @@ -372,7 +372,7 @@ export default function AdminSidebar({ {!isSectionCollapsed && ( @@ -403,4 +403,4 @@ export default function AdminSidebar({
); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminStatCards.tsx b/src/components/admin/AdminStatCards.tsx index 9797ad47..0bb4486f 100644 --- a/src/components/admin/AdminStatCards.tsx +++ b/src/components/admin/AdminStatCards.tsx @@ -1,5 +1,5 @@ -import { adminCardClass } from "./adminConstants"; -import type { AdminStatItem } from "./AdminStatStrip"; +import { adminCardClass } from './adminConstants'; +import type { AdminStatItem } from './AdminStatStrip'; type AdminStatCardsProps = { items: AdminStatItem[]; @@ -12,28 +12,30 @@ export default function AdminStatCards({ }: AdminStatCardsProps) { const colClass = columns === 2 - ? "grid-cols-1 sm:grid-cols-2" + ? 'grid-cols-1 sm:grid-cols-2' : columns === 3 - ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3" - : "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"; + ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' + : 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4'; return (
{items.map((item) => ( -
+

{item.label}

- {typeof item.value === "number" + {typeof item.value === 'number' ? item.value.toLocaleString() : item.value}

{item.sub ? ( -

{item.sub}

+

+ {item.sub} +

) : null}
))}
); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminStatStrip.tsx b/src/components/admin/AdminStatStrip.tsx index 0d0ebcca..3ef4528c 100644 --- a/src/components/admin/AdminStatStrip.tsx +++ b/src/components/admin/AdminStatStrip.tsx @@ -1,4 +1,4 @@ -import { adminStatStripItemClass } from "./adminConstants"; +import { adminStatStripItemClass } from './adminConstants'; export type AdminStatItem = { label: string; @@ -17,10 +17,10 @@ export default function AdminStatStrip({ }: AdminStatStripProps) { const colClass = columns === 2 - ? "grid-cols-2" + ? 'grid-cols-2' : columns === 3 - ? "grid-cols-2 sm:grid-cols-3" - : "grid-cols-2 sm:grid-cols-4"; + ? 'grid-cols-2 sm:grid-cols-3' + : 'grid-cols-2 sm:grid-cols-4'; return (

- {typeof item.value === "number" + {typeof item.value === 'number' ? item.value.toLocaleString() : item.value}

{item.sub && ( -

{item.sub}

+

+ {item.sub} +

)}
))}
); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminTable.tsx b/src/components/admin/AdminTable.tsx index e28018e9..e26dbf42 100644 --- a/src/components/admin/AdminTable.tsx +++ b/src/components/admin/AdminTable.tsx @@ -1,5 +1,5 @@ -import type { ReactNode } from "react"; -import { adminTableShellClass } from "./adminConstants"; +import type { ReactNode } from 'react'; +import { adminTableShellClass } from './adminConstants'; type AdminTableProps = { children: ReactNode; @@ -9,8 +9,8 @@ type AdminTableProps = { export default function AdminTable({ children, - className = "", - minWidth = "640px", + className = '', + minWidth = '640px', }: AdminTableProps) { return (
@@ -21,4 +21,4 @@ export default function AdminTable({
); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminTextInput.tsx b/src/components/admin/AdminTextInput.tsx index 9f34883b..53b0d1e1 100644 --- a/src/components/admin/AdminTextInput.tsx +++ b/src/components/admin/AdminTextInput.tsx @@ -1,11 +1,11 @@ -import type { ReactNode } from "react"; +import type { ReactNode } from 'react'; import { ADMIN_DATETIME_INPUT, ADMIN_DATETIME_INPUT_ICON, ADMIN_FIELD_INPUT, ADMIN_FIELD_INPUT_ICON, ADMIN_INPUT_ICON_CLASS, -} from "./adminConstants"; +} from './adminConstants'; type AdminTextInputProps = { label?: string; @@ -13,7 +13,7 @@ type AdminTextInputProps = { value: string; onChange: (value: string) => void; placeholder?: string; - type?: "text" | "datetime-local" | "date"; + type?: 'text' | 'datetime-local' | 'date'; disabled?: boolean; className?: string; required?: boolean; @@ -25,12 +25,12 @@ export default function AdminTextInput({ value, onChange, placeholder, - type = "text", + type = 'text', disabled = false, - className = "", + className = '', required = false, }: AdminTextInputProps) { - const isDate = type === "datetime-local" || type === "date"; + const isDate = type === 'datetime-local' || type === 'date'; const inputClass = icon ? isDate ? ADMIN_DATETIME_INPUT_ICON @@ -71,4 +71,4 @@ export default function AdminTextInput({ )}
); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminToggleSwitch.tsx b/src/components/admin/AdminToggleSwitch.tsx index 1c6f56a2..42635603 100644 --- a/src/components/admin/AdminToggleSwitch.tsx +++ b/src/components/admin/AdminToggleSwitch.tsx @@ -1,20 +1,20 @@ import { ADMIN_TOGGLE_TRACK_OFF, ADMIN_TOGGLE_TRACK_ON, -} from "./adminConstants"; +} from './adminConstants'; type AdminToggleSwitchProps = { checked: boolean; onChange: () => void; disabled?: boolean; - "aria-label": string; + 'aria-label': string; }; export default function AdminToggleSwitch({ checked, onChange, disabled = false, - "aria-label": ariaLabel, + 'aria-label': ariaLabel, }: AdminToggleSwitchProps) { return ( ); -} \ No newline at end of file +} diff --git a/src/components/admin/AdminToolbar.tsx b/src/components/admin/AdminToolbar.tsx index 62f2f610..641ec245 100644 --- a/src/components/admin/AdminToolbar.tsx +++ b/src/components/admin/AdminToolbar.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ReactNode } from 'react'; type AdminToolbarProps = { children: ReactNode; @@ -7,7 +7,7 @@ type AdminToolbarProps = { export default function AdminToolbar({ children, - className = "", + className = '', }: AdminToolbarProps) { return (
); -} \ No newline at end of file +} diff --git a/src/components/admin/DeveloperDiscordAvatar.tsx b/src/components/admin/DeveloperDiscordAvatar.tsx index 46912dbf..52e9d4a0 100644 --- a/src/components/admin/DeveloperDiscordAvatar.tsx +++ b/src/components/admin/DeveloperDiscordAvatar.tsx @@ -1,4 +1,4 @@ -import { MdPeople } from "react-icons/md"; +import { MdPeople } from 'react-icons/md'; type Props = { userId: string; @@ -11,7 +11,7 @@ export default function DeveloperDiscordAvatar({ userId, username, avatar, - className = "h-8 w-8", + className = 'h-8 w-8', }: Props) { if (avatar) { return ( @@ -30,4 +30,4 @@ export default function DeveloperDiscordAvatar({
); -} \ No newline at end of file +} diff --git a/src/components/admin/UpdateModalsSection.tsx b/src/components/admin/UpdateModalsSection.tsx index 0c642cfd..6ef9fa92 100644 --- a/src/components/admin/UpdateModalsSection.tsx +++ b/src/components/admin/UpdateModalsSection.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from 'react'; import { MdCampaign, MdAdd, @@ -7,23 +7,23 @@ import { MdSend, MdVisibilityOff, MdUpload, -} from "react-icons/md"; -import AdminModal from "./AdminModal"; -import AdminTable from "./AdminTable"; -import AdminSectionTitle from "./AdminSectionTitle"; -import AdminToolbar from "./AdminToolbar"; +} from 'react-icons/md'; +import AdminModal from './AdminModal'; +import AdminTable from './AdminTable'; +import AdminSectionTitle from './AdminSectionTitle'; +import AdminToolbar from './AdminToolbar'; import { adminDownsizeButtonSize, ADMIN_TABLE_HEAD, ADMIN_TH, ADMIN_TD, statusBadgeClass, -} from "./adminConstants"; -import Button from "../common/Button"; -import Toast from "../common/Toast"; -import Loader from "../common/Loader"; -import TextInput from "../common/TextInput"; -import MDEditor from "@uiw/react-md-editor"; +} from './adminConstants'; +import Button from '../common/Button'; +import Toast from '../common/Toast'; +import Loader from '../common/Loader'; +import TextInput from '../common/TextInput'; +import MDEditor from '@uiw/react-md-editor'; import { fetchAllUpdateModals, createUpdateModal, @@ -32,7 +32,7 @@ import { publishUpdateModal, unpublishUpdateModal, type UpdateModal, -} from "../../utils/fetch/admin/updateModals"; +} from '../../utils/fetch/admin/updateModals'; export default function UpdateModalsSection() { const [modals, setModals] = useState([]); @@ -41,14 +41,14 @@ export default function UpdateModalsSection() { const [editingModal, setEditingModal] = useState(null); const [toast, setToast] = useState<{ message: string; - type: "success" | "error" | "info"; + type: 'success' | 'error' | 'info'; } | null>(null); const [uploading, setUploading] = useState(false); const [formData, setFormData] = useState({ - title: "", - content: "", - banner_url: "", + title: '', + content: '', + banner_url: '', }); useEffect(() => { @@ -63,8 +63,8 @@ export default function UpdateModalsSection() { } catch (err) { setToast({ message: - err instanceof Error ? err.message : "Failed to fetch update modals", - type: "error", + err instanceof Error ? err.message : 'Failed to fetch update modals', + type: 'error', }); } finally { setLoading(false); @@ -73,15 +73,15 @@ export default function UpdateModalsSection() { const handleCreate = async () => { if (!formData.title || !formData.content) { - setToast({ message: "Title and content are required", type: "error" }); + setToast({ message: 'Title and content are required', type: 'error' }); return; } try { await createUpdateModal(formData); setToast({ - message: "Update modal created successfully", - type: "success", + message: 'Update modal created successfully', + type: 'success', }); setShowAddModal(false); resetForm(); @@ -89,8 +89,8 @@ export default function UpdateModalsSection() { } catch (err) { setToast({ message: - err instanceof Error ? err.message : "Failed to create update modal", - type: "error", + err instanceof Error ? err.message : 'Failed to create update modal', + type: 'error', }); } }; @@ -98,15 +98,15 @@ export default function UpdateModalsSection() { const handleUpdate = async () => { if (!editingModal) return; if (!formData.title || !formData.content) { - setToast({ message: "Title and content are required", type: "error" }); + setToast({ message: 'Title and content are required', type: 'error' }); return; } try { await updateUpdateModal(editingModal.id, formData); setToast({ - message: "Update modal updated successfully", - type: "success", + message: 'Update modal updated successfully', + type: 'success', }); setEditingModal(null); resetForm(); @@ -114,27 +114,27 @@ export default function UpdateModalsSection() { } catch (err) { setToast({ message: - err instanceof Error ? err.message : "Failed to update update modal", - type: "error", + err instanceof Error ? err.message : 'Failed to update update modal', + type: 'error', }); } }; const handleDelete = async (id: number) => { - if (!confirm("Are you sure you want to delete this update modal?")) return; + if (!confirm('Are you sure you want to delete this update modal?')) return; try { await deleteUpdateModal(id); setToast({ - message: "Update modal deleted successfully", - type: "success", + message: 'Update modal deleted successfully', + type: 'success', }); fetchModals(); } catch (err) { setToast({ message: - err instanceof Error ? err.message : "Failed to delete update modal", - type: "error", + err instanceof Error ? err.message : 'Failed to delete update modal', + type: 'error', }); } }; @@ -150,15 +150,15 @@ export default function UpdateModalsSection() { try { await publishUpdateModal(id); setToast({ - message: "Update modal published! Users will see it on next page load.", - type: "success", + message: 'Update modal published! Users will see it on next page load.', + type: 'success', }); fetchModals(); } catch (err) { setToast({ message: - err instanceof Error ? err.message : "Failed to publish update modal", - type: "error", + err instanceof Error ? err.message : 'Failed to publish update modal', + type: 'error', }); } }; @@ -167,8 +167,8 @@ export default function UpdateModalsSection() { try { await unpublishUpdateModal(id); setToast({ - message: "Update modal unpublished successfully", - type: "success", + message: 'Update modal unpublished successfully', + type: 'success', }); fetchModals(); } catch (err) { @@ -176,8 +176,8 @@ export default function UpdateModalsSection() { message: err instanceof Error ? err.message - : "Failed to unpublish update modal", - type: "error", + : 'Failed to unpublish update modal', + type: 'error', }); } }; @@ -186,35 +186,35 @@ export default function UpdateModalsSection() { const file = e.target.files?.[0]; if (!file) return; - if (!file.type.startsWith("image/")) { - setToast({ message: "Please upload an image file", type: "error" }); + if (!file.type.startsWith('image/')) { + setToast({ message: 'Please upload an image file', type: 'error' }); return; } try { setUploading(true); const formData = new FormData(); - formData.append("image", file); + formData.append('image', file); - const API_BASE_URL = import.meta.env.VITE_SERVER_URL || ""; + const API_BASE_URL = import.meta.env.VITE_SERVER_URL || ''; const response = await fetch( `${API_BASE_URL}/api/uploads/upload-modal-banner`, { - method: "POST", - credentials: "include", + method: 'POST', + credentials: 'include', body: formData, } ); - if (!response.ok) throw new Error("Upload failed"); + if (!response.ok) throw new Error('Upload failed'); const result = await response.json(); setFormData((prev) => ({ ...prev, banner_url: result.url })); - setToast({ message: "Banner uploaded successfully", type: "success" }); + setToast({ message: 'Banner uploaded successfully', type: 'success' }); } catch (err) { setToast({ - message: err instanceof Error ? err.message : "Failed to upload banner", - type: "error", + message: err instanceof Error ? err.message : 'Failed to upload banner', + type: 'error', }); } finally { setUploading(false); @@ -222,7 +222,7 @@ export default function UpdateModalsSection() { }; const resetForm = () => { - setFormData({ title: "", content: "", banner_url: "" }); + setFormData({ title: '', content: '', banner_url: '' }); }; const openEditModal = (modal: UpdateModal) => { @@ -230,7 +230,7 @@ export default function UpdateModalsSection() { setFormData({ title: modal.title, content: modal.content, - banner_url: modal.banner_url || "", + banner_url: modal.banner_url || '', }); }; @@ -264,7 +264,7 @@ export default function UpdateModalsSection() {
} @@ -466,7 +466,7 @@ export default function UpdateModalsSection() { - setFormData({ ...formData, content: val || "" }) + setFormData({ ...formData, content: val || '' }) } preview="edit" height={400} @@ -489,4 +489,4 @@ export default function UpdateModalsSection() { )}
); -} \ No newline at end of file +} diff --git a/src/components/admin/adminConstants.ts b/src/components/admin/adminConstants.ts index 5810feed..98770afd 100644 --- a/src/components/admin/adminConstants.ts +++ b/src/components/admin/adminConstants.ts @@ -1,19 +1,19 @@ -export function adminCardClass(extra = "") { - return `rounded-2xl border border-zinc-800 bg-zinc-900/90 backdrop-blur-xl p-5 xl:p-7 shadow-xl ring-1 ring-zinc-700/50${extra ? ` ${extra}` : ""}`; +export function adminCardClass(extra = '') { + return `rounded-2xl border border-zinc-800 bg-zinc-900/90 backdrop-blur-xl p-5 xl:p-7 shadow-xl ring-1 ring-zinc-700/50${extra ? ` ${extra}` : ''}`; } -export function adminSectionClass(extra = "") { - return `border-t border-zinc-800/80 pt-5 xl:pt-6 mt-5 xl:mt-6 first:border-t-0 first:pt-0 first:mt-0${extra ? ` ${extra}` : ""}`; +export function adminSectionClass(extra = '') { + return `border-t border-zinc-800/80 pt-5 xl:pt-6 mt-5 xl:mt-6 first:border-t-0 first:pt-0 first:mt-0${extra ? ` ${extra}` : ''}`; } export function adminStatStripItemClass() { - return "min-w-0"; + return 'min-w-0'; } -export const ADMIN_TOOLBAR_HEIGHT = "h-10 xl:h-11"; +export const ADMIN_TOOLBAR_HEIGHT = 'h-10 xl:h-11'; export const ADMIN_INPUT_ICON_CLASS = - "absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none z-10 flex items-center justify-center"; + 'absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none z-10 flex items-center justify-center'; export const ADMIN_SEARCH_INPUT = `box-border ${ADMIN_TOOLBAR_HEIGHT} w-full text-sm xl:text-base font-medium pl-11 pr-4 rounded-full border-2 border-blue-600 bg-gray-800 text-white placeholder-zinc-400 focus:outline-none focus:border-blue-400 transition-colors appearance-none [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden`; @@ -25,144 +25,146 @@ export const ADMIN_DATETIME_INPUT = `${ADMIN_FIELD_INPUT} pr-3 [color-scheme:dar export const ADMIN_DATETIME_INPUT_ICON = `${ADMIN_DATETIME_INPUT} pl-11`; -export function adminTableShellClass(extra = "") { - return `rounded-xl border border-zinc-800/60 overflow-hidden bg-zinc-900/30${extra ? ` ${extra}` : ""}`; +export function adminTableShellClass(extra = '') { + return `rounded-xl border border-zinc-800/60 overflow-hidden bg-zinc-900/30${extra ? ` ${extra}` : ''}`; } -export const ADMIN_SECTION_TITLE = "text-sm xl:text-base font-semibold text-zinc-200 mb-3"; +export const ADMIN_SECTION_TITLE = + 'text-sm xl:text-base font-semibold text-zinc-200 mb-3'; export function adminDownsizeButtonSize( - size?: "icon" | "xs" | "sm" | "md" | "lg" -): "icon" | "xs" | "sm" | "md" | "lg" { - if (!size || size === "md") return "sm"; - if (size === "sm") return "xs"; - if (size === "lg") return "md"; + size?: 'icon' | 'xs' | 'sm' | 'md' | 'lg' +): 'icon' | 'xs' | 'sm' | 'md' | 'lg' { + if (!size || size === 'md') return 'sm'; + if (size === 'sm') return 'xs'; + if (size === 'lg') return 'md'; return size; } -export const ADMIN_PAGE_BG = "min-h-screen bg-zinc-950 text-white"; +export const ADMIN_PAGE_BG = 'min-h-screen bg-zinc-950 text-white'; export const ADMIN_HEADING = - "text-transparent bg-clip-text bg-linear-to-r from-blue-400 to-blue-600 font-extrabold"; + 'text-transparent bg-clip-text bg-linear-to-r from-blue-400 to-blue-600 font-extrabold'; export type AdminPageAccent = - | "blue" - | "green" - | "yellow" - | "red" - | "cyan" - | "purple" - | "rose" - | "orange" - | "amber" - | "indigo"; + | 'blue' + | 'green' + | 'yellow' + | 'red' + | 'cyan' + | 'purple' + | 'rose' + | 'orange' + | 'amber' + | 'indigo'; const ACCENT_TITLE_GRADIENT: Record = { - blue: "from-blue-400 to-blue-600", - green: "from-green-400 to-green-600", - yellow: "from-yellow-400 to-yellow-600", - red: "from-red-400 to-red-600", - cyan: "from-cyan-400 to-cyan-600", - purple: "from-purple-400 to-purple-600", - rose: "from-rose-400 to-rose-600", - orange: "from-orange-400 to-orange-600", - amber: "from-amber-400 to-amber-600", - indigo: "from-indigo-400 to-indigo-600", + blue: 'from-blue-400 to-blue-600', + green: 'from-green-400 to-green-600', + yellow: 'from-yellow-400 to-yellow-600', + red: 'from-red-400 to-red-600', + cyan: 'from-cyan-400 to-cyan-600', + purple: 'from-purple-400 to-purple-600', + rose: 'from-rose-400 to-rose-600', + orange: 'from-orange-400 to-orange-600', + amber: 'from-amber-400 to-amber-600', + indigo: 'from-indigo-400 to-indigo-600', }; const ACCENT_ICON: Record = { - blue: "text-blue-400", - green: "text-green-400", - yellow: "text-yellow-400", - red: "text-red-400", - cyan: "text-cyan-400", - purple: "text-purple-400", - rose: "text-rose-400", - orange: "text-orange-400", - amber: "text-amber-400", - indigo: "text-indigo-400", + blue: 'text-blue-400', + green: 'text-green-400', + yellow: 'text-yellow-400', + red: 'text-red-400', + cyan: 'text-cyan-400', + purple: 'text-purple-400', + rose: 'text-rose-400', + orange: 'text-orange-400', + amber: 'text-amber-400', + indigo: 'text-indigo-400', }; -export function adminPageTitleClass(accent: AdminPageAccent = "blue") { +export function adminPageTitleClass(accent: AdminPageAccent = 'blue') { return `text-2xl sm:text-3xl lg:text-4xl xl:text-5xl text-transparent bg-clip-text bg-linear-to-r ${ACCENT_TITLE_GRADIENT[accent]} font-extrabold`; } -export function adminPageIconClass(accent: AdminPageAccent = "blue") { +export function adminPageIconClass(accent: AdminPageAccent = 'blue') { return ACCENT_ICON[accent]; } -export const ADMIN_TOGGLE_TRACK_ON = "bg-blue-600"; -export const ADMIN_TOGGLE_TRACK_OFF = "bg-zinc-600"; -export const ADMIN_SEGMENT_ACTIVE = "bg-blue-600 text-white"; +export const ADMIN_TOGGLE_TRACK_ON = 'bg-blue-600'; +export const ADMIN_TOGGLE_TRACK_OFF = 'bg-zinc-600'; +export const ADMIN_SEGMENT_ACTIVE = 'bg-blue-600 text-white'; export const ADMIN_SEGMENT_INACTIVE = - "text-zinc-400 hover:text-white hover:bg-zinc-800/50"; -export const ADMIN_TOGGLE_CHECKBOX_ON = "bg-blue-600 border-blue-600"; + 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'; +export const ADMIN_TOGGLE_CHECKBOX_ON = 'bg-blue-600 border-blue-600'; export const ADMIN_TOGGLE_BADGE_ACTIVE = - "px-2 py-0.5 bg-blue-500/20 text-blue-300 text-xs rounded-full border border-blue-500/30"; -export const ADMIN_TOGGLE_ICON_ACTIVE = "bg-blue-500/20 text-blue-400"; + 'px-2 py-0.5 bg-blue-500/20 text-blue-300 text-xs rounded-full border border-blue-500/30'; +export const ADMIN_TOGGLE_ICON_ACTIVE = 'bg-blue-500/20 text-blue-400'; -export const ADMIN_CHECKBOX = "rounded border-zinc-600 accent-blue-600"; +export const ADMIN_CHECKBOX = 'rounded border-zinc-600 accent-blue-600'; -export const ADMIN_TABLE_HEAD = "bg-zinc-900"; +export const ADMIN_TABLE_HEAD = 'bg-zinc-900'; export const ADMIN_TH = - "py-2 px-3 xl:py-3 xl:px-4 text-left text-xs xl:text-sm font-medium text-zinc-400 uppercase tracking-wider"; + 'py-2 px-3 xl:py-3 xl:px-4 text-left text-xs xl:text-sm font-medium text-zinc-400 uppercase tracking-wider'; -export const ADMIN_TD = "py-2 px-3 xl:py-3 xl:px-4 text-sm xl:text-base text-zinc-300"; +export const ADMIN_TD = + 'py-2 px-3 xl:py-3 xl:px-4 text-sm xl:text-base text-zinc-300'; export const NAV_ACTIVE_COLORS: Record = { - "green-400": "text-green-400", - "yellow-400": "text-yellow-400", - "cyan-400": "text-cyan-400", - "indigo-400": "text-indigo-400", - "red-400": "text-red-400", - "rose-400": "text-rose-400", - "blue-400": "text-blue-400", - "purple-400": "text-purple-400", - "orange-400": "text-orange-400", - "amber-400": "text-amber-400", + 'green-400': 'text-green-400', + 'yellow-400': 'text-yellow-400', + 'cyan-400': 'text-cyan-400', + 'indigo-400': 'text-indigo-400', + 'red-400': 'text-red-400', + 'rose-400': 'text-rose-400', + 'blue-400': 'text-blue-400', + 'purple-400': 'text-purple-400', + 'orange-400': 'text-orange-400', + 'amber-400': 'text-amber-400', }; export function navActiveClass(textColor?: string): string { - if (!textColor) return "text-blue-400"; - return NAV_ACTIVE_COLORS[textColor] ?? "text-blue-400"; + if (!textColor) return 'text-blue-400'; + return NAV_ACTIVE_COLORS[textColor] ?? 'text-blue-400'; } export function statusBadgeClass(status: string): string { const s = status.toLowerCase(); - if (s === "active" || s === "approved" || s === "success") { - return "bg-emerald-950/55 text-emerald-300 ring-1 ring-emerald-800/40"; + if (s === 'active' || s === 'approved' || s === 'success') { + return 'bg-emerald-950/55 text-emerald-300 ring-1 ring-emerald-800/40'; } - if (s === "pending") { - return "bg-amber-950/50 text-amber-200 ring-1 ring-amber-800/35"; + if (s === 'pending') { + return 'bg-amber-950/50 text-amber-200 ring-1 ring-amber-800/35'; } if ( - s === "revoked" || - s === "rejected" || - s === "suspended" || - s === "banned" + s === 'revoked' || + s === 'rejected' || + s === 'suspended' || + s === 'banned' ) { - return "bg-red-950/40 text-red-300 ring-1 ring-red-900/40"; + return 'bg-red-950/40 text-red-300 ring-1 ring-red-900/40'; } - return "bg-zinc-800 text-zinc-400 ring-1 ring-zinc-700/50"; + return 'bg-zinc-800 text-zinc-400 ring-1 ring-zinc-700/50'; } export const ADMIN_TOOLBAR_MOBILE_COL = - "max-md:flex-col max-md:items-stretch max-md:gap-2"; + 'max-md:flex-col max-md:items-stretch max-md:gap-2'; export const ADMIN_TOOLBAR_MOBILE_SEARCH = - "max-md:!w-full max-md:!max-w-none max-md:flex-none max-md:basis-full"; + 'max-md:!w-full max-md:!max-w-none max-md:flex-none max-md:basis-full'; export const ADMIN_TOOLBAR_MOBILE_SPLIT_ROW = - "md:contents max-md:w-full max-md:flex max-md:items-center max-md:gap-2 max-md:[&>*]:flex-1 max-md:[&>*]:min-w-0"; + 'md:contents max-md:w-full max-md:flex max-md:items-center max-md:gap-2 max-md:[&>*]:flex-1 max-md:[&>*]:min-w-0'; -export const ADMIN_TOOLBAR_MOBILE_SPLIT_ITEM = "max-md:flex-1 max-md:min-w-0"; +export const ADMIN_TOOLBAR_MOBILE_SPLIT_ITEM = 'max-md:flex-1 max-md:min-w-0'; export const ADMIN_TOOLBAR_MOBILE_PAIR = - "md:contents max-md:flex max-md:w-full max-md:gap-2 max-md:items-center max-md:[&>*]:flex-1 max-md:[&>*]:min-w-0"; + 'md:contents max-md:flex max-md:w-full max-md:gap-2 max-md:items-center max-md:[&>*]:flex-1 max-md:[&>*]:min-w-0'; export const ADMIN_TOOLBAR_MOBILE_STACK_ITEM = - "max-md:w-full max-md:max-w-none max-md:flex-none max-md:basis-full"; + 'max-md:w-full max-md:max-w-none max-md:flex-none max-md:basis-full'; export const ADMIN_HEADER_ACTIONS_MOBILE = - "max-md:basis-full max-md:w-full max-md:ml-0 max-md:justify-stretch max-md:[&_button]:flex-1 max-md:[&_button]:min-w-0"; \ No newline at end of file + 'max-md:basis-full max-md:w-full max-md:ml-0 max-md:justify-stretch max-md:[&_button]:flex-1 max-md:[&_button]:min-w-0'; diff --git a/src/components/admin/adminDurationPresetConfig.ts b/src/components/admin/adminDurationPresetConfig.ts index 3519ed95..f55744c5 100644 --- a/src/components/admin/adminDurationPresetConfig.ts +++ b/src/components/admin/adminDurationPresetConfig.ts @@ -1,9 +1,9 @@ export const ADMIN_DURATION_PRESETS = [ - { id: "24h", label: "24h", ms: 86400000 }, - { id: "7d", label: "7d", ms: 7 * 86400000 }, - { id: "30d", label: "30d", ms: 30 * 86400000 }, + { id: '24h', label: '24h', ms: 86400000 }, + { id: '7d', label: '7d', ms: 7 * 86400000 }, + { id: '30d', label: '30d', ms: 30 * 86400000 }, ] as const; export type AdminDurationPresetId = - | (typeof ADMIN_DURATION_PRESETS)[number]["id"] - | "permanent"; \ No newline at end of file + | (typeof ADMIN_DURATION_PRESETS)[number]['id'] + | 'permanent'; diff --git a/src/components/chat/ChatMessageRow.tsx b/src/components/chat/ChatMessageRow.tsx index 4626d75d..b86b8e5a 100644 --- a/src/components/chat/ChatMessageRow.tsx +++ b/src/components/chat/ChatMessageRow.tsx @@ -70,14 +70,12 @@ export function ChatMessageRow({ {showHeader && (
{displayName} - {isGlobal && - 'station' in msg && - msg.station && ( - - {' - '} - {formatStationDisplay(msg.station, msg.position)} - - )} + {isGlobal && 'station' in msg && msg.station && ( + + {' - '} + {formatStationDisplay(msg.station, msg.position)} + + )} {' • '} {getMessageTimeString(msg.sent_at)}
diff --git a/src/components/chat/ChatSidebar.tsx b/src/components/chat/ChatSidebar.tsx index 4cd9be8a..07c5ecf1 100644 --- a/src/components/chat/ChatSidebar.tsx +++ b/src/components/chat/ChatSidebar.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from "react"; +import { useState, useEffect, useRef, useMemo } from 'react'; import { fetchChatMessages, reportChatMessage, @@ -6,38 +6,46 @@ import { reportGlobalChatMessage, fetchAATCChatMessages, reportAATCChatMessage, -} from "../../utils/fetch/chats"; +} from '../../utils/fetch/chats'; import { isUserInActiveChat, handleMentionSuggestions, handleGlobalMentionSuggestions, insertMentionIntoText, isAtBottom, -} from "../../utils/chats"; -import { useAuth } from "../../hooks/auth/useAuth"; -import { useData } from "../../hooks/data/useData"; -import { createChatSocket } from "../../sockets/chatSocket"; +} from '../../utils/chats'; +import { useAuth } from '../../hooks/auth/useAuth'; +import { useData } from '../../hooks/data/useData'; +import { createChatSocket } from '../../sockets/chatSocket'; import { createGlobalChatSocket, type GlobalChatMessage, type ConnectedGlobalChatUser, -} from "../../sockets/globalChatSocket"; -import { X, Flag, MessageCircle, Radio, Wifi, WifiOff, Phone } from "lucide-react"; -import type { ChatMessage, ChatMention } from "../../types/chats"; -import type { SessionUser } from "../../types/session"; -import type { ToastType } from "../common/Toast"; +} from '../../sockets/globalChatSocket'; +import { + X, + Flag, + MessageCircle, + Radio, + Wifi, + WifiOff, + Phone, +} from 'lucide-react'; +import type { ChatMessage, ChatMention } from '../../types/chats'; +import type { SessionUser } from '../../types/session'; +import type { ToastType } from '../common/Toast'; import { createVoiceChatSocket, type VoiceUser, type VoiceConnectionState, -} from "../../sockets/voiceChatSocket"; -import Button from "../common/Button"; -import Loader from "../common/Loader"; -import Modal from "../common/Modal"; -import Toast from "../common/Toast"; -import VoiceChat from "./VoiceChat"; -import { ChatMessageRow, type ChatListMessage } from "./ChatMessageRow"; -import { ChatTextComposer } from "./ChatTextComposer"; +} from '../../sockets/voiceChatSocket'; +import Button from '../common/Button'; +import Loader from '../common/Loader'; +import Modal from '../common/Modal'; +import Toast from '../common/Toast'; +import VoiceChat from './VoiceChat'; +import { ChatMessageRow, type ChatListMessage } from './ChatMessageRow'; +import { ChatTextComposer } from './ChatTextComposer'; interface ChatSidebarProps { sessionId: string; @@ -74,21 +82,27 @@ export default function ChatSidebar({ const { airports } = useData(); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); - const [input, setInput] = useState(""); + const [input, setInput] = useState(''); const [hoveredMessage, setHoveredMessage] = useState(null); const [activeChatUsers, setActiveChatUsers] = useState([]); const [showMentionSuggestions, setShowMentionSuggestions] = useState(false); - const [mentionSuggestions, setMentionSuggestions] = useState([]); + const [mentionSuggestions, setMentionSuggestions] = useState( + [] + ); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const [showReportModal, setShowReportModal] = useState(false); - const [reportReason, setReportReason] = useState(""); - const [reportingMessageId, setReportingMessageId] = useState(null); + const [reportReason, setReportReason] = useState(''); + const [reportingMessageId, setReportingMessageId] = useState( + null + ); const [reportingGlobalMessage, setReportingGlobalMessage] = useState(false); const [toast, setToast] = useState<{ message: string; type: ToastType; } | null>(null); - const [automoddedMessages, setAutomoddedMessages] = useState>(new Map()); + const [automoddedMessages, setAutomoddedMessages] = useState< + Map + >(new Map()); const [messagesLoaded, setMessagesLoaded] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const socketRef = useRef | null>(null); @@ -97,35 +111,49 @@ export default function ChatSidebar({ const textareaRef = useRef(null); const isAtBottomRef = useRef(true); - const [activeTab, setActiveTab] = useState<"session" | "voice" | "pfatc" | "aatc">( - sessionId ? "session" : isAdvancedATC ? "aatc" : "pfatc", - ); + const [activeTab, setActiveTab] = useState< + 'session' | 'voice' | 'pfatc' | 'aatc' + >(sessionId ? 'session' : isAdvancedATC ? 'aatc' : 'pfatc'); const [globalMessages, setGlobalMessages] = useState([]); const [globalLoading, setGlobalLoading] = useState(false); - const [globalInput, setGlobalInput] = useState(""); + const [globalInput, setGlobalInput] = useState(''); const [connectedGlobalChatUsers, setConnectedGlobalChatUsers] = useState< ConnectedGlobalChatUser[] >([]); const [showGlobalSuggestions, setShowGlobalSuggestions] = useState(false); const [globalSuggestions, setGlobalSuggestions] = useState< Array<{ - type: "user" | "airport"; - data: SessionUser | { icao: string; name: string } | ConnectedGlobalChatUser; + type: 'user' | 'airport'; + data: + | SessionUser + | { icao: string; name: string } + | ConnectedGlobalChatUser; }> >([]); - const [selectedGlobalSuggestionIndex, setSelectedGlobalSuggestionIndex] = useState(-1); - const globalSocketRef = useRef | null>(null); + const [selectedGlobalSuggestionIndex, setSelectedGlobalSuggestionIndex] = + useState(-1); + const globalSocketRef = useRef | null>(null); const globalPendingDeleteRef = useRef(null); const globalTextareaRef = useRef(null); // AATC-specific state (separate socket + messages from PFATC) - const aatcSocketRef = useRef | null>(null); + const aatcSocketRef = useRef | null>(null); const aatcPendingDeleteRef = useRef(null); const [aatcMessages, setAatcMessages] = useState([]); const [aatcLoading, setAatcLoading] = useState(false); - const [aatcInput, setAatcInput] = useState(""); - const [aatcConnectedUsers, setAatcConnectedUsers] = useState([]); - const [aatcTypingUsers, setAatcTypingUsers] = useState>(new Map()); - const aatcTypingTimeouts = useRef>>(new Map()); + const [aatcInput, setAatcInput] = useState(''); + const [aatcConnectedUsers, setAatcConnectedUsers] = useState< + ConnectedGlobalChatUser[] + >([]); + const [aatcTypingUsers, setAatcTypingUsers] = useState>( + new Map() + ); + const aatcTypingTimeouts = useRef>>( + new Map() + ); const lastAatcTypingEmit = useRef(0); const onMentionReceivedRef = useRef(onMentionReceived); const [voiceUsers, setVoiceUsers] = useState([]); @@ -135,26 +163,38 @@ export default function ChatSidebar({ error: null, }); const [talkingUsers, setTalkingUsers] = useState>(new Set()); - const [audioLevels, setAudioLevels] = useState>(new Map()); + const [audioLevels, setAudioLevels] = useState>( + new Map() + ); const [isInVoice, setIsInVoice] = useState(false); const [voiceDevices, setVoiceDevices] = useState([]); - const voiceSocketRef = useRef | null>(null); + const voiceSocketRef = useRef | null>(null); const [, setUnreadSessionMentions] = useState([]); const [, setUnreadGlobalMentions] = useState([]); // userId -> username for people currently typing - const [sessionTypingUsers, setSessionTypingUsers] = useState>(new Map()); - const [globalTypingUsers, setGlobalTypingUsers] = useState>(new Map()); + const [sessionTypingUsers, setSessionTypingUsers] = useState< + Map + >(new Map()); + const [globalTypingUsers, setGlobalTypingUsers] = useState< + Map + >(new Map()); // Timeouts that auto-clear a user from the typing map after inactivity - const sessionTypingTimeouts = useRef>>(new Map()); - const globalTypingTimeouts = useRef>>(new Map()); + const sessionTypingTimeouts = useRef< + Map> + >(new Map()); + const globalTypingTimeouts = useRef< + Map> + >(new Map()); // Timestamp of last typing emit — used to throttle sends const lastSessionTypingEmit = useRef(0); const lastGlobalTypingEmit = useRef(0); const [userVolumes, setUserVolumes] = useState>(() => { - const storedVolumes = localStorage.getItem("userVolumes"); + const storedVolumes = localStorage.getItem('userVolumes'); return storedVolumes ? new Map(JSON.parse(storedVolumes)) : new Map(); }); // Ref so the voice socket always reads the latest volumes without needing @@ -171,7 +211,8 @@ export default function ChatSidebar({ const getConnectionIcon = () => { if (connectionState.connecting) return ; - if (connectionState.connected) return ; + if (connectionState.connected) + return ; return ; }; @@ -181,13 +222,13 @@ export default function ChatSidebar({ useEffect(() => { if (open) { - document.body.style.overflow = "hidden"; + document.body.style.overflow = 'hidden'; } else { - document.body.style.overflow = ""; + document.body.style.overflow = ''; } return () => { - document.body.style.overflow = ""; + document.body.style.overflow = ''; }; }, [open]); @@ -216,11 +257,15 @@ export default function ChatSidebar({ }); }, (data: { messageId: number; error: string }) => { - if (pendingDeleteRef.current && pendingDeleteRef.current.id === data.messageId) { + if ( + pendingDeleteRef.current && + pendingDeleteRef.current.id === data.messageId + ) { setMessages((prev) => { const newMessages = [...prev, pendingDeleteRef.current!]; return newMessages.sort( - (a, b) => new Date(a.sent_at).getTime() - new Date(b.sent_at).getTime(), + (a, b) => + new Date(a.sent_at).getTime() - new Date(b.sent_at).getTime() ); }); pendingDeleteRef.current = null; @@ -230,7 +275,7 @@ export default function ChatSidebar({ setActiveChatUsers(users); }, (mention: ChatMention) => { - if (!open || activeTab !== "session") { + if (!open || activeTab !== 'session') { if (mention.mentionedUserId === user.userId && onMentionReceived) { onMentionReceived(mention); } @@ -239,12 +284,20 @@ export default function ChatSidebar({ (data: { messageId: number; reason?: string }) => { setAutomoddedMessages((prev) => { const newMap = new Map(prev); - newMap.set(data.messageId, data.reason || "Hate speech detected"); + newMap.set(data.messageId, data.reason || 'Hate speech detected'); return newMap; }); }, - ({ userId: typingId, username }: { userId: string; username: string }) => { - setSessionTypingUsers((prev) => new Map(prev).set(typingId, username)); + ({ + userId: typingId, + username, + }: { + userId: string; + username: string; + }) => { + setSessionTypingUsers((prev) => + new Map(prev).set(typingId, username) + ); const prev = sessionTypingTimeouts.current.get(typingId); if (prev) clearTimeout(prev); sessionTypingTimeouts.current.set( @@ -256,13 +309,13 @@ export default function ChatSidebar({ return next; }); sessionTypingTimeouts.current.delete(typingId); - }, 3000), + }, 3000) ); - }, + } ); if (open) { - socketRef.current.socket.emit("chatOpened"); + socketRef.current.socket.emit('chatOpened'); } } @@ -278,9 +331,9 @@ export default function ChatSidebar({ if (!socketRef.current) return; if (open) { - socketRef.current.socket.emit("chatOpened"); + socketRef.current.socket.emit('chatOpened'); } else { - socketRef.current.socket.emit("chatClosed"); + socketRef.current.socket.emit('chatClosed'); } }, [open]); @@ -296,8 +349,8 @@ export default function ChatSidebar({ setMessagesLoaded(true); }) .catch((error) => { - console.error("Failed to fetch chat messages:", error); - setErrorMessage("Failed to load chat messages"); + console.error('Failed to fetch chat messages:', error); + setErrorMessage('Failed to load chat messages'); setMessages([]); setLoading(false); setMessagesLoaded(true); @@ -320,14 +373,17 @@ export default function ChatSidebar({ }); }, (data: { messageId: number }) => { - setGlobalMessages((prev) => prev.filter((m) => m.id !== data.messageId)); + setGlobalMessages((prev) => + prev.filter((m) => m.id !== data.messageId) + ); }, (data: { messageId: number; error: string }) => { if (globalPendingDeleteRef.current?.id === data.messageId) { setGlobalMessages((prev) => [...prev, globalPendingDeleteRef.current!].sort( - (a, b) => new Date(a.sent_at).getTime() - new Date(b.sent_at).getTime(), - ), + (a, b) => + new Date(a.sent_at).getTime() - new Date(b.sent_at).getTime() + ) ); globalPendingDeleteRef.current = null; } @@ -341,29 +397,38 @@ export default function ChatSidebar({ }); }, (mention) => { - if (!open || activeTab !== "pfatc") { - if (user && mention.mentionedUserId === user.userId && onMentionReceivedRef.current) { + if (!open || activeTab !== 'pfatc') { + if ( + user && + mention.mentionedUserId === user.userId && + onMentionReceivedRef.current + ) { onMentionReceivedRef.current({ messageId: parseInt(mention.messageId, 10), mentionedUserId: mention.mentionedUserId, mentionerUsername: mention.mentionerUsername, message: mention.message, timestamp: mention.timestamp, - sessionId: "global-chat", + sessionId: 'global-chat', }); } } }, (mention) => { - if (!open || activeTab !== "pfatc") { - if (mention.airport && station && mention.airport.toUpperCase() === station.toUpperCase() && onMentionReceivedRef.current) { + if (!open || activeTab !== 'pfatc') { + if ( + mention.airport && + station && + mention.airport.toUpperCase() === station.toUpperCase() && + onMentionReceivedRef.current + ) { onMentionReceivedRef.current({ messageId: parseInt(mention.messageId, 10), mentionedUserId: user.userId, mentionerUsername: mention.mentionerUsername, message: mention.message, timestamp: mention.timestamp, - sessionId: "global-chat", + sessionId: 'global-chat', }); } } @@ -371,19 +436,29 @@ export default function ChatSidebar({ (users: ConnectedGlobalChatUser[]) => { setConnectedGlobalChatUsers(users); }, - ({ userId: typingId, username }: { userId: string; username: string }) => { + ({ + userId: typingId, + username, + }: { + userId: string; + username: string; + }) => { setGlobalTypingUsers((prev) => new Map(prev).set(typingId, username)); const prev = globalTypingTimeouts.current.get(typingId); if (prev) clearTimeout(prev); globalTypingTimeouts.current.set( typingId, setTimeout(() => { - setGlobalTypingUsers((m) => { const next = new Map(m); next.delete(typingId); return next; }); + setGlobalTypingUsers((m) => { + const next = new Map(m); + next.delete(typingId); + return next; + }); globalTypingTimeouts.current.delete(typingId); - }, 3000), + }, 3000) ); }, - "pfatc", + 'pfatc' ); } @@ -411,14 +486,17 @@ export default function ChatSidebar({ }); }, (data: { messageId: number }) => { - setAatcMessages((prev) => prev.filter((m) => m.id !== data.messageId)); + setAatcMessages((prev) => + prev.filter((m) => m.id !== data.messageId) + ); }, (data: { messageId: number; error: string }) => { if (aatcPendingDeleteRef.current?.id === data.messageId) { setAatcMessages((prev) => [...prev, aatcPendingDeleteRef.current!].sort( - (a, b) => new Date(a.sent_at).getTime() - new Date(b.sent_at).getTime(), - ), + (a, b) => + new Date(a.sent_at).getTime() - new Date(b.sent_at).getTime() + ) ); aatcPendingDeleteRef.current = null; } @@ -432,29 +510,38 @@ export default function ChatSidebar({ }); }, (mention) => { - if (!open || activeTab !== "aatc") { - if (user && mention.mentionedUserId === user.userId && onMentionReceivedRef.current) { + if (!open || activeTab !== 'aatc') { + if ( + user && + mention.mentionedUserId === user.userId && + onMentionReceivedRef.current + ) { onMentionReceivedRef.current({ messageId: parseInt(mention.messageId, 10), mentionedUserId: mention.mentionedUserId, mentionerUsername: mention.mentionerUsername, message: mention.message, timestamp: mention.timestamp, - sessionId: "aatc-chat", + sessionId: 'aatc-chat', }); } } }, (mention) => { - if (!open || activeTab !== "aatc") { - if (mention.airport && station && mention.airport.toUpperCase() === station.toUpperCase() && onMentionReceivedRef.current) { + if (!open || activeTab !== 'aatc') { + if ( + mention.airport && + station && + mention.airport.toUpperCase() === station.toUpperCase() && + onMentionReceivedRef.current + ) { onMentionReceivedRef.current({ messageId: parseInt(mention.messageId, 10), mentionedUserId: user.userId, mentionerUsername: mention.mentionerUsername, message: mention.message, timestamp: mention.timestamp, - sessionId: "aatc-chat", + sessionId: 'aatc-chat', }); } } @@ -462,19 +549,29 @@ export default function ChatSidebar({ (users: ConnectedGlobalChatUser[]) => { setAatcConnectedUsers(users); }, - ({ userId: typingId, username }: { userId: string; username: string }) => { + ({ + userId: typingId, + username, + }: { + userId: string; + username: string; + }) => { setAatcTypingUsers((prev) => new Map(prev).set(typingId, username)); const prev = aatcTypingTimeouts.current.get(typingId); if (prev) clearTimeout(prev); aatcTypingTimeouts.current.set( typingId, setTimeout(() => { - setAatcTypingUsers((m) => { const next = new Map(m); next.delete(typingId); return next; }); + setAatcTypingUsers((m) => { + const next = new Map(m); + next.delete(typingId); + return next; + }); aatcTypingTimeouts.current.delete(typingId); - }, 3000), + }, 3000) ); }, - "aatc", + 'aatc' ); } @@ -488,48 +585,66 @@ export default function ChatSidebar({ useEffect(() => { if (globalSocketRef.current) { - if (open && activeTab === "pfatc") { - globalSocketRef.current.socket.emit("globalChatOpened"); + if (open && activeTab === 'pfatc') { + globalSocketRef.current.socket.emit('globalChatOpened'); } else { - globalSocketRef.current.socket.emit("globalChatClosed"); + globalSocketRef.current.socket.emit('globalChatClosed'); } } if (aatcSocketRef.current) { - if (open && activeTab === "aatc") { - aatcSocketRef.current.socket.emit("globalChatOpened"); + if (open && activeTab === 'aatc') { + aatcSocketRef.current.socket.emit('globalChatOpened'); } else { - aatcSocketRef.current.socket.emit("globalChatClosed"); + aatcSocketRef.current.socket.emit('globalChatClosed'); } } }, [open, activeTab]); useEffect(() => { if (!open) return; - if (activeTab === "pfatc" && globalMessages.length === 0) { + if (activeTab === 'pfatc' && globalMessages.length === 0) { setGlobalLoading(true); fetchGlobalChatMessages() - .then((msgs) => { setGlobalMessages(msgs); setGlobalLoading(false); }) - .catch(() => { setGlobalMessages([]); setGlobalLoading(false); }); + .then((msgs) => { + setGlobalMessages(msgs); + setGlobalLoading(false); + }) + .catch(() => { + setGlobalMessages([]); + setGlobalLoading(false); + }); } - if (activeTab === "aatc" && aatcMessages.length === 0) { + if (activeTab === 'aatc' && aatcMessages.length === 0) { setAatcLoading(true); fetchAATCChatMessages() - .then((msgs) => { setAatcMessages(msgs); setAatcLoading(false); }) - .catch(() => { setAatcMessages([]); setAatcLoading(false); }); + .then((msgs) => { + setAatcMessages(msgs); + setAatcLoading(false); + }) + .catch(() => { + setAatcMessages([]); + setAatcLoading(false); + }); } }, [open, activeTab, globalMessages.length, aatcMessages.length]); - const isGlobalChat = activeTab === "pfatc" || activeTab === "aatc"; - const textMessages: ChatListMessage[] = activeTab === "aatc" ? aatcMessages : isGlobalChat ? globalMessages : messages; - const textLoading = activeTab === "aatc" ? aatcLoading : isGlobalChat ? globalLoading : loading; + const isGlobalChat = activeTab === 'pfatc' || activeTab === 'aatc'; + const textMessages: ChatListMessage[] = + activeTab === 'aatc' + ? aatcMessages + : isGlobalChat + ? globalMessages + : messages; + const textLoading = + activeTab === 'aatc' ? aatcLoading : isGlobalChat ? globalLoading : loading; const showTextChat = - (sessionId && activeTab === "session") || - (isPFATC && activeTab === "pfatc") || - (isAdvancedATC && activeTab === "aatc"); + (sessionId && activeTab === 'session') || + (isPFATC && activeTab === 'pfatc') || + (isAdvancedATC && activeTab === 'aatc'); useEffect(() => { if (chatEndRef.current && isAtBottomRef.current) { - chatEndRef.current.scrollIntoView({ behavior: "smooth" }); + chatEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [textMessages]); @@ -542,7 +657,12 @@ export default function ChatSidebar({ setInput(value); const cursorPos = textareaRef.current?.selectionStart || 0; - const result = handleMentionSuggestions(value, cursorPos, sessionUsers, user?.userId); + const result = handleMentionSuggestions( + value, + cursorPos, + sessionUsers, + user?.userId + ); setMentionSuggestions(result.suggestions); setShowMentionSuggestions(result.shouldShow); @@ -568,33 +688,43 @@ export default function ChatSidebar({ setTimeout(() => { if (textareaRef.current) { textareaRef.current.focus(); - textareaRef.current.setSelectionRange(result.newCursorPos, result.newCursorPos); + textareaRef.current.setSelectionRange( + result.newCursorPos, + result.newCursorPos + ); } }, 0); }; const sendMessage = () => { - if (!input.trim() || !socketRef.current || input.trim().length > 500) return; - socketRef.current.socket.emit("chatMessage", { + if (!input.trim() || !socketRef.current || input.trim().length > 500) + return; + socketRef.current.socket.emit('chatMessage', { sessionId, user, message: input.trim(), }); - setInput(""); + setInput(''); lastSessionTypingEmit.current = 0; }; const sendGlobalMessage = () => { - const isAatcTab = activeTab === "aatc"; - const socketToUse = isAatcTab ? aatcSocketRef.current : globalSocketRef.current; + const isAatcTab = activeTab === 'aatc'; + const socketToUse = isAatcTab + ? aatcSocketRef.current + : globalSocketRef.current; const inputValue = isAatcTab ? aatcInput : globalInput; - if (!inputValue.trim() || !socketToUse || inputValue.trim().length > 500) return; - socketToUse.socket.emit("globalChatMessage", { user, message: inputValue.trim() }); + if (!inputValue.trim() || !socketToUse || inputValue.trim().length > 500) + return; + socketToUse.socket.emit('globalChatMessage', { + user, + message: inputValue.trim(), + }); if (isAatcTab) { - setAatcInput(""); + setAatcInput(''); lastAatcTypingEmit.current = 0; } else { - setGlobalInput(""); + setGlobalInput(''); lastGlobalTypingEmit.current = 0; } }; @@ -611,8 +741,10 @@ export default function ChatSidebar({ } async function handleGlobalDelete(msgId: number) { - const isAatcTab = activeTab === "aatc"; - const socketToUse = isAatcTab ? aatcSocketRef.current : globalSocketRef.current; + const isAatcTab = activeTab === 'aatc'; + const socketToUse = isAatcTab + ? aatcSocketRef.current + : globalSocketRef.current; if (!socketToUse || !user) return; if (isAatcTab) { @@ -630,8 +762,9 @@ export default function ChatSidebar({ } const handleGlobalInputChange = (value: string) => { - const isAatcTab = activeTab === "aatc"; - if (isAatcTab) setAatcInput(value); else setGlobalInput(value); + const isAatcTab = activeTab === 'aatc'; + if (isAatcTab) setAatcInput(value); + else setGlobalInput(value); const cursorPos = globalTextareaRef.current?.selectionStart || 0; const result = handleGlobalMentionSuggestions( @@ -641,14 +774,16 @@ export default function ChatSidebar({ isAatcTab ? aatcMessages : globalMessages, isAatcTab ? aatcConnectedUsers : connectedGlobalChatUsers, sessionUsers, - user?.userId, + user?.userId ); setGlobalSuggestions(result.suggestions); setShowGlobalSuggestions(result.shouldShow); setSelectedGlobalSuggestionIndex(result.shouldShow ? 0 : -1); - const socketToUse = isAatcTab ? aatcSocketRef.current : globalSocketRef.current; + const socketToUse = isAatcTab + ? aatcSocketRef.current + : globalSocketRef.current; const lastEmitRef = isAatcTab ? lastAatcTypingEmit : lastGlobalTypingEmit; if (value && socketToUse && user) { const now = Date.now(); @@ -660,43 +795,57 @@ export default function ChatSidebar({ }; const insertGlobalMention = (value: string) => { - const isAatcTab = activeTab === "aatc"; + const isAatcTab = activeTab === 'aatc'; const cursorPos = globalTextareaRef.current?.selectionStart || 0; - const result = insertMentionIntoText(isAatcTab ? aatcInput : globalInput, cursorPos, value); + const result = insertMentionIntoText( + isAatcTab ? aatcInput : globalInput, + cursorPos, + value + ); - if (isAatcTab) setAatcInput(result.newText); else setGlobalInput(result.newText); + if (isAatcTab) setAatcInput(result.newText); + else setGlobalInput(result.newText); setShowGlobalSuggestions(false); setSelectedGlobalSuggestionIndex(-1); setTimeout(() => { if (globalTextareaRef.current) { globalTextareaRef.current.focus(); - globalTextareaRef.current.setSelectionRange(result.newCursorPos, result.newCursorPos); + globalTextareaRef.current.setSelectionRange( + result.newCursorPos, + result.newCursorPos + ); } }, 0); }; - const handleComposerKeyDown = (e: React.KeyboardEvent) => { - if (activeTab === "pfatc" || activeTab === "aatc") { - const hasSuggestions = showGlobalSuggestions && globalSuggestions.length > 0; + const handleComposerKeyDown = ( + e: React.KeyboardEvent + ) => { + if (activeTab === 'pfatc' || activeTab === 'aatc') { + const hasSuggestions = + showGlobalSuggestions && globalSuggestions.length > 0; if (hasSuggestions) { - if (e.key === "ArrowDown") { + if (e.key === 'ArrowDown') { e.preventDefault(); - setSelectedGlobalSuggestionIndex((prev) => (prev + 1) % globalSuggestions.length); - } else if (e.key === "ArrowUp") { + setSelectedGlobalSuggestionIndex( + (prev) => (prev + 1) % globalSuggestions.length + ); + } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedGlobalSuggestionIndex( - (prev) => (prev - 1 + globalSuggestions.length) % globalSuggestions.length, + (prev) => + (prev - 1 + globalSuggestions.length) % globalSuggestions.length ); - } else if (e.key === "Enter" && !e.shiftKey) { + } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if ( selectedGlobalSuggestionIndex >= 0 && selectedGlobalSuggestionIndex < globalSuggestions.length ) { const suggestion = globalSuggestions[selectedGlobalSuggestionIndex]; - if (suggestion.type === "airport") { + if (suggestion.type === 'airport') { const airport = suggestion.data as { icao: string; name: string; @@ -707,41 +856,43 @@ export default function ChatSidebar({ insertGlobalMention(u.username); } } - } else if (e.key === "Escape") { + } else if (e.key === 'Escape') { setShowGlobalSuggestions(false); setSelectedGlobalSuggestionIndex(-1); } - } else if (e.key === "Enter" && !e.shiftKey) { + } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendGlobalMessage(); } return; } - if (activeTab === "session") { + if (activeTab === 'session') { if (showMentionSuggestions) { - if (e.key === "ArrowDown") { + if (e.key === 'ArrowDown') { e.preventDefault(); - setSelectedSuggestionIndex((prev) => (prev + 1) % mentionSuggestions.length); - } else if (e.key === "ArrowUp") { + setSelectedSuggestionIndex( + (prev) => (prev + 1) % mentionSuggestions.length + ); + } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedSuggestionIndex((prev) => - prev === 0 ? mentionSuggestions.length - 1 : prev - 1, + prev === 0 ? mentionSuggestions.length - 1 : prev - 1 ); - } else if (e.key === "Enter") { + } else if (e.key === 'Enter') { e.preventDefault(); if (selectedSuggestionIndex >= 0) { insertMention(mentionSuggestions[selectedSuggestionIndex].username); } - } else if (e.key === "Escape") { + } else if (e.key === 'Escape') { setShowMentionSuggestions(false); setSelectedSuggestionIndex(-1); } } else { - if (e.key === "Enter" && !e.shiftKey) { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); - } else if (e.key === "Escape") { + } else if (e.key === 'Escape') { setShowMentionSuggestions(false); } } @@ -764,29 +915,36 @@ export default function ChatSidebar({ try { if (reportingGlobalMessage) { - if (activeTab === "aatc") { + if (activeTab === 'aatc') { await reportAATCChatMessage(reportingMessageId, reportReason.trim()); } else { - await reportGlobalChatMessage(reportingMessageId, reportReason.trim()); + await reportGlobalChatMessage( + reportingMessageId, + reportReason.trim() + ); } } else { - await reportChatMessage(sessionId, reportingMessageId, reportReason.trim()); + await reportChatMessage( + sessionId, + reportingMessageId, + reportReason.trim() + ); } - setToast({ message: "Message reported successfully.", type: "success" }); + setToast({ message: 'Message reported successfully.', type: 'success' }); setShowReportModal(false); - setReportReason(""); + setReportReason(''); setReportingMessageId(null); setReportingGlobalMessage(false); } catch { - setToast({ message: "Failed to report message.", type: "error" }); + setToast({ message: 'Failed to report message.', type: 'error' }); } } useEffect(() => { if (open) { - if (activeTab === "session") { + if (activeTab === 'session') { setUnreadSessionMentions([]); - } else if (activeTab === "pfatc" || activeTab === "aatc") { + } else if (activeTab === 'pfatc' || activeTab === 'aatc') { setUnreadGlobalMentions([]); } } @@ -830,16 +988,16 @@ export default function ChatSidebar({ }); }, () => userVolumesRef.current, - (devices: MediaDeviceInfo[]) => setVoiceDevices(devices), + (devices: MediaDeviceInfo[]) => setVoiceDevices(devices) ); if (voiceSocketRef.current) { - voiceSocketRef.current.socket.emit("get-voice-users"); + voiceSocketRef.current.socket.emit('get-voice-users'); voiceSocketRef.current.socket.on( - "user-left-voice", + 'user-left-voice', ({ userId: leftId }: { userId: string }) => { setVoiceUsers((prev) => prev.filter((u) => u.userId !== leftId)); - }, + } ); } @@ -859,53 +1017,62 @@ export default function ChatSidebar({ useEffect(() => { if (open && voiceSocketRef.current) { - voiceSocketRef.current.socket.emit("get-voice-users"); + voiceSocketRef.current.socket.emit('get-voice-users'); } }, [open]); useEffect(() => { try { - localStorage.setItem("userVolumes", JSON.stringify(Array.from(userVolumes.entries()))); + localStorage.setItem( + 'userVolumes', + JSON.stringify(Array.from(userVolumes.entries())) + ); } catch (error) { - console.warn("Failed to save user volumes to localStorage:", error); + console.warn('Failed to save user volumes to localStorage:', error); } }, [userVolumes]); - type SidebarTabId = "session" | "voice" | "pfatc" | "aatc"; + type SidebarTabId = 'session' | 'voice' | 'pfatc' | 'aatc'; const sidebarTabs = useMemo(() => { const items: SidebarTabId[] = []; if (sessionId) { - items.push("session", "voice"); + items.push('session', 'voice'); } - if (isPFATC) items.push("pfatc"); - if (isAdvancedATC) items.push("aatc"); + if (isPFATC) items.push('pfatc'); + if (isAdvancedATC) items.push('aatc'); return items; }, [sessionId, isPFATC, isAdvancedATC]); - const activeTabIndex = Math.max(0, sidebarTabs.indexOf(activeTab as SidebarTabId)); + const activeTabIndex = Math.max( + 0, + sidebarTabs.indexOf(activeTab as SidebarTabId) + ); const tabCount = sidebarTabs.length; return (
- {activeTab === "session" - ? "Session Chat" - : activeTab === "voice" - ? "Voice Chat" - : activeTab === "aatc" - ? "AATC Chat" - : "PFATC Chat"} + {activeTab === 'session' + ? 'Session Chat' + : activeTab === 'voice' + ? 'Voice Chat' + : activeTab === 'aatc' + ? 'AATC Chat' + : 'PFATC Chat'}
-
@@ -916,7 +1083,10 @@ export default function ChatSidebar({
0 ? `calc((100% - 0.5rem) / ${tabCount})` : undefined, + width: + tabCount > 0 + ? `calc((100% - 0.5rem) / ${tabCount})` + : undefined, left: tabCount > 0 ? `calc(0.25rem + ${activeTabIndex} * ((100% - 0.5rem) / ${tabCount}))` @@ -932,49 +1102,53 @@ export default function ChatSidebar({ type="button" onClick={() => setActiveTab(tabId)} className={`relative z-10 flex flex-1 items-center justify-center gap-1.5 rounded-full px-2 py-2 text-sm font-semibold transition-colors sm:gap-2 sm:px-3 ${ - isActive ? "text-white" : "text-zinc-400 hover:text-zinc-200" + isActive + ? 'text-white' + : 'text-zinc-400 hover:text-zinc-200' }`} > - {tabId === "session" && ( + {tabId === 'session' && ( <> Session - {unreadSessionCount > 0 && activeTab !== "session" && ( + {unreadSessionCount > 0 && activeTab !== 'session' && ( {unreadSessionCount} )} )} - {tabId === "voice" && ( + {tabId === 'voice' && ( <> Voice {voiceUsers.length} )} - {tabId === "pfatc" && ( + {tabId === 'pfatc' && ( <> PFATC - {unreadGlobalCount > 0 && activeTab !== "pfatc" && ( + {unreadGlobalCount > 0 && activeTab !== 'pfatc' && ( {unreadGlobalCount} )} )} - {tabId === "aatc" && ( + {tabId === 'aatc' && ( <> AATC - {unreadGlobalCount > 0 && activeTab !== "aatc" && ( + {unreadGlobalCount > 0 && activeTab !== 'aatc' && ( {unreadGlobalCount} @@ -988,40 +1162,52 @@ export default function ChatSidebar({
)} - {activeTab !== "voice" && ( + {activeTab !== 'voice' && (
<> - {activeTab === "session" ? ( + {activeTab === 'session' ? ( sessionUsers.map((sessionUser) => ( {sessionUser.username} )) ) : (
- {(activeTab === "aatc" ? aatcConnectedUsers : connectedGlobalChatUsers).map((globalUser) => ( + {(activeTab === 'aatc' + ? aatcConnectedUsers + : connectedGlobalChatUsers + ).map((globalUser) => ( {globalUser.username} { - e.currentTarget.src = "/assets/app/default/avatar.webp"; + e.currentTarget.src = '/assets/app/default/avatar.webp'; }} /> ))} - {(activeTab === "aatc" ? aatcConnectedUsers : connectedGlobalChatUsers).length === 0 && ( -
No controllers online
+ {(activeTab === 'aatc' + ? aatcConnectedUsers + : connectedGlobalChatUsers + ).length === 0 && ( +
+ No controllers online +
)}
)} @@ -1035,7 +1221,7 @@ export default function ChatSidebar({
@@ -1078,17 +1264,31 @@ export default function ChatSidebar({
)} {/* Voice Chat Content */} - {sessionId && activeTab === "voice" && ( + {sessionId && activeTab === 'voice' && (
{connectionState.error && ( - {connectionState.error} + + {connectionState.error} + )}
@@ -1163,7 +1371,13 @@ export default function ChatSidebar({ /> - {toast && setToast(null)} />} + {toast && ( + setToast(null)} + /> + )}
); -} \ No newline at end of file +} diff --git a/src/components/chat/ChatTextComposer.tsx b/src/components/chat/ChatTextComposer.tsx index 518dca86..a40e5f42 100644 --- a/src/components/chat/ChatTextComposer.tsx +++ b/src/components/chat/ChatTextComposer.tsx @@ -7,10 +7,7 @@ import Button from '../common/Button'; export type GlobalChatSuggestion = { type: 'user' | 'airport'; - data: - | SessionUser - | { icao: string; name: string } - | ConnectedGlobalChatUser; + data: SessionUser | { icao: string; name: string } | ConnectedGlobalChatUser; }; const mentionListClass = @@ -203,10 +200,7 @@ export function ChatTextComposer({
([]); + const [audioInputDevices, setAudioInputDevices] = useState( + [] + ); const [selectedAudioInput, setSelectedAudioInput] = useState(() => { try { return localStorage.getItem('voice-chat-audio-input') || 'default'; @@ -79,7 +81,10 @@ export default function VoiceChat({ if (!navigator.mediaDevices) return; navigator.mediaDevices.addEventListener('devicechange', refreshDevices); return () => { - navigator.mediaDevices.removeEventListener('devicechange', refreshDevices); + navigator.mediaDevices.removeEventListener( + 'devicechange', + refreshDevices + ); }; }, [isInVoice, refreshDevices]); @@ -92,19 +97,25 @@ export default function VoiceChat({ useEffect(() => { try { localStorage.setItem('voice-chat-audio-input', selectedAudioInput); - } catch {/* ignore */} + } catch { + /* ignore */ + } }, [selectedAudioInput]); useEffect(() => { try { localStorage.setItem('voice-chat-muted', isMuted.toString()); - } catch {/* ignore */} + } catch { + /* ignore */ + } }, [isMuted]); useEffect(() => { try { localStorage.setItem('voice-chat-deafened', isDeafened.toString()); - } catch {/* ignore */} + } catch { + /* ignore */ + } }, [isDeafened]); useEffect(() => { diff --git a/src/components/chat/index.ts b/src/components/chat/index.ts index c06fdd39..3f80bfbc 100644 --- a/src/components/chat/index.ts +++ b/src/components/chat/index.ts @@ -1,4 +1,7 @@ export { default as ChatSidebar } from './ChatSidebar'; export { ChatMessageRow, type ChatListMessage } from './ChatMessageRow'; -export { ChatTextComposer, type GlobalChatSuggestion } from './ChatTextComposer'; +export { + ChatTextComposer, + type GlobalChatSuggestion, +} from './ChatTextComposer'; export { default as VoiceChat } from './VoiceChat'; diff --git a/src/components/common/Checkbox.tsx b/src/components/common/Checkbox.tsx index 78edf87a..c195b63c 100644 --- a/src/components/common/Checkbox.tsx +++ b/src/components/common/Checkbox.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ReactNode } from 'react'; interface CheckboxProps { checked: boolean; @@ -17,15 +17,18 @@ export default function Checkbox({ checked, onChange, label, - className = "", - checkedClass = "bg-blue-600 border-blue-600", - uncheckedClass = "bg-transparent border-gray-400", + className = '', + checkedClass = 'bg-blue-600 border-blue-600', + uncheckedClass = 'bg-transparent border-gray-400', flashing = false, id, disabled = false, }: CheckboxProps) { return ( -
); -} \ No newline at end of file +} diff --git a/src/components/developers/DeveloperUsageCharts.tsx b/src/components/developers/DeveloperUsageCharts.tsx index 0fa72d45..347b78c2 100644 --- a/src/components/developers/DeveloperUsageCharts.tsx +++ b/src/components/developers/DeveloperUsageCharts.tsx @@ -1,4 +1,4 @@ -import { useId, useMemo } from "react"; +import { useId, useMemo } from 'react'; import { Area, AreaChart, @@ -10,20 +10,20 @@ import { Tooltip, XAxis, YAxis, -} from "recharts"; +} from 'recharts'; const CHART_TOOLTIP_PANEL = - "rounded-xl border border-zinc-700 bg-zinc-950 px-3.5 py-2.5 shadow-2xl shadow-black/50"; + 'rounded-xl border border-zinc-700 bg-zinc-950 px-3.5 py-2.5 shadow-2xl shadow-black/50'; const SCOPE_COLORS = [ - "#60a5fa", - "#34d399", - "#fbbf24", - "#f472b6", - "#a78bfa", - "#fb7185", - "#2dd4bf", - "#94a3b8", + '#60a5fa', + '#34d399', + '#fbbf24', + '#f472b6', + '#a78bfa', + '#fb7185', + '#2dd4bf', + '#94a3b8', ]; type UsageTooltipContentProps = { @@ -39,43 +39,43 @@ function shortAxisDate(iso: string): string { const raw = /^\d{4}-\d{2}-\d{2}$/.test(iso) ? `${iso}T12:00:00` : iso; const d = new Date(raw); if (Number.isNaN(d.getTime())) return iso; - if (iso.includes("T")) { + if (iso.includes('T')) { return d.toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', }); } - return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } function fullTooltipDate(iso: string): string { const raw = /^\d{4}-\d{2}-\d{2}$/.test(iso) ? `${iso}T12:00:00` : iso; const d = new Date(raw); if (Number.isNaN(d.getTime())) return iso; - if (iso.includes("T")) { + if (iso.includes('T')) { return d.toLocaleString(undefined, { - weekday: "short", - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', }); } return d.toLocaleDateString(undefined, { - weekday: "short", - month: "short", - day: "numeric", - year: "numeric", + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', }); } type DailyRow = { date: string; count: number }; export function DeveloperRequestsAreaChart({ data }: { data: DailyRow[] }) { - const gid = useId().replace(/:/g, ""); + const gid = useId().replace(/:/g, ''); const gradientId = `reqFill-${gid}`; const points = useMemo( @@ -85,17 +85,22 @@ export function DeveloperRequestsAreaChart({ data }: { data: DailyRow[] }) { label: shortAxisDate(d.date), requests: d.count, })), - [data], + [data] ); - const maxRequests = useMemo(() => points.reduce((m, p) => Math.max(m, p.requests), 0), [points]); + const maxRequests = useMemo( + () => points.reduce((m, p) => Math.max(m, p.requests), 0), + [points] + ); const yAxisMax = maxRequests === 0 ? 8 : Math.ceil(maxRequests * 1.12); if (points.length === 0) { return (
-

No usage data for this period yet.

+

+ No usage data for this period yet. +

); } @@ -107,7 +112,10 @@ export function DeveloperRequestsAreaChart({ data }: { data: DailyRow[] }) { aria-label="Request volume over time" > - + @@ -124,8 +132,8 @@ export function DeveloperRequestsAreaChart({ data }: { data: DailyRow[] }) { { if (!active || !payload?.length) return null; @@ -153,7 +161,7 @@ export function DeveloperRequestsAreaChart({ data }: { data: DailyRow[] }) { {fullTooltipDate(row.date)}

- {row.requests.toLocaleString()}{" "} + {row.requests.toLocaleString()}{' '} requests

@@ -169,8 +177,8 @@ export function DeveloperRequestsAreaChart({ data }: { data: DailyRow[] }) { activeDot={{ r: 6, strokeWidth: 0, - fill: "#bae6fd", - className: "drop-shadow-[0_0_8px_rgba(125,211,252,0.65)]", + fill: '#bae6fd', + className: 'drop-shadow-[0_0_8px_rgba(125,211,252,0.65)]', }} dot={false} isAnimationActive={points.length <= 96} @@ -198,10 +206,13 @@ export function DeveloperScopeDonutChart({ name: scopeLabelMap.get(r.scope_id) ?? r.scope_id, value: r.count, })), - [rows, scopeLabelMap], + [rows, scopeLabelMap] ); - const total = useMemo(() => pieData.reduce((s, d) => s + d.value, 0), [pieData]); + const total = useMemo( + () => pieData.reduce((s, d) => s + d.value, 0), + [pieData] + ); return (
@@ -227,7 +238,10 @@ export function DeveloperScopeDonutChart({ animationEasing="ease-out" > {pieData.map((_, i) => ( - + ))} 0 ? Math.round((v / total) * 1000) / 10 : 0; + const pct = + total > 0 ? Math.round((v / total) * 1000) / 10 : 0; return (

{String(p.name)}

- {v.toLocaleString()} call{v === 1 ? "" : "s"} · {pct}% + {v.toLocaleString()} call{v === 1 ? '' : 's'} · {pct}%

); @@ -294,4 +309,4 @@ export function DeveloperScopeDonutChart({
); -} \ No newline at end of file +} diff --git a/src/components/developers/ScopeTagSelector.tsx b/src/components/developers/ScopeTagSelector.tsx index a6e9b48d..f6e40e3a 100644 --- a/src/components/developers/ScopeTagSelector.tsx +++ b/src/components/developers/ScopeTagSelector.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo } from 'react'; import { Building2, Plane, @@ -21,7 +21,7 @@ import { Bell, ScrollText, type LucideIcon, -} from "lucide-react"; +} from 'lucide-react'; export interface ScopeCatalogEntry { id: string; @@ -30,28 +30,28 @@ export interface ScopeCatalogEntry { } const SCOPE_ICONS: Record = { - "data.airports": Building2, - "data.aircrafts": Plane, - "data.airlines": Globe, - "data.frequencies": Radio, - "data.backgrounds": Image, - "data.airport_runways": ArrowLeftRight, - "data.airport_sids": TrendingUp, - "data.airport_stars": TrendingDown, - "data.find_route": Route, - "data.airport_status": Activity, - "sessions.network_pfatc": Network, - "sessions.network_aatc": Network, - "sessions.list": List, - "sessions.create": Plus, - "sessions.read": Eye, - "flights.list": Layers, - "flights.read": Eye, - "flights.create": PlaneTakeoff, - "flights.update": PencilLine, - "ratings.controller_stats": BarChart3, - "notifications.read": Bell, - "flight_logs.read": ScrollText, + 'data.airports': Building2, + 'data.aircrafts': Plane, + 'data.airlines': Globe, + 'data.frequencies': Radio, + 'data.backgrounds': Image, + 'data.airport_runways': ArrowLeftRight, + 'data.airport_sids': TrendingUp, + 'data.airport_stars': TrendingDown, + 'data.find_route': Route, + 'data.airport_status': Activity, + 'sessions.network_pfatc': Network, + 'sessions.network_aatc': Network, + 'sessions.list': List, + 'sessions.create': Plus, + 'sessions.read': Eye, + 'flights.list': Layers, + 'flights.read': Eye, + 'flights.create': PlaneTakeoff, + 'flights.update': PencilLine, + 'ratings.controller_stats': BarChart3, + 'notifications.read': Bell, + 'flight_logs.read': ScrollText, }; interface ScopeTagSelectorProps { @@ -60,7 +60,7 @@ interface ScopeTagSelectorProps { onChange: (next: Set) => void; readOnly?: boolean; className?: string; - appearance?: "dark" | "light"; + appearance?: 'dark' | 'light'; } export default function ScopeTagSelector({ @@ -68,14 +68,14 @@ export default function ScopeTagSelector({ selected, onChange, readOnly = false, - className = "", - appearance = "dark", + className = '', + appearance = 'dark', }: ScopeTagSelectorProps) { - const light = appearance === "light"; + const light = appearance === 'light'; const groups = useMemo(() => { const m = new Map(); for (const c of catalog) { - const g = c.id.split(".")[0] ?? "other"; + const g = c.id.split('.')[0] ?? 'other'; const arr = m.get(g) ?? []; arr.push(c); m.set(g, arr); @@ -93,15 +93,17 @@ export default function ScopeTagSelector({ if (groups.length === 0) { return ( -

+

No scopes available.

); } const groupLabelClass = light - ? "text-[10px] font-semibold uppercase tracking-widest text-sky-800/55 mb-2" - : "text-[10px] font-semibold uppercase tracking-widest text-zinc-500 mb-2"; + ? 'text-[10px] font-semibold uppercase tracking-widest text-sky-800/55 mb-2' + : 'text-[10px] font-semibold uppercase tracking-widest text-zinc-500 mb-2'; return (
@@ -114,30 +116,30 @@ export default function ScopeTagSelector({ const Icon = SCOPE_ICONS[c.id]; const chipClass = light ? [ - "inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-150 border", - readOnly ? "cursor-default" : "cursor-pointer", + 'inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-150 border', + readOnly ? 'cursor-default' : 'cursor-pointer', active - ? "bg-sky-600 text-white border-sky-500 shadow-sm shadow-sky-900/10" + ? 'bg-sky-600 text-white border-sky-500 shadow-sm shadow-sky-900/10' : readOnly - ? "bg-slate-100/80 text-slate-500 border-slate-200" - : "bg-white/90 text-slate-700 border-slate-200 hover:border-sky-300 hover:bg-white", - ].join(" ") + ? 'bg-slate-100/80 text-slate-500 border-slate-200' + : 'bg-white/90 text-slate-700 border-slate-200 hover:border-sky-300 hover:bg-white', + ].join(' ') : [ - "inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-all duration-150 border", - readOnly ? "cursor-default" : "cursor-pointer", + 'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-all duration-150 border', + readOnly ? 'cursor-default' : 'cursor-pointer', active - ? "bg-zinc-700 text-zinc-50 border-zinc-600" + ? 'bg-zinc-700 text-zinc-50 border-zinc-600' : readOnly - ? "bg-transparent text-zinc-600 border-zinc-800" - : "bg-transparent text-zinc-400 border-zinc-700 hover:border-zinc-500 hover:text-zinc-200", - ].join(" "); + ? 'bg-transparent text-zinc-600 border-zinc-800' + : 'bg-transparent text-zinc-400 border-zinc-700 hover:border-zinc-500 hover:text-zinc-200', + ].join(' '); const iconClass = light ? active - ? "text-white" - : "text-slate-500" + ? 'text-white' + : 'text-slate-500' : active - ? "text-zinc-300" - : "text-zinc-600"; + ? 'text-zinc-300' + : 'text-zinc-600'; return ( @@ -196,7 +362,6 @@ function MockToolbar({ avatarCount }: { avatarCount: number }) { ); } - function DemoRouteModal({ visible, resetKey, @@ -247,8 +412,11 @@ function DemoRouteModal({ }); }, 2400); - return () => { clearTimeout(t1); clearTimeout(t2); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + return () => { + clearTimeout(t1); + clearTimeout(t2); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible, resetKey, paused]); return ( @@ -259,7 +427,9 @@ function DemoRouteModal({ left: '44%', width: '520px', opacity: visible ? 1 : 0, - transform: visible ? 'translateX(-50%) translateY(-110px) scale(1)' : 'translateX(-50%) translateY(-10px) scale(0.97)', + transform: visible + ? 'translateX(-50%) translateY(-110px) scale(1)' + : 'translateX(-50%) translateY(-10px) scale(0.97)', transition: 'opacity 0.4s ease, transform 0.4s ease', }} > @@ -267,27 +437,41 @@ function DemoRouteModal({
-

AAL2314 — B77W

+

+ AAL2314 — B77W +

+
+
+
-
-
Departure
+
+ Departure +
EGKK
-
Arrival
+
+ Arrival +
MDPC
-
SID
-
{sid || 'NOVMA1X'}
+
+ SID +
+
+ {sid || 'NOVMA1X'} +
-
STAR
+
+ STAR +
{star || '—'}
@@ -298,9 +482,11 @@ function DemoRouteModal({
- {generating - ? - : } + {generating ? ( + + ) : ( + + )} {generating ? 'Generating…' : 'Generate'}
@@ -314,15 +500,20 @@ function DemoRouteModal({ className="w-full bg-zinc-800 border border-zinc-600 rounded-lg p-3 font-mono text-sm leading-relaxed min-h-[72px]" style={{ wordBreak: 'break-all' }} > - {generating - ? Generating route… - : route - ? {route} - : Enter route…} + {generating ? ( + Generating route… + ) : route ? ( + {route} + ) : ( + Enter route… + )}
{route && !generating && ( -
+
(null); - const dotsPillRef = useRef(null); + const cardRef = useRef(null); + const dotsPillRef = useRef(null); const dotsContentRef = useRef(null); - const playBtnRef = useRef(null); + const playBtnRef = useRef(null); - const [viewHighlight, setViewHighlight] = useState(false); - const [viewCopied, setViewCopied] = useState(false); + const [viewHighlight, setViewHighlight] = useState(false); + const [viewCopied, setViewCopied] = useState(false); const [submitHighlight, setSubmitHighlight] = useState(false); - const [submitCopied, setSubmitCopied] = useState(false); - const [avatarCount, setAvatarCount] = useState(1); - const [stripsShown, setStripsShown] = useState(0); + const [submitCopied, setSubmitCopied] = useState(false); + const [avatarCount, setAvatarCount] = useState(1); + const [stripsShown, setStripsShown] = useState(0); - const [bawReq, setBawReq] = useState(0); - const [ezyReq, setEzyReq] = useState(0); - const [aalReq, setAalReq] = useState(0); + const [bawReq, setBawReq] = useState(0); + const [ezyReq, setEzyReq] = useState(0); + const [aalReq, setAalReq] = useState(0); const [bawReqActive, setBawReqActive] = useState(false); const [ezyReqActive, setEzyReqActive] = useState(false); const [aalReqActive, setAalReqActive] = useState(false); const [bawCleared, setBawCleared] = useState(false); - const [bawStatus, setBawStatus] = useState<'PENDING' | 'STUP' | 'PUSH'>('PENDING'); + const [bawStatus, setBawStatus] = useState<'PENDING' | 'STUP' | 'PUSH'>( + 'PENDING' + ); const [squawkHighlight, setSquawkHighlight] = useState(false); - const [squawkSpinning, setSquawkSpinning] = useState(false); - const [aalSqkNew, setAalSqkNew] = useState(null); + const [squawkSpinning, setSquawkSpinning] = useState(false); + const [aalSqkNew, setAalSqkNew] = useState(null); const [routeModalVisible, setRouteModalVisible] = useState(false); - const [routeGenerated, setRouteGenerated] = useState(false); + const [routeGenerated, setRouteGenerated] = useState(false); const elapsedAtPauseRef = useRef(0); - const runStartRef = useRef(Date.now()); - const captionRef = useRef(null); - const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]); - const ctaContentRef = useRef(null); - const dotRefs = useRef<(HTMLDivElement | null)[]>([]); + const runStartRef = useRef(Date.now()); + const captionRef = useRef(null); + const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]); + const ctaContentRef = useRef(null); + const dotRefs = useRef<(HTMLDivElement | null)[]>([]); const goTo = (i: number, source: 'dot' | 'restart' = 'dot') => { elapsedAtPauseRef.current = 0; setDone(false); setSlide(i); - setSeq(s => s + 1); + setSeq((s) => s + 1); setPaused(false); posthog?.capture('showcase_navigate', { slide: i, source }); }; useLayoutEffect(() => { - if (cardRef.current) gsap.set(cardRef.current, { opacity: 0, scale: 0.96, y: 24 }); - if (dotsPillRef.current) gsap.set(dotsPillRef.current, { scaleX: 0.15, transformOrigin: 'center' }); - if (dotsContentRef.current) gsap.set(dotsContentRef.current, { opacity: 0 }); - if (playBtnRef.current) gsap.set(playBtnRef.current, { scale: 0, opacity: 0, transformOrigin: 'center' }); + if (cardRef.current) + gsap.set(cardRef.current, { opacity: 0, scale: 0.96, y: 24 }); + if (dotsPillRef.current) + gsap.set(dotsPillRef.current, { + scaleX: 0.15, + transformOrigin: 'center', + }); + if (dotsContentRef.current) + gsap.set(dotsContentRef.current, { opacity: 0 }); + if (playBtnRef.current) + gsap.set(playBtnRef.current, { + scale: 0, + opacity: 0, + transformOrigin: 'center', + }); }, []); useEffect(() => { @@ -408,24 +612,41 @@ export default function ProductShowcase() { if (!hasEnteredRef.current) { hasEnteredRef.current = true; posthog?.capture('showcase_viewed'); - gsap.to(card, { opacity: 1, scale: 1, y: 0, duration: 0.7, ease: 'circ.out' }); + gsap.to(card, { + opacity: 1, + scale: 1, + y: 0, + duration: 0.7, + ease: 'circ.out', + }); const pill = dotsPillRef.current; - const btn = playBtnRef.current; + const btn = playBtnRef.current; if (pill) { - gsap.timeline() - .to(pill, { scaleX: 1.06, duration: 0.35, ease: 'power3.out' }) - .to(pill, { scaleX: 0.97, duration: 0.1, ease: 'power1.inOut' }) - .to(pill, { scaleX: 1, duration: 0.1, ease: 'power1.inOut' }); + gsap + .timeline() + .to(pill, { scaleX: 1.06, duration: 0.35, ease: 'power3.out' }) + .to(pill, { scaleX: 0.97, duration: 0.1, ease: 'power1.inOut' }) + .to(pill, { scaleX: 1, duration: 0.1, ease: 'power1.inOut' }); } const dotsContent = dotsContentRef.current; if (dotsContent) { - gsap.to(dotsContent, { opacity: 1, duration: 0.12, delay: 0.35 + 0.1 + 0.1 }); + gsap.to(dotsContent, { + opacity: 1, + duration: 0.12, + delay: 0.35 + 0.1 + 0.1, + }); } if (btn) { - gsap.to(btn, { scale: 1, opacity: 1, duration: 0.4, ease: 'back.out(2)', delay: 0.35 + 0.1 + 0.1 }); + gsap.to(btn, { + scale: 1, + opacity: 1, + duration: 0.4, + ease: 'back.out(2)', + delay: 0.35 + 0.1 + 0.1, + }); } } - setPaused(p => (p ? false : p)); + setPaused((p) => (p ? false : p)); } else { if (hasEnteredRef.current) setPaused(true); } @@ -444,20 +665,26 @@ export default function ProductShowcase() { } runStartRef.current = Date.now(); const remaining = PHASES[slide].duration - elapsedAtPauseRef.current; - const t = setTimeout(() => { - elapsedAtPauseRef.current = 0; - if (slide === PHASES.length - 1) { - setDone(true); - setPaused(true); - posthog?.capture('showcase_completed'); - } else { - setSlide(s => { - posthog?.capture('showcase_slide_view', { slide: s + 1, caption: PHASES[s + 1]?.caption }); - return s + 1; - }); - setSeq(s => s + 1); - } - }, Math.max(0, remaining)); + const t = setTimeout( + () => { + elapsedAtPauseRef.current = 0; + if (slide === PHASES.length - 1) { + setDone(true); + setPaused(true); + posthog?.capture('showcase_completed'); + } else { + setSlide((s) => { + posthog?.capture('showcase_slide_view', { + slide: s + 1, + caption: PHASES[s + 1]?.caption, + }); + return s + 1; + }); + setSeq((s) => s + 1); + } + }, + Math.max(0, remaining) + ); return () => clearTimeout(t); }, [slide, seq, paused, done, posthog]); @@ -482,15 +709,23 @@ export default function ProductShowcase() { gsap.set(row, { opacity: newStripsShown >= i + 1 ? 1 : 0, y: 0 }); }); if (slide <= 4) { - setBawReq(0); setEzyReq(0); setAalReq(0); - setBawReqActive(false); setEzyReqActive(false); setAalReqActive(false); + setBawReq(0); + setEzyReq(0); + setAalReq(0); + setBawReqActive(false); + setEzyReqActive(false); + setAalReqActive(false); + } + if (slide < 5) { + setBawCleared(false); + setBawStatus('PENDING'); } - if (slide < 5) { setBawCleared(false); setBawStatus('PENDING'); } }, [slide, seq]); useEffect(() => { if (!captionRef.current) return; - gsap.fromTo(captionRef.current, + gsap.fromTo( + captionRef.current, { opacity: 0, y: 14 }, { opacity: 1, y: 0, duration: 0.55, ease: 'power3.out' } ); @@ -504,7 +739,8 @@ export default function ProductShowcase() { if (slide !== 3 || stripsShown === 0 || stripsShown <= prev) return; const row = rowRefs.current[stripsShown - 1]; if (!row) return; - gsap.fromTo(row, + gsap.fromTo( + row, { opacity: 0, y: -10 }, { opacity: 1, y: 0, duration: 0.6, ease: 'power3.out' } ); @@ -512,7 +748,8 @@ export default function ProductShowcase() { useEffect(() => { if (!done || !ctaContentRef.current) return; - gsap.fromTo(ctaContentRef.current, + gsap.fromTo( + ctaContentRef.current, { opacity: 0, scale: 0.9, y: 20 }, { opacity: 1, scale: 1, y: 0, duration: 0.6, ease: 'back.out(1.4)' } ); @@ -520,7 +757,7 @@ export default function ProductShowcase() { // Dot transition — old + new animate simultaneously; intermediates ripple const prevDotIdxRef = useRef(-1); - const dotTlRef = useRef(null); + const dotTlRef = useRef(null); useEffect(() => { const curr = done ? PHASES.length : slide; const prev = prevDotIdxRef.current; @@ -545,28 +782,52 @@ export default function ProductShowcase() { }); const direction = curr > prev ? 1 : -1; - const steps = Math.abs(curr - prev); - const totalDur = 0.5; - const isSkip = steps > 1; + const steps = Math.abs(curr - prev); + const totalDur = 0.5; + const isSkip = steps > 1; const tl = gsap.timeline(); dotTlRef.current = tl; - const prevDot = dotRefs.current[prev]; - const destDot = dotRefs.current[curr]; - const collapseDur = isSkip ? totalDur * 0.25 : totalDur; - const collapseEase = isSkip ? 'power3.in' : 'circ.out'; - const expandEase = isSkip ? 'power4.in' : 'circ.out'; - if (prevDot) tl.to(prevDot, { width: 11, duration: collapseDur, ease: collapseEase }, 0); - if (destDot) tl.to(destDot, { width: 36, duration: totalDur, ease: expandEase }, 0); + const prevDot = dotRefs.current[prev]; + const destDot = dotRefs.current[curr]; + const collapseDur = isSkip ? totalDur * 0.25 : totalDur; + const collapseEase = isSkip ? 'power3.in' : 'circ.out'; + const expandEase = isSkip ? 'power4.in' : 'circ.out'; + if (prevDot) + tl.to( + prevDot, + { width: 11, duration: collapseDur, ease: collapseEase }, + 0 + ); + if (destDot) + tl.to(destDot, { width: 36, duration: totalDur, ease: expandEase }, 0); for (let s = 1; s < steps; s++) { const dot = dotRefs.current[prev + direction * s]; if (!dot) continue; - const t = (s / (steps + 1)) * totalDur * 0.8; + const t = (s / (steps + 1)) * totalDur * 0.8; const pulseDur = Math.min(0.14, totalDur / steps); - tl.to(dot, { width: 20, background: 'rgba(255,255,255,0.3)', duration: pulseDur * 0.5, ease: 'power1.out' }, t); - tl.to(dot, { width: 11, background: 'rgba(255,255,255,0.25)', duration: pulseDur * 0.5, ease: 'power1.in' }, t + pulseDur * 0.5); + tl.to( + dot, + { + width: 20, + background: 'rgba(255,255,255,0.3)', + duration: pulseDur * 0.5, + ease: 'power1.out', + }, + t + ); + tl.to( + dot, + { + width: 11, + background: 'rgba(255,255,255,0.25)', + duration: pulseDur * 0.5, + ease: 'power1.in', + }, + t + pulseDur * 0.5 + ); } }, [slide, done, seq]); @@ -574,19 +835,34 @@ export default function ProductShowcase() { useEffect(() => { if (slide !== 1 || paused) return; const t1 = setTimeout(() => setViewHighlight(true), 400); - const t2 = setTimeout(() => { setViewCopied(true); setViewHighlight(false); }, 1200); + const t2 = setTimeout(() => { + setViewCopied(true); + setViewHighlight(false); + }, 1200); const t3 = setTimeout(() => setViewCopied(false), 3200); const t4 = setTimeout(() => setAvatarCount(2), 1800); - return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); clearTimeout(t4); }; + return () => { + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + clearTimeout(t4); + }; }, [slide, seq, paused]); // Phase 2: submit link highlight useEffect(() => { if (slide !== 2 || paused) return; const t1 = setTimeout(() => setSubmitHighlight(true), 400); - const t2 = setTimeout(() => { setSubmitCopied(true); setSubmitHighlight(false); }, 1200); + const t2 = setTimeout(() => { + setSubmitCopied(true); + setSubmitHighlight(false); + }, 1200); const t3 = setTimeout(() => setSubmitCopied(false), 3200); - return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); }; + return () => { + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + }; }, [slide, seq, paused]); // Phase 3: strips arrive one by one @@ -595,7 +871,11 @@ export default function ProductShowcase() { const t1 = setTimeout(() => setStripsShown(1), 200); const t2 = setTimeout(() => setStripsShown(2), 2000); const t3 = setTimeout(() => setStripsShown(3), 3800); - return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); }; + return () => { + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + }; }, [slide, seq, paused]); // Phase 4: REQ appears in sequence, then timers count @@ -604,21 +884,25 @@ export default function ProductShowcase() { const t1 = setTimeout(() => setBawReqActive(true), 500); const t2 = setTimeout(() => setEzyReqActive(true), 1200); const t3 = setTimeout(() => setAalReqActive(true), 1900); - return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); }; + return () => { + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + }; }, [slide, seq, paused]); useEffect(() => { if (!bawReqActive || paused) return; - const iv = setInterval(() => setBawReq(e => e + 1.5), 100); + const iv = setInterval(() => setBawReq((e) => e + 1.5), 100); return () => clearInterval(iv); }, [bawReqActive, paused]); useEffect(() => { if (!ezyReqActive || paused) return; - const iv = setInterval(() => setEzyReq(e => e + 1.5), 100); + const iv = setInterval(() => setEzyReq((e) => e + 1.5), 100); return () => clearInterval(iv); }, [ezyReqActive, paused]); useEffect(() => { if (!aalReqActive || paused) return; - const iv = setInterval(() => setAalReq(e => e + 1.5), 100); + const iv = setInterval(() => setAalReq((e) => e + 1.5), 100); return () => clearInterval(iv); }, [aalReqActive, paused]); @@ -628,7 +912,11 @@ export default function ProductShowcase() { const t1 = setTimeout(() => setBawCleared(true), 800); const t2 = setTimeout(() => setBawStatus('STUP'), 3000); const t3 = setTimeout(() => setBawStatus('PUSH'), 5500); - return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); }; + return () => { + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + }; }, [slide, seq, paused]); // Phase 6: highlight squawk cell, then spin + regenerate to new code @@ -636,8 +924,15 @@ export default function ProductShowcase() { if (slide !== 6 || paused) return; const t1 = setTimeout(() => setSquawkHighlight(true), 400); const t2 = setTimeout(() => setSquawkSpinning(true), 1800); - const t3 = setTimeout(() => { setSquawkSpinning(false); setAalSqkNew(4721); }, 2800); - return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); }; + const t3 = setTimeout(() => { + setSquawkSpinning(false); + setAalSqkNew(4721); + }, 2800); + return () => { + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + }; }, [slide, seq, paused]); // Phase 7: route modal fades in @@ -649,10 +944,14 @@ export default function ProductShowcase() { const animKey = `${slide}-${seq}`; - const bawReqData: ReqData = (slide === 4 && bawReqActive) || (slide === 5 && !bawCleared) - ? { label: 'R1C', elapsed: bawReq } : null; - const ezyReqData: ReqData = slide >= 4 && ezyReqActive ? { label: 'R2C', elapsed: ezyReq } : null; - const aalReqData: ReqData = slide >= 4 && aalReqActive ? { label: 'R3C', elapsed: aalReq } : null; + const bawReqData: ReqData = + (slide === 4 && bawReqActive) || (slide === 5 && !bawCleared) + ? { label: 'R1C', elapsed: bawReq } + : null; + const ezyReqData: ReqData = + slide >= 4 && ezyReqActive ? { label: 'R2C', elapsed: ezyReq } : null; + const aalReqData: ReqData = + slide >= 4 && aalReqActive ? { label: 'R3C', elapsed: aalReq } : null; return (
@@ -673,7 +972,8 @@ export default function ProductShowcase() { backgroundImage: 'url(/assets/app/backgrounds/vowray__002.webp)', backgroundSize: 'cover', backgroundPosition: 'center', - boxShadow: '0 0 0 1px rgba(255,255,255,0.07), 0 40px 100px rgba(0,0,0,0.85)', + boxShadow: + '0 0 0 1px rgba(255,255,255,0.07), 0 40px 100px rgba(0,0,0,0.85)', minHeight: '700px', }} > @@ -688,25 +988,58 @@ export default function ProductShowcase() {
- +
- - - - - - - - - - - - - - - + + + + + + + + + + + + + + + @@ -721,43 +1054,138 @@ export default function ProductShowcase() { )} {ALL_FLIGHTS.map((f) => { const visible = stripsShown >= f.id; - const req = f.id === 1 ? bawReqData : f.id === 2 ? ezyReqData : aalReqData; - const status = f.id === 1 ? bawStatus : 'PENDING'; + const req = + f.id === 1 + ? bawReqData + : f.id === 2 + ? ezyReqData + : aalReqData; + const status = f.id === 1 ? bawStatus : 'PENDING'; const cleared = f.id === 1 && bawCleared; - const sqk = visible ? (f.id === 3 ? (aalSqkNew ?? (slide >= 6 ? 4721 : f.sqk)) : f.sqk) : 0; - const sqkHL = f.id === 3 && squawkHighlight; + const sqk = visible + ? f.id === 3 + ? (aalSqkNew ?? (slide >= 6 ? 4721 : f.sqk)) + : f.sqk + : 0; + const sqkHL = f.id === 3 && squawkHighlight; const sqkSpin = f.id === 3 && squawkSpinning; return ( { rowRefs.current[f.id - 1] = el; }} + ref={(el) => { + rowRefs.current[f.id - 1] = el; + }} className="select-none" - style={{ backgroundColor: 'rgba(0,0,0,0.5)', opacity: 0 }} + style={{ + backgroundColor: 'rgba(0,0,0,0.5)', + opacity: 0, + }} > - - - - - - - - - - - + + + + + + + + + + + - - - + + +
TIMECALLSIGNREQSTANDATYPADESRWYSIDRFLCFLRTEASSRCSTS + TIME + + CALLSIGN + + REQ + + STAND + + ATYP + + ADES + + RWY + + SID + + RFL + + CFL + + RTE + + ASSR + + C + + STS + + +
{f.time}{f.callsign}{f.stand} {}} size="xs" /> {}} size="xs" /> {}} size="xs" /> {}} size="xs" /> {}} size="xs" /> {}} size="xs" placeholder="-" /> + + + {f.time} + + {f.callsign} + + + + {f.stand} + + {}} + size="xs" + /> + + {}} + size="xs" + /> + + {}} + size="xs" + /> + + {}} + size="xs" + /> + + {}} + size="xs" + /> + + {}} + size="xs" + placeholder="-" + /> + -
+
{}} label="" checkedClass="bg-green-600 border-green-600" /> {}} size="xs" controllerType="departure" /> + + + {}} + label="" + checkedClass="bg-green-600 border-green-600" + /> + + {}} + size="xs" + controllerType="departure" + /> +
@@ -780,11 +1208,25 @@ export default function ProductShowcase() {
{done && ( -
-
-

Ready to get started?

+
+ )} -
+

{PHASES.map((p, i) => ( ))} {/* Done dot */} ) : ( )}
diff --git a/src/components/map/RouteMap.tsx b/src/components/map/RouteMap.tsx index 6f7d35bc..22c563e8 100644 --- a/src/components/map/RouteMap.tsx +++ b/src/components/map/RouteMap.tsx @@ -1,42 +1,42 @@ -import DeckGL from '@deck.gl/react' -import { OrthographicView, COORDINATE_SYSTEM } from '@deck.gl/core' -import { LineLayer, TextLayer, PolygonLayer } from '@deck.gl/layers' -import { clamp } from '@math.gl/core' -import { useEffect, useMemo, useRef, useState } from 'react' +import DeckGL from '@deck.gl/react'; +import { OrthographicView, COORDINATE_SYSTEM } from '@deck.gl/core'; +import { LineLayer, TextLayer, PolygonLayer } from '@deck.gl/layers'; +import { clamp } from '@math.gl/core'; +import { useEffect, useMemo, useRef, useState } from 'react'; interface Waypoint { - name: string - x: number - y: number - type: string + name: string; + x: number; + y: number; + type: string; } interface AirportEntry { - icao: string - sidWaypoints?: Record - starWaypoints?: Record + icao: string; + sidWaypoints?: Record; + starWaypoints?: Record; } type ViewState = { - target: [number, number, number] - zoom: number - minZoom: number - maxZoom: number - zoomX?: number - zoomY?: number -} + target: [number, number, number]; + zoom: number; + minZoom: number; + maxZoom: number; + zoomX?: number; + zoomY?: number; +}; -type LineSegment = { source: Waypoint; target: Waypoint } +type LineSegment = { source: Waypoint; target: Waypoint }; -type IslandFeature = { polygon: number[][][] } +type IslandFeature = { polygon: number[][][] }; export interface RouteMapProps { - route?: string - departure?: string - arrival?: string - sid?: string - star?: string - className?: string + route?: string; + departure?: string; + arrival?: string; + sid?: string; + star?: string; + className?: string; } const DEFAULT_VIEW_STATE = { @@ -44,33 +44,37 @@ const DEFAULT_VIEW_STATE = { zoom: 1, minZoom: -1, maxZoom: 20, -} - -const MAP_BOUNDS = { minX: -50, maxX: 270, minY: -20, maxY: 185 } - -function computeViewState(points: Waypoint[], containerW: number, containerH: number) { - if (points.length === 0) return DEFAULT_VIEW_STATE - - const xs = points.map((p) => p.x) - const ys = points.map((p) => p.y) - const minX = Math.min(...xs) - const maxX = Math.max(...xs) - const minY = Math.min(...ys) - const maxY = Math.max(...ys) - const centerX = (minX + maxX) / 2 - const centerY = (minY + maxY) / 2 - const spanX = (maxX - minX) || 10 - const spanY = (maxY - minY) || 10 +}; + +const MAP_BOUNDS = { minX: -50, maxX: 270, minY: -20, maxY: 185 }; + +function computeViewState( + points: Waypoint[], + containerW: number, + containerH: number +) { + if (points.length === 0) return DEFAULT_VIEW_STATE; + + const xs = points.map((p) => p.x); + const ys = points.map((p) => p.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const spanX = maxX - minX || 10; + const spanY = maxY - minY || 10; // Fit whichever axis is the limiting dimension, with 15% padding - const zoomX = Math.log2((containerW * 0.85) / spanX) - const zoomY = Math.log2((containerH * 0.85) / spanY) + const zoomX = Math.log2((containerW * 0.85) / spanX); + const zoomY = Math.log2((containerH * 0.85) / spanY); return { target: [centerX, centerY, 0] as [number, number, number], zoom: clamp(Math.min(zoomX, zoomY), 1, 12), minZoom: -1, maxZoom: 20, - } + }; } /** @@ -85,37 +89,41 @@ function buildFullRoute( sidName: string | undefined, starName: string | undefined, depAirport: AirportEntry | undefined, - arrAirport: AirportEntry | undefined, + arrAirport: AirportEntry | undefined ): Waypoint[] { - const find = (name: string) => waypoints.find((w) => w.name === name) + const find = (name: string) => waypoints.find((w) => w.name === name); - const sidWps: string[] = sidName ? (depAirport?.sidWaypoints?.[sidName] ?? []) : [] - const starWps: string[] = starName ? (arrAirport?.starWaypoints?.[starName] ?? []) : [] + const sidWps: string[] = sidName + ? (depAirport?.sidWaypoints?.[sidName] ?? []) + : []; + const starWps: string[] = starName + ? (arrAirport?.starWaypoints?.[starName] ?? []) + : []; // Parse the raw route tokens, skip procedure names (they'll be replaced by sequences) - const tokens = routeStr.trim().split(/\s+/) + const tokens = routeStr.trim().split(/\s+/); const midTokens = tokens.filter( - (t) => t !== departure && t !== arrival && t !== sidName && t !== starName, - ) + (t) => t !== departure && t !== arrival && t !== sidName && t !== starName + ); // Build ordered name list: dep → SID waypoints → mid route → STAR waypoints → arr - const names: string[] = [] - if (departure) names.push(departure) - names.push(...sidWps) - names.push(...midTokens) - names.push(...starWps) - if (arrival) names.push(arrival) + const names: string[] = []; + if (departure) names.push(departure); + names.push(...sidWps); + names.push(...midTokens); + names.push(...starWps); + if (arrival) names.push(arrival); // Deduplicate consecutive duplicates (e.g. SID exit fix appearing in mid route too) - const deduped: string[] = [] + const deduped: string[] = []; for (const n of names) { - if (deduped[deduped.length - 1] !== n) deduped.push(n) + if (deduped[deduped.length - 1] !== n) deduped.push(n); } return deduped.flatMap((name) => { - const wp = find(name) - return wp ? [wp] : [] - }) + const wp = find(name); + return wp ? [wp] : []; + }); } export default function RouteMap({ @@ -126,123 +134,164 @@ export default function RouteMap({ star, className, }: RouteMapProps) { - const [waypoints, setWaypoints] = useState([]) - const [airports, setAirports] = useState([]) - const [islands, setIslands] = useState([]) - const [viewState, setViewState] = useState(DEFAULT_VIEW_STATE) - const [containerSize, setContainerSize] = useState({ w: 600, h: 400 }) - const containerRef = useRef(null) - const lastFittedKeyRef = useRef('') + const [waypoints, setWaypoints] = useState([]); + const [airports, setAirports] = useState([]); + const [islands, setIslands] = useState([]); + const [viewState, setViewState] = useState(DEFAULT_VIEW_STATE); + const [containerSize, setContainerSize] = useState({ w: 600, h: 400 }); + const containerRef = useRef(null); + const lastFittedKeyRef = useRef(''); useEffect(() => { - const el = containerRef.current - if (!el) return + const el = containerRef.current; + if (!el) return; const ro = new ResizeObserver(([entry]) => { - const { width, height } = entry.contentRect - setContainerSize({ w: width, h: height }) - }) - ro.observe(el) - const onWheel = (e: WheelEvent) => e.preventDefault() - el.addEventListener('wheel', onWheel, { passive: false }) + const { width, height } = entry.contentRect; + setContainerSize({ w: width, h: height }); + }); + ro.observe(el); + const onWheel = (e: WheelEvent) => e.preventDefault(); + el.addEventListener('wheel', onWheel, { passive: false }); return () => { - ro.disconnect() - el.removeEventListener('wheel', onWheel) - } - }, []) + ro.disconnect(); + el.removeEventListener('wheel', onWheel); + }; + }, []); - const apiBase = import.meta.env.VITE_SERVER_URL || '' + const apiBase = import.meta.env.VITE_SERVER_URL || ''; useEffect(() => { fetch(`${apiBase}/api/data/waypoints`) .then((r) => r.json()) .then(setWaypoints) - .catch(console.error) - }, [apiBase]) + .catch(console.error); + }, [apiBase]); useEffect(() => { fetch(`${apiBase}/api/data/airports`) .then((r) => r.json()) .then(setAirports) - .catch(console.error) - }, [apiBase]) + .catch(console.error); + }, [apiBase]); useEffect(() => { fetch(`${apiBase}/api/data/islands`) .then((r) => r.json()) .then(setIslands) - .catch(console.error) - }, [apiBase]) + .catch(console.error); + }, [apiBase]); // Parse departure/arrival from the route string itself (first/last AIRPORT token) - const routeTokens = (route ?? '').trim().split(/\s+/).filter(Boolean) - const firstToken = routeTokens[0] - const lastToken = routeTokens[routeTokens.length - 1] - const firstWp = waypoints.find((w) => w.name === firstToken) - const lastWp = waypoints.find((w) => w.name === lastToken) - const effectiveDep = (firstWp?.type === 'AIRPORT' ? firstToken : undefined) ?? departure - const effectiveArr = (lastWp?.type === 'AIRPORT' ? lastToken : undefined) ?? arrival - - const depAirport = airports.find((a) => a.icao === effectiveDep) - const arrAirport = airports.find((a) => a.icao === effectiveArr) + const routeTokens = (route ?? '').trim().split(/\s+/).filter(Boolean); + const firstToken = routeTokens[0]; + const lastToken = routeTokens[routeTokens.length - 1]; + const firstWp = waypoints.find((w) => w.name === firstToken); + const lastWp = waypoints.find((w) => w.name === lastToken); + const effectiveDep = + (firstWp?.type === 'AIRPORT' ? firstToken : undefined) ?? departure; + const effectiveArr = + (lastWp?.type === 'AIRPORT' ? lastToken : undefined) ?? arrival; + + const depAirport = airports.find((a) => a.icao === effectiveDep); + const arrAirport = airports.find((a) => a.icao === effectiveArr); // Auto-detect SID/STAR from route tokens when not provided as props - const effectiveSid = sid - ?? routeTokens.find((t) => depAirport?.sidWaypoints && t in depAirport.sidWaypoints) - const effectiveStar = star - ?? routeTokens.find((t) => arrAirport?.starWaypoints && t in arrAirport.starWaypoints) + const effectiveSid = + sid ?? + routeTokens.find( + (t) => depAirport?.sidWaypoints && t in depAirport.sidWaypoints + ); + const effectiveStar = + star ?? + routeTokens.find( + (t) => arrAirport?.starWaypoints && t in arrAirport.starWaypoints + ); const resolved = useMemo( - () => waypoints.length > 0 - ? buildFullRoute(route ?? '', waypoints, effectiveDep, effectiveArr, effectiveSid, effectiveStar, depAirport, arrAirport) - : [], - [route, waypoints, effectiveDep, effectiveArr, effectiveSid, effectiveStar, depAirport, arrAirport], - ) + () => + waypoints.length > 0 + ? buildFullRoute( + route ?? '', + waypoints, + effectiveDep, + effectiveArr, + effectiveSid, + effectiveStar, + depAirport, + arrAirport + ) + : [], + [ + route, + waypoints, + effectiveDep, + effectiveArr, + effectiveSid, + effectiveStar, + depAirport, + arrAirport, + ] + ); // Re-fit the view whenever the route identity changes or waypoints first load useEffect(() => { - if (resolved.length === 0) return - const key = `${route ?? ''}|${sid ?? ''}|${star ?? ''}|${departure ?? ''}|${arrival ?? ''}` - if (key === lastFittedKeyRef.current) return - lastFittedKeyRef.current = key - setViewState(computeViewState(resolved, containerSize.w, containerSize.h)) - }, [resolved, route, sid, star, departure, arrival, containerSize]) + if (resolved.length === 0) return; + const key = `${route ?? ''}|${sid ?? ''}|${star ?? ''}|${departure ?? ''}|${arrival ?? ''}`; + if (key === lastFittedKeyRef.current) return; + lastFittedKeyRef.current = key; + setViewState(computeViewState(resolved, containerSize.w, containerSize.h)); + }, [resolved, route, sid, star, departure, arrival, containerSize]); const islandLayer = new PolygonLayer({ id: 'island', data: islands, coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, getPolygon: (d) => d.polygon, - getFillColor: [24, 24, 27], // zinc-900 — matches modal backgrounds - getLineColor: [37, 99, 235], // blue-600 — matches border-blue-600 - getLineWidth: 0.10, + getFillColor: [24, 24, 27], // zinc-900 — matches modal backgrounds + getLineColor: [37, 99, 235], // blue-600 — matches border-blue-600 + getLineWidth: 0.1, filled: true, stroked: true, pickable: false, - }) + }); // SID/STAR waypoint sets — use effective (auto-detected) values, must be declared before lineSegments - const sidWpNames = new Set(effectiveSid ? (depAirport?.sidWaypoints?.[effectiveSid] ?? []) : []) - const starWpNames = new Set(effectiveStar ? (arrAirport?.starWaypoints?.[effectiveStar] ?? []) : []) - - const lineSegments = resolved.slice(0, -1).map((_, i) => ({ - source: resolved[i], - target: resolved[i + 1], - })).filter((seg) => { - // Don't draw airport → first SID fix (implied radar vectors off runway) - if (seg.source.type === 'AIRPORT' && sidWpNames.has(seg.target.name)) return false - // Don't draw last STAR fix → airport (implied radar vectors to runway) - if (starWpNames.has(seg.source.name) && seg.target.type === 'AIRPORT') return false - return true - }) - - const aptWaypoints = resolved.filter((w) => w.type === 'AIRPORT') - const navPoints = resolved.filter((w) => w.type !== 'AIRPORT') - - const sidLineColor = (srcName: string, tgtName: string): [number, number, number] => { - if (sidWpNames.has(srcName) && sidWpNames.has(tgtName)) return [234, 179, 8] // yellow — both in SID - if (starWpNames.has(srcName) && starWpNames.has(tgtName)) return [168, 85, 247] // purple — both in STAR - return [59, 130, 246] // blue — en-route - } + const sidWpNames = new Set( + effectiveSid ? (depAirport?.sidWaypoints?.[effectiveSid] ?? []) : [] + ); + const starWpNames = new Set( + effectiveStar ? (arrAirport?.starWaypoints?.[effectiveStar] ?? []) : [] + ); + + const lineSegments = resolved + .slice(0, -1) + .map((_, i) => ({ + source: resolved[i], + target: resolved[i + 1], + })) + .filter((seg) => { + // Don't draw airport → first SID fix (implied radar vectors off runway) + if (seg.source.type === 'AIRPORT' && sidWpNames.has(seg.target.name)) + return false; + // Don't draw last STAR fix → airport (implied radar vectors to runway) + if (starWpNames.has(seg.source.name) && seg.target.type === 'AIRPORT') + return false; + return true; + }); + + const aptWaypoints = resolved.filter((w) => w.type === 'AIRPORT'); + const navPoints = resolved.filter((w) => w.type !== 'AIRPORT'); + + const sidLineColor = ( + srcName: string, + tgtName: string + ): [number, number, number] => { + if (sidWpNames.has(srcName) && sidWpNames.has(tgtName)) + return [234, 179, 8]; // yellow — both in SID + if (starWpNames.has(srcName) && starWpNames.has(tgtName)) + return [168, 85, 247]; // purple — both in STAR + return [59, 130, 246]; // blue — en-route + }; const routeLine = new LineLayer({ id: 'route-line', @@ -258,10 +307,10 @@ export default function RouteMap({ getTargetPosition: resolved, getColor: [sid, star], }, - }) + }); - const WHITE: [number, number, number] = [255, 255, 255] - const fontFamily = '"Arial Unicode MS", "Segoe UI Symbol", Arial, sans-serif' + const WHITE: [number, number, number] = [255, 255, 255]; + const fontFamily = '"Arial Unicode MS", "Segoe UI Symbol", Arial, sans-serif'; const airportLayer = new TextLayer({ id: 'airport-x', @@ -276,7 +325,7 @@ export default function RouteMap({ fontFamily, characterSet: ['✕'], pickable: false, - }) + }); const navSymbolLayer = new TextLayer({ id: 'nav-symbol', @@ -291,7 +340,7 @@ export default function RouteMap({ fontFamily, characterSet: ['⬡'], pickable: false, - }) + }); const labelLayer = new TextLayer({ id: 'waypoint-labels', @@ -306,13 +355,18 @@ export default function RouteMap({ getAlignmentBaseline: 'top', fontFamily, pickable: false, - }) + }); return (
- ) + ); } diff --git a/src/components/modals/AddCustomFlightModal.tsx b/src/components/modals/AddCustomFlightModal.tsx index a33563d9..441db2c3 100644 --- a/src/components/modals/AddCustomFlightModal.tsx +++ b/src/components/modals/AddCustomFlightModal.tsx @@ -60,8 +60,10 @@ export default function AddCustomFlightModal({ const isLocalArrival = useCallback( (arrival: string) => - !!arrival && !!airportIcao && arrival.toUpperCase() === airportIcao.toUpperCase(), - [airportIcao], + !!arrival && + !!airportIcao && + arrival.toUpperCase() === airportIcao.toUpperCase(), + [airportIcao] ); const handleChange = useCallback( @@ -78,16 +80,22 @@ export default function AddCustomFlightModal({ } // Auto-update SID when arrival or flight type changes (departures only) - if (flightType === 'departure' && (field === 'arrival' || field === 'flight_type')) { + if ( + flightType === 'departure' && + (field === 'arrival' || field === 'flight_type') + ) { const arrival = field === 'arrival' ? value : formData.arrival || ''; - const flType = field === 'flight_type' ? value : formData.flight_type || 'IFR'; + const flType = + field === 'flight_type' ? value : formData.flight_type || 'IFR'; if (flType === 'VFR' || isLocalArrival(arrival)) { setFormData((prev) => ({ ...prev, sid: 'RADAR VECTORS' })); } else if (arrival) { try { const sids = await fetchSids(airportIcao); - const preferred = sids.find((s) => s.length > 0 && !s.includes(' ')); + const preferred = sids.find( + (s) => s.length > 0 && !s.includes(' ') + ); setFormData((prev) => ({ ...prev, sid: preferred || '' })); } catch { setFormData((prev) => ({ ...prev, sid: '' })); @@ -97,7 +105,14 @@ export default function AddCustomFlightModal({ } } }, - [airportIcao, flightType, formData.arrival, formData.flight_type, errors, isLocalArrival], + [ + airportIcao, + flightType, + formData.arrival, + formData.flight_type, + errors, + isLocalArrival, + ] ); if (!isOpen) return null; @@ -133,7 +148,9 @@ export default function AddCustomFlightModal({ onAdd({ ...formData, - ...(flightType === 'departure' ? { sid: isRadarVectors ? 'RADAR VECTORS' : formData.sid } : {}), + ...(flightType === 'departure' + ? { sid: isRadarVectors ? 'RADAR VECTORS' : formData.sid } + : {}), }); handleClose(); }; diff --git a/src/components/modals/AtisReminderModal.tsx b/src/components/modals/AtisReminderModal.tsx index 94b90de9..e33f7ba0 100644 --- a/src/components/modals/AtisReminderModal.tsx +++ b/src/components/modals/AtisReminderModal.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; -import { Copy, Check, Loader2 } from "lucide-react"; -import Button from "../common/Button"; +import { useState } from 'react'; +import { Copy, Check, Loader2 } from 'lucide-react'; +import Button from '../common/Button'; -export type AtisReminderNetworkKind = "pfatc" | "advanced_atc"; +export type AtisReminderNetworkKind = 'pfatc' | 'advanced_atc'; interface AtisReminderModalProps { onContinue: () => void; @@ -30,22 +30,22 @@ export default function AtisReminderModal({ airportFrequencyType, networkSessionKind, }: AtisReminderModalProps) { - const isAdvancedAtc = networkSessionKind === "advanced_atc"; + const isAdvancedAtc = networkSessionKind === 'advanced_atc'; const submitLink = `${window.location?.origin}/submit/${sessionId}`; const [copied, setCopied] = useState(false); const [isLoading, setIsLoading] = useState(false); const getFormattedControlName = () => { if (!airportControlName) return null; - if (airportFrequencyType === "APP") { - if (airportIcao === "EGKK") { + if (airportFrequencyType === 'APP') { + if (airportIcao === 'EGKK') { return `${airportControlName} Director`; } return `${airportControlName} Approach`; - } else if (airportFrequencyType === "TWR") { + } else if (airportFrequencyType === 'TWR') { return `${airportControlName} Tower`; - } else if (airportFrequencyType === "GND") { + } else if (airportFrequencyType === 'GND') { return `${airportControlName} Ground`; - } else if (airportFrequencyType === "DEL") { + } else if (airportFrequencyType === 'DEL') { return `${airportControlName} Delivery`; } return null; @@ -63,7 +63,7 @@ export default function AtisReminderModal({ setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (err) { - console.error("Failed to copy:", err); + console.error('Failed to copy:', err); } }; @@ -74,7 +74,7 @@ export default function AtisReminderModal({ await navigator.clipboard.writeText(`${airportName}\n\n${formattedAtis}`); setCopied(true); } catch (err) { - console.error("Failed to copy:", err); + console.error('Failed to copy:', err); } setTimeout(() => { @@ -85,10 +85,10 @@ export default function AtisReminderModal({ }, 500); }; - const accentTitle = "text-blue-400"; - const accentBorder = "border-zinc-500/50"; - const accentLabel = "text-blue-400"; - const buttonClass = "bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800"; + const accentTitle = 'text-blue-400'; + const accentBorder = 'border-zinc-500/50'; + const accentLabel = 'text-blue-400'; + const buttonClass = 'bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800'; return (
@@ -97,21 +97,22 @@ export default function AtisReminderModal({ >

{isAdvancedAtc - ? "Advanced ATC ATIS format reminder" - : "PFATC Network ATIS format reminder"} + ? 'Advanced ATC ATIS format reminder' + : 'PFATC Network ATIS format reminder'}

{isAdvancedAtc ? ( <> - For an Advanced ATC session, use the same - public-network ATIS layout as PFATC (shown below) so pilots and overview stay - consistent. + For an Advanced ATC{' '} + session, use the same public-network ATIS layout as PFATC (shown + below) so pilots and overview stay consistent. ) : ( <> - If you want to use this on the{" "} - PFATC Network, use the ATIS format below: + If you want to use this on the{' '} + PFATC Network, use the + ATIS format below: )}

@@ -129,7 +130,9 @@ export default function AtisReminderModal({ )}
{airportName}
-
{formattedAtis}
+
+            {formattedAtis}
+          
); -} \ No newline at end of file +} diff --git a/src/components/tables/ArrivalsTable.tsx b/src/components/tables/ArrivalsTable.tsx index d57dce4f..3eb41ed4 100644 --- a/src/components/tables/ArrivalsTable.tsx +++ b/src/components/tables/ArrivalsTable.tsx @@ -880,4 +880,4 @@ function ArrivalsTable({ ); } -export default memo(ArrivalsTable); \ No newline at end of file +export default memo(ArrivalsTable); diff --git a/src/components/tables/CombinedFlightsTable.tsx b/src/components/tables/CombinedFlightsTable.tsx index 934270fe..538c25bf 100644 --- a/src/components/tables/CombinedFlightsTable.tsx +++ b/src/components/tables/CombinedFlightsTable.tsx @@ -1,5 +1,8 @@ import type { Flight } from '../../types/flight'; -import type { DepartureTableColumnSettings, ArrivalsTableColumnSettings } from '../../types/settings'; +import type { + DepartureTableColumnSettings, + ArrivalsTableColumnSettings, +} from '../../types/settings'; import DepartureTable from './DepartureTable'; import ArrivalsTable from './ArrivalsTable'; diff --git a/src/components/tables/DepartureTable.tsx b/src/components/tables/DepartureTable.tsx index d0589628..31a188fb 100644 --- a/src/components/tables/DepartureTable.tsx +++ b/src/components/tables/DepartureTable.tsx @@ -1585,4 +1585,4 @@ function DepartureTable({ ); } -export default memo(DepartureTable); \ No newline at end of file +export default memo(DepartureTable); diff --git a/src/components/tables/mobile/ArrivalsTableMobile.tsx b/src/components/tables/mobile/ArrivalsTableMobile.tsx index be2ff15e..35ef45d9 100644 --- a/src/components/tables/mobile/ArrivalsTableMobile.tsx +++ b/src/components/tables/mobile/ArrivalsTableMobile.tsx @@ -434,15 +434,14 @@ export default function ArrivalsTableMobile({
- {(flight.timestamp || flight.created_at) - ? new Date(flight.timestamp || flight.created_at!).toLocaleTimeString( - 'en-GB', - { - hour: '2-digit', - minute: '2-digit', - timeZone: 'UTC', - } - ) + {flight.timestamp || flight.created_at + ? new Date( + flight.timestamp || flight.created_at! + ).toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', + }) : '--'}
diff --git a/src/components/tables/mobile/DepartureTableMobile.tsx b/src/components/tables/mobile/DepartureTableMobile.tsx index 1fc213dd..a01c14c2 100644 --- a/src/components/tables/mobile/DepartureTableMobile.tsx +++ b/src/components/tables/mobile/DepartureTableMobile.tsx @@ -85,14 +85,23 @@ export default function DepartureTableMobile({ let changed = false; for (const [id, opt] of prev) { const flight = flights.find((f) => f.id === id); - if (!flight) { next.delete(id); changed = true; continue; } + if (!flight) { + next.delete(id); + changed = true; + continue; + } const serverAt = flight.req_at ?? null; const optAt = opt.req_at ?? null; const synced = (serverAt === null && optAt === null) || - (serverAt !== null && optAt !== null && - Math.abs(new Date(serverAt).getTime() - new Date(optAt).getTime()) < 5000); - if (synced) { next.delete(id); changed = true; } + (serverAt !== null && + optAt !== null && + Math.abs(new Date(serverAt).getTime() - new Date(optAt).getTime()) < + 5000); + if (synced) { + next.delete(id); + changed = true; + } } return changed ? next : prev; }); @@ -106,7 +115,10 @@ export default function DepartureTableMobile({ }; const reqPositions = useMemo(() => { - const byPhase: Record> = {}; + const byPhase: Record< + string, + Array<{ id: string | number; req_at: string }> + > = {}; for (const f of flights) { const { req_at, req_phase } = getReqData(f); if (!req_at) continue; @@ -125,18 +137,24 @@ export default function DepartureTableMobile({ }); } return result; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [flights, reqOptimistic]); const formatReqElapsed = (req_at: string) => { - const elapsed = Math.max(0, Math.floor((Date.now() - new Date(req_at).getTime()) / 1000)); + const elapsed = Math.max( + 0, + Math.floor((Date.now() - new Date(req_at).getTime()) / 1000) + ); const m = Math.floor(elapsed / 60); const s = elapsed % 60; return `${m}:${s.toString().padStart(2, '0')}`; }; const getReqColor = (req_at: string): string => { - const elapsed = Math.max(0, (Date.now() - new Date(req_at).getTime()) / 1000); + const elapsed = Math.max( + 0, + (Date.now() - new Date(req_at).getTime()) / 1000 + ); const progress = Math.min(1, elapsed / 300); const hue = Math.round(48 * (1 - progress)); return `hsl(${hue}, 90%, 58%)`; @@ -305,7 +323,9 @@ export default function DepartureTableMobile({ if (checked && flight && getReqData(flight).req_at) { updates.req_at = null; updates.req_phase = null; - setReqOptimistic((prev) => new Map(prev).set(flightId, { req_at: null, req_phase: null })); + setReqOptimistic((prev) => + new Map(prev).set(flightId, { req_at: null, req_phase: null }) + ); } onFlightChange(flightId, updates); } @@ -315,12 +335,15 @@ export default function DepartureTableMobile({ if (!onFlightChange) return; const isClearanceCheckedLocal = (v: boolean | string | undefined) => { if (typeof v === 'boolean') return v; - if (typeof v === 'string') return ['true', 'c', 'yes', '1'].includes(v.trim().toLowerCase()); + if (typeof v === 'string') + return ['true', 'c', 'yes', '1'].includes(v.trim().toLowerCase()); return false; }; const current = getReqData(flight); if (current.req_at) { - setReqOptimistic((prev) => new Map(prev).set(flight.id, { req_at: null, req_phase: null })); + setReqOptimistic((prev) => + new Map(prev).set(flight.id, { req_at: null, req_phase: null }) + ); onFlightChange(flight.id, { req_at: null, req_phase: null }); } else { const status = (flight.status || '').toLowerCase(); @@ -331,7 +354,9 @@ export default function DepartureTableMobile({ else if (status === 'push') phase = 'T'; else phase = 'G'; const newReqAt = new Date().toISOString(); - setReqOptimistic((prev) => new Map(prev).set(flight.id, { req_at: newReqAt, req_phase: phase })); + setReqOptimistic((prev) => + new Map(prev).set(flight.id, { req_at: newReqAt, req_phase: phase }) + ); onFlightChange(flight.id, { req_at: newReqAt, req_phase: phase }); } }; @@ -430,7 +455,9 @@ export default function DepartureTableMobile({ if (flight && getReqData(flight).req_at) { updates.req_at = null; updates.req_phase = null; - setReqOptimistic((prev) => new Map(prev).set(flightId, { req_at: null, req_phase: null })); + setReqOptimistic((prev) => + new Map(prev).set(flightId, { req_at: null, req_phase: null }) + ); } onFlightChange(flightId, updates); } @@ -522,30 +549,43 @@ export default function DepartureTableMobile({ />
)} - {departureColumns.req !== false && (() => { - const { req_at } = getReqData(flight); - return ( -
handleReqToggle(flight)} - title={req_at ? 'Click to clear request' : 'Click to mark as on-request'} - > - REQ: - {req_at ? ( -
- - {reqPositions.get(flight.id) ?? 'REQ'} - - - {formatReqElapsed(req_at)} + {departureColumns.req !== false && + (() => { + const { req_at } = getReqData(flight); + return ( +
handleReqToggle(flight)} + title={ + req_at + ? 'Click to clear request' + : 'Click to mark as on-request' + } + > + REQ: + {req_at ? ( +
+ + {reqPositions.get(flight.id) ?? 'REQ'} + + + {formatReqElapsed(req_at)} + +
+ ) : ( + + — -
- ) : ( - - )} -
- ); - })()} + )} +
+ ); + })()} {departureColumns.stand !== false && (
Stand:{' '} @@ -727,8 +767,10 @@ export default function DepartureTableMobile({ {/* Time is always visible */}
Time:{' '} - {(flight.timestamp || flight.created_at) - ? new Date(flight.timestamp || flight.created_at!).toLocaleTimeString('en-GB', { + {flight.timestamp || flight.created_at + ? new Date( + flight.timestamp || flight.created_at! + ).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC', diff --git a/src/components/tools/ATIS.tsx b/src/components/tools/ATIS.tsx index 61b1f23e..1a8ca4fd 100644 --- a/src/components/tools/ATIS.tsx +++ b/src/components/tools/ATIS.tsx @@ -1,13 +1,13 @@ -import { useEffect, useState, useMemo } from "react"; -import { X, Loader, Info, RefreshCw, Copy } from "lucide-react"; -import { useData } from "../../hooks/data/useData"; -import { fetchMetar } from "../../utils/fetch/metar"; -import { generateATIS } from "../../utils/fetch/atis"; -import { fetchSession } from "../../utils/fetch/sessions"; -import type { Socket } from "socket.io-client"; -import Checkbox from "../common/Checkbox"; -import TextInput from "../common/TextInput"; -import Button from "../common/Button"; +import { useEffect, useState, useMemo } from 'react'; +import { X, Loader, Info, RefreshCw, Copy } from 'lucide-react'; +import { useData } from '../../hooks/data/useData'; +import { fetchMetar } from '../../utils/fetch/metar'; +import { generateATIS } from '../../utils/fetch/atis'; +import { fetchSession } from '../../utils/fetch/sessions'; +import type { Socket } from 'socket.io-client'; +import Checkbox from '../common/Checkbox'; +import TextInput from '../common/TextInput'; +import Button from '../common/Button'; interface ATISData { letter: string; @@ -37,22 +37,28 @@ export default function ATIS({ onAtisUpdate, }: ATISProps) { const { airportRunways, fetchAirportData, fetchedAirports } = useData(); - const [ident, setIdent] = useState("A"); - const [selectedApproaches, setSelectedApproaches] = useState(["ILS"]); + const [ident, setIdent] = useState('A'); + const [selectedApproaches, setSelectedApproaches] = useState([ + 'ILS', + ]); const [landingRunways, setLandingRunways] = useState([]); const [departingRunways, setDepartingRunways] = useState([]); - const [remarks, setRemarks] = useState(""); - const [metar, setMetar] = useState(""); - const [atisText, setAtisText] = useState(""); + const [remarks, setRemarks] = useState(''); + const [metar, setMetar] = useState(''); + const [atisText, setAtisText] = useState(''); const [isLoading, setIsLoading] = useState(false); - const [isLoadingPreviousATIS, setIsLoadingPreviousATIS] = useState(false); + const [isLoadingPreviousATIS, setIsLoadingPreviousATIS] = + useState(false); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); - const identOptions = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); - const approachOptions = ["ILS", "VISUAL", "RNAV"]; - const availableRunways = useMemo(() => airportRunways[icao] || [], [airportRunways, icao]); + const identOptions = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + const approachOptions = ['ILS', 'VISUAL', 'RNAV']; + const availableRunways = useMemo( + () => airportRunways[icao] || [], + [airportRunways, icao] + ); useEffect(() => { if (!socket) return; @@ -67,10 +73,10 @@ export default function ATIS({ } }; - socket.on("atisUpdate", handleAtisUpdate); + socket.on('atisUpdate', handleAtisUpdate); return () => { - socket.off("atisUpdate", handleAtisUpdate); + socket.off('atisUpdate', handleAtisUpdate); }; }, [socket, onAtisUpdate]); @@ -84,7 +90,10 @@ export default function ATIS({ if (sessionData?.atis) { let atisData = null; - if (typeof sessionData.atis === "object" && icao in sessionData.atis) { + if ( + typeof sessionData.atis === 'object' && + icao in sessionData.atis + ) { // @ts-expect-error: dynamic key access atisData = sessionData.atis[icao]; } else if (sessionData.atis.letter && sessionData.atis.text) { @@ -114,8 +123,8 @@ export default function ATIS({ while ((match = pattern.exec(atisText)) !== null) { const runwayString = match[1]; const runways = runwayString - .split(",") - .map((r) => r.trim().replace(/[^0-9LRC]/g, "")) + .split(',') + .map((r) => r.trim().replace(/[^0-9LRC]/g, '')) .filter((r) => r.length >= 2); runways.forEach((runway) => { @@ -134,8 +143,8 @@ export default function ATIS({ while ((match = pattern.exec(atisText)) !== null) { const runwayString = match[1]; const runways = runwayString - .split(",") - .map((r) => r.trim().replace(/[^0-9LRC]/g, "")) + .split(',') + .map((r) => r.trim().replace(/[^0-9LRC]/g, '')) .filter((r) => r.length >= 2); runways.forEach((runway) => { @@ -150,23 +159,27 @@ export default function ATIS({ }); if ( - atisText.includes("SIMULTANEOUS ILS AND VISUAL") || - atisText.includes("ILS AND VISUAL") + atisText.includes('SIMULTANEOUS ILS AND VISUAL') || + atisText.includes('ILS AND VISUAL') ) { - extractedApproaches.push("ILS", "VISUAL"); + extractedApproaches.push('ILS', 'VISUAL'); } else if ( - atisText.includes("SIMULTANEOUS VISUAL AND ILS") || - atisText.includes("VISUAL AND ILS") + atisText.includes('SIMULTANEOUS VISUAL AND ILS') || + atisText.includes('VISUAL AND ILS') ) { - extractedApproaches.push("ILS", "VISUAL"); - } else if (atisText.includes("SIMULTANEOUS")) { - if (atisText.includes("ILS")) extractedApproaches.push("ILS"); - if (atisText.includes("VISUAL")) extractedApproaches.push("VISUAL"); - if (atisText.includes("RNAV")) extractedApproaches.push("RNAV"); + extractedApproaches.push('ILS', 'VISUAL'); + } else if (atisText.includes('SIMULTANEOUS')) { + if (atisText.includes('ILS')) extractedApproaches.push('ILS'); + if (atisText.includes('VISUAL')) + extractedApproaches.push('VISUAL'); + if (atisText.includes('RNAV')) extractedApproaches.push('RNAV'); } else { - if (atisText.includes("ILS APPROACH")) extractedApproaches.push("ILS"); - if (atisText.includes("VISUAL APPROACH")) extractedApproaches.push("VISUAL"); - if (atisText.includes("RNAV APPROACH")) extractedApproaches.push("RNAV"); + if (atisText.includes('ILS APPROACH')) + extractedApproaches.push('ILS'); + if (atisText.includes('VISUAL APPROACH')) + extractedApproaches.push('VISUAL'); + if (atisText.includes('RNAV APPROACH')) + extractedApproaches.push('RNAV'); } if (extractedLandingRunways.length > 0) { @@ -182,11 +195,11 @@ export default function ATIS({ } setAtisText(atisData.text); - setIdent(atisData.letter || "A"); + setIdent(atisData.letter || 'A'); } } } catch (error) { - console.error("Error loading previous ATIS:", error); + console.error('Error loading previous ATIS:', error); } finally { setIsLoadingPreviousATIS(false); } @@ -207,22 +220,27 @@ export default function ATIS({ .then((data) => { setMetar((prev) => { if (data?.rawOb) return data.rawOb; - if (data && typeof data === "string") return data; + if (data && typeof data === 'string') return data; if (data) { - console.warn("Unexpected METAR data structure:", data); + console.warn('Unexpected METAR data structure:', data); return prev; } return prev; }); }) .catch((error) => { - console.warn("Failed to fetch METAR data:", error); + console.warn('Failed to fetch METAR data:', error); }); } }, [icao, open]); useEffect(() => { - if (activeRunway && open && landingRunways.length === 0 && departingRunways.length === 0) { + if ( + activeRunway && + open && + landingRunways.length === 0 && + departingRunways.length === 0 + ) { setLandingRunways([activeRunway]); setDepartingRunways([activeRunway]); } @@ -237,26 +255,30 @@ export default function ATIS({ }); }; - const toggleRunway = (runway: string, type: "landing" | "departing") => { - if (type === "landing") { + const toggleRunway = (runway: string, type: 'landing' | 'departing') => { + if (type === 'landing') { setLandingRunways((prev) => - prev.includes(runway) ? prev.filter((r) => r !== runway) : [...prev, runway], + prev.includes(runway) + ? prev.filter((r) => r !== runway) + : [...prev, runway] ); } else { setDepartingRunways((prev) => - prev.includes(runway) ? prev.filter((r) => r !== runway) : [...prev, runway], + prev.includes(runway) + ? prev.filter((r) => r !== runway) + : [...prev, runway] ); } }; const handleGenerateATIS = async () => { if (!icao || !sessionId) { - setError("Airport ICAO and Session ID are required"); + setError('Airport ICAO and Session ID are required'); return; } if (landingRunways.length === 0 && departingRunways.length === 0) { - setError("At least one runway must be selected"); + setError('At least one runway must be selected'); return; } @@ -265,26 +287,27 @@ export default function ATIS({ try { const formatApproaches = () => { - if (selectedApproaches.length === 0) return ""; + if (selectedApproaches.length === 0) return ''; - const approachRunways = landingRunways.length > 0 ? landingRunways : departingRunways; + const approachRunways = + landingRunways.length > 0 ? landingRunways : departingRunways; const runwaysText = approachRunways.length === 1 ? `RUNWAY ${approachRunways[0]}` - : `RUNWAYS ${approachRunways.join(",")}`; + : `RUNWAYS ${approachRunways.join(',')}`; if (selectedApproaches.length === 1) { return `EXPECT ${selectedApproaches[0]} APPROACH ${runwaysText}`; } if (selectedApproaches.length === 2) { - return `EXPECT SIMULTANEOUS ${selectedApproaches.join(" AND ")} APPROACH ${runwaysText}`; + return `EXPECT SIMULTANEOUS ${selectedApproaches.join(' AND ')} APPROACH ${runwaysText}`; } const lastApproach = selectedApproaches[selectedApproaches.length - 1]; const otherApproaches = selectedApproaches.slice(0, -1); return `EXPECT SIMULTANEOUS ${otherApproaches.join( - ", ", + ', ' )} AND ${lastApproach} APPROACH ${runwaysText}`; }; @@ -310,7 +333,7 @@ export default function ATIS({ setAtisText(data.atisText); if (socket) { - socket.emit("atisGenerated", { + socket.emit('atisGenerated', { atis: { letter: data.ident, text: data.atisText, @@ -328,12 +351,17 @@ export default function ATIS({ onAtisUpdate({ letter: data.ident, text: data.atisText, - timestamp: typeof data.timestamp === "string" ? Number(data.timestamp) : data.timestamp, + timestamp: + typeof data.timestamp === 'string' + ? Number(data.timestamp) + : data.timestamp, }); } } catch (error) { - console.error("Error generating ATIS:", error); - setError(error instanceof Error ? error.message : "Failed to generate ATIS"); + console.error('Error generating ATIS:', error); + setError( + error instanceof Error ? error.message : 'Failed to generate ATIS' + ); } finally { setIsLoading(false); } @@ -346,15 +374,15 @@ export default function ATIS({ const data = await fetchMetar(icao); setMetar((prev) => { if (data?.rawOb) return data.rawOb; - if (data && typeof data === "string") return data; + if (data && typeof data === 'string') return data; if (data) { - console.warn("Unexpected METAR data structure:", data); + console.warn('Unexpected METAR data structure:', data); return prev; } return prev; }); } catch (error) { - console.warn("Failed to refresh METAR data:", error); + console.warn('Failed to refresh METAR data:', error); } finally { const elapsed = Date.now() - start; const minDelay = 500; @@ -368,20 +396,25 @@ export default function ATIS({ setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (error) { - console.error("Failed to copy:", error); + console.error('Failed to copy:', error); } }; return (
- ATIS Generator - {icao} -
@@ -389,9 +422,14 @@ export default function ATIS({
{(isLoading || isLoadingPreviousATIS) && (
- + {isLoadingPreviousATIS && ( - Loading previous ATIS data... + + Loading previous ATIS data... + )}
)} @@ -405,26 +443,32 @@ export default function ATIS({ {atisText && (
-

Generated ATIS

+

+ Generated ATIS +

-

Approach Types

+

+ Approach Types +

{approachOptions.map((approach) => (
-

Active Runways

+

+ Active Runways +

{availableRunways.length > 0 ? (
{availableRunways.map((runway) => ( @@ -486,17 +536,19 @@ export default function ATIS({ key={runway} className="flex items-center justify-between p-3 bg-zinc-800 rounded-lg border border-zinc-700" > - {runway} + + {runway} +
toggleRunway(runway, "landing")} + onChange={() => toggleRunway(runway, 'landing')} label="ARR" checkedClass="bg-green-600 border-green-600" /> toggleRunway(runway, "departing")} + onChange={() => toggleRunway(runway, 'departing')} label="DEP" checkedClass="bg-blue-600 border-blue-600" /> @@ -522,7 +574,9 @@ export default function ATIS({ disabled={isRefreshing} className="flex items-center gap-1" > - + Refresh
@@ -536,7 +590,9 @@ export default function ATIS({
-

Additional Remarks

+

+ Additional Remarks +

); -} \ No newline at end of file +} diff --git a/src/components/tools/ControllerRatingPopup.tsx b/src/components/tools/ControllerRatingPopup.tsx index 8cc1ee77..94afc769 100644 --- a/src/components/tools/ControllerRatingPopup.tsx +++ b/src/components/tools/ControllerRatingPopup.tsx @@ -46,8 +46,12 @@ export default function ControllerRatingPopup({ className={`${isInline ? 'bg-zinc-900/70 backdrop-blur-md mb-6' : 'bg-zinc-900 max-w-md mx-auto mb-8 shadow-2xl'} border border-zinc-800 rounded-2xl w-full overflow-hidden animate-in fade-in zoom-in duration-200`} >
-
-

Rate your Controller

+
+

+ Rate your Controller +

{!isInline && (
); -} \ No newline at end of file +} diff --git a/src/components/tools/WindDisplay.tsx b/src/components/tools/WindDisplay.tsx index e1a3d745..297f3652 100644 --- a/src/components/tools/WindDisplay.tsx +++ b/src/components/tools/WindDisplay.tsx @@ -1,24 +1,39 @@ -import React, { useState, useEffect } from "react"; -import { Wind, AlertTriangle, Loader2, Gauge, RefreshCw, Plane, Clock } from "lucide-react"; -import { fetchMetar } from "../../utils/fetch/metar"; -import type { MetarData } from "../../types/metar"; +import React, { useState, useEffect } from 'react'; +import { + Wind, + AlertTriangle, + Loader2, + Gauge, + RefreshCw, + Plane, + Clock, +} from 'lucide-react'; +import { fetchMetar } from '../../utils/fetch/metar'; +import type { MetarData } from '../../types/metar'; interface WindDisplayProps { icao: string | null; forceHide?: boolean; - size?: "normal" | "small"; + size?: 'normal' | 'small'; } -function metarMatchesSessionIcao(metar: MetarData | null, sessionIcao: string): boolean { +function metarMatchesSessionIcao( + metar: MetarData | null, + sessionIcao: string +): boolean { if (!metar?.icaoId) return false; const u = sessionIcao.trim().toUpperCase(); const id = String(metar.icaoId).toUpperCase(); - if (u === "MDCR") return id === "MDBH"; - if (u === "MTCA") return id === "MTPP"; + if (u === 'MDCR') return id === 'MDBH'; + if (u === 'MTCA') return id === 'MTPP'; return id === u; } -const WindDisplay: React.FC = ({ icao, forceHide = false, size = "normal" }) => { +const WindDisplay: React.FC = ({ + icao, + forceHide = false, + size = 'normal', +}) => { const [metarData, setMetarData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -27,7 +42,9 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size const [lastRefreshed, setLastRefreshed] = useState(null); const [, forceUpdate] = useState({}); const metarRef = React.useRef(null); - const loadFinishTimeoutRef = React.useRef | null>(null); + const loadFinishTimeoutRef = React.useRef | null>(null); React.useEffect(() => { metarRef.current = metarData; @@ -51,10 +68,10 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size try { let data: MetarData | null = null; - if (icao.toLowerCase() === "mdcr") { - data = await fetchMetar("MDBH"); - } else if (icao.toLowerCase() === "mtca") { - data = await fetchMetar("MTPP"); + if (icao.toLowerCase() === 'mdcr') { + data = await fetchMetar('MDBH'); + } else if (icao.toLowerCase() === 'mtca') { + data = await fetchMetar('MTPP'); } else { data = await fetchMetar(icao); } @@ -65,17 +82,17 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size setError(null); setRefreshMiss(false); } else if (!metarMatchesSessionIcao(metarRef.current, icao)) { - setError("No METAR data available"); + setError('No METAR data available'); } else { setRefreshMiss(true); } } catch (err) { if (!metarMatchesSessionIcao(metarRef.current, icao)) { - setError("Failed to load METAR data"); + setError('Failed to load METAR data'); } else { setRefreshMiss(true); } - console.error("Error loading METAR data:", err); + console.error('Error loading METAR data:', err); } finally { const elapsedTime = Date.now() - startTime; const remainingTime = Math.max(0, 500 - elapsedTime); // At least 500ms to avoid a flash of loading @@ -89,7 +106,7 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size }, remainingTime); } }, - [icao], + [icao] ); useEffect(() => { @@ -109,7 +126,7 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size () => { loadMetarData({ background: true }); }, - 5 * 60 * 1000, + 5 * 60 * 1000 ); return () => { @@ -144,34 +161,34 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size if (effectiveWind >= 35) { return { - icon: "text-red-500", - text: "text-red-400", + icon: 'text-red-500', + text: 'text-red-400', }; } if (effectiveWind >= 20) { return { - icon: "text-yellow-400", - text: "text-yellow-300", + icon: 'text-yellow-400', + text: 'text-yellow-300', }; } if (effectiveWind >= 10) { return { - icon: "text-blue-400", - text: "text-blue-300", + icon: 'text-blue-400', + text: 'text-blue-300', }; } return { - icon: "text-green-400", - text: "text-green-300", + icon: 'text-green-400', + text: 'text-green-300', }; }; const formatPressure = (altimeterHpa: number) => { if (!altimeterHpa) { return { - value: "N/A", - unit: "", - label: "", + value: 'N/A', + unit: '', + label: '', }; } @@ -179,25 +196,27 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size const inHgValue = altimeterHpa / 33.8639; return { value: `A${inHgValue.toFixed(2)}`, - unit: "", - label: "Altimeter", + unit: '', + label: 'Altimeter', }; } else { return { value: altimeterHpa.toString(), - unit: " hPa", - label: "QNH", + unit: ' hPa', + label: 'QNH', }; } }; const formatRefreshTime = (refreshDate: Date | null) => { - if (!refreshDate) return "Not refreshed"; + if (!refreshDate) return 'Not refreshed'; const now = new Date(); - const diffMinutes = Math.floor((now.getTime() - refreshDate.getTime()) / (1000 * 60)); + const diffMinutes = Math.floor( + (now.getTime() - refreshDate.getTime()) / (1000 * 60) + ); if (diffMinutes === 0) { - return "Just now"; + return 'Just now'; } else if (diffMinutes < 60) { return `${diffMinutes}m ago`; } else if (diffMinutes < 1440) { @@ -205,28 +224,29 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size return `${hours}h ago`; } else { return refreshDate.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", + hour: '2-digit', + minute: '2-digit', }); } }; const getFlightCategoryColor = (category: string) => { switch (category) { - case "VFR": - return "text-green-400"; - case "MVFR": - return "text-yellow-400"; - case "IFR": - return "text-orange-400"; - case "LIFR": - return "text-red-400"; + case 'VFR': + return 'text-green-400'; + case 'MVFR': + return 'text-yellow-400'; + case 'IFR': + return 'text-orange-400'; + case 'LIFR': + return 'text-red-400'; default: - return "text-gray-400"; + return 'text-gray-400'; } }; - const isSpecial = icao && (icao.toLowerCase() === "mdcr" || icao.toLowerCase() === "mtca"); + const isSpecial = + icao && (icao.toLowerCase() === 'mdcr' || icao.toLowerCase() === 'mtca'); if (forceHide) { return null; @@ -236,10 +256,10 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size return (
- + No airport selected
); @@ -249,10 +269,14 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size return (
- + Loading METAR data...
); @@ -262,19 +286,19 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size return (
- - {error || "No data available"} + + {error || 'No data available'}
); @@ -284,12 +308,14 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size const windSpeed = metarData.wspd; const windGust = metarData.wgst; const formattedDirection = - windDirection != null ? windDirection.toString().padStart(3, "0") + "°" : "VRB"; - const gustInfo = windGust ? `G${windGust}` : ""; + windDirection != null + ? windDirection.toString().padStart(3, '0') + '°' + : 'VRB'; + const gustInfo = windGust ? `G${windGust}` : ''; const windColors = getWindSeverityColor(windSpeed, windGust); const pressureDisplay = formatPressure(metarData.altim); - if (size === "small") { + if (size === 'small') { return (
= ({ icao, forceHide = false, size onClick={togglePressureFormat} className={`flex items-center gap-1 font-mono transition-colors ${ showAltimeter - ? "text-blue-400 hover:text-blue-300" - : "text-green-400 hover:text-green-300" + ? 'text-blue-400 hover:text-blue-300' + : 'text-green-400 hover:text-green-300' }`} - title={`Toggle to ${showAltimeter ? "QNH" : "altimeter"}`} + title={`Toggle to ${showAltimeter ? 'QNH' : 'altimeter'}`} > - + {pressureDisplay.value} {pressureDisplay.unit} @@ -324,15 +352,20 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size
{refreshMiss && ( - + Could not refresh )} - +
{formatRefreshTime(lastRefreshed)} @@ -342,9 +375,9 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size role="tooltip" className="absolute top-8 left-0 z-10 w-max px-2 py-1 text-xs bg-yellow-800 text-white rounded shadow pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-0 whitespace-nowrap" > - There is no data for {icao}. This data is from{" "} - {icao.toLowerCase() === "mdcr" ? "MDBH" : "MTPP"} which is{" "} - {icao.toLowerCase() === "mdcr" ? "65km" : "166km"} away. + There is no data for {icao}. This data is from{' '} + {icao.toLowerCase() === 'mdcr' ? 'MDBH' : 'MTPP'} which is{' '} + {icao.toLowerCase() === 'mdcr' ? '65km' : '166km'} away.
)}
@@ -386,14 +419,16 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size
@@ -459,4 +496,4 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size ); }; -export default WindDisplay; \ No newline at end of file +export default WindDisplay; diff --git a/src/components/tutorial/TutorialStepsCreate.ts b/src/components/tutorial/TutorialStepsCreate.ts index d9c20d47..e3741a21 100644 --- a/src/components/tutorial/TutorialStepsCreate.ts +++ b/src/components/tutorial/TutorialStepsCreate.ts @@ -1,4 +1,4 @@ -import type { Placement } from "react-joyride-react19-compat"; +import type { Placement } from 'react-joyride-react19-compat'; export const steps: { target: string; @@ -9,51 +9,52 @@ export const steps: { isLast?: boolean; }[] = [ { - target: "#session-count-info", - title: "Session Limit", + target: '#session-count-info', + title: 'Session Limit', content: - "You can only create a limited amount of sessions. If you reach the limit, delete an old one to make room.", - placement: "bottom" as Placement, + 'You can only create a limited amount of sessions. If you reach the limit, delete an old one to make room.', + placement: 'bottom' as Placement, disableNext: true, }, { - target: "#airport-dropdown", - title: "Select Airport", + target: '#airport-dropdown', + title: 'Select Airport', content: "Choose the airport where you'll be controlling. This sets the location for your session.", - placement: "right" as Placement, + placement: 'right' as Placement, disableNext: true, }, { - target: "#runway-dropdown", - title: "Select Departure Runway", - content: "Pick the active departure runway. This helps with wind and ATIS generation.", - placement: "right" as Placement, + target: '#runway-dropdown', + title: 'Select Departure Runway', + content: + 'Pick the active departure runway. This helps with wind and ATIS generation.', + placement: 'right' as Placement, disableNext: true, }, { - target: "#arrival-runway-dropdown", - title: "Select Arrival Runway (Optional)", + target: '#arrival-runway-dropdown', + title: 'Select Arrival Runway (Optional)', content: - "Optionally set a different runway for arrivals. If not selected, the departure runway will be used for both.", - placement: "right" as Placement, + 'Optionally set a different runway for arrivals. If not selected, the departure runway will be used for both.', + placement: 'right' as Placement, disableNext: true, }, { - target: "#network-session-options", - title: "Network session (optional)", + target: '#network-session-options', + title: 'Network session (optional)', content: - "PFATC Network or Advanced ATC enables the live overview and shared arrivals. For this tutorial, PFATC mode is turned on automatically.", - placement: "top" as Placement, + 'PFATC Network or Advanced ATC enables the live overview and shared arrivals. For this tutorial, PFATC mode is turned on automatically.', + placement: 'top' as Placement, disableNext: true, }, { - target: "#create-session-btn", - title: "Create Your Session", + target: '#create-session-btn', + title: 'Create Your Session', content: "Click here to create your session and start controlling! You'll be taken to the flight management page.", - placement: "top" as Placement, + placement: 'top' as Placement, disableNext: true, isLast: true, }, -]; \ No newline at end of file +]; diff --git a/src/hooks/auth/AuthProvider.tsx b/src/hooks/auth/AuthProvider.tsx index 7eaabb50..9a1bd363 100644 --- a/src/hooks/auth/AuthProvider.tsx +++ b/src/hooks/auth/AuthProvider.tsx @@ -1,9 +1,9 @@ -import React, { useState, useEffect, useRef } from "react"; -import { getCurrentUser, logout as apiLogout } from "../../utils/fetch/auth"; -import { AuthContext } from "./useAuth"; -import { useFingerprint } from "./useFingerprint"; -import type { User } from "../../types/user"; -import { usePostHog } from "@posthog/react"; +import React, { useState, useEffect, useRef } from 'react'; +import { getCurrentUser, logout as apiLogout } from '../../utils/fetch/auth'; +import { AuthContext } from './useAuth'; +import { useFingerprint } from './useFingerprint'; +import type { User } from '../../types/user'; +import { usePostHog } from '@posthog/react'; export function AuthProvider({ children }: { children: React.ReactNode }) { const posthog = usePostHog(); @@ -47,8 +47,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { rolePermissions: currentUser.rolePermissions || {}, }); } catch (error) { - console.error("Error refreshing user:", error); - posthog?.captureException?.(error, { source: "AuthProvider.refreshUser" }); + console.error('Error refreshing user:', error); + posthog?.captureException?.(error, { + source: 'AuthProvider.refreshUser', + }); setUser(null); } finally { setIsLoading(false); @@ -65,8 +67,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { await apiLogout(); setUser(null); } catch (error) { - console.error("Error logging out:", error); - posthog?.captureException?.(error, { source: "AuthProvider.logout" }); + console.error('Error logging out:', error); + posthog?.captureException?.(error, { source: 'AuthProvider.logout' }); } }; @@ -76,8 +78,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const handler = () => refreshUser(); - window.addEventListener("auth:forbidden", handler); - return () => window.removeEventListener("auth:forbidden", handler); + window.addEventListener('auth:forbidden', handler); + return () => window.removeEventListener('auth:forbidden', handler); }, []); return ( @@ -93,4 +95,4 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { {children} ); -} \ No newline at end of file +} diff --git a/src/hooks/auth/useFingerprint.ts b/src/hooks/auth/useFingerprint.ts index 03a99b58..22a79a63 100644 --- a/src/hooks/auth/useFingerprint.ts +++ b/src/hooks/auth/useFingerprint.ts @@ -40,4 +40,4 @@ export function useFingerprint(userId: string | undefined) { cancelled = true; }; }, [userId]); -} \ No newline at end of file +} diff --git a/src/hooks/useActiveUpdateModal.ts b/src/hooks/useActiveUpdateModal.ts index e749aa24..eb0d7716 100644 --- a/src/hooks/useActiveUpdateModal.ts +++ b/src/hooks/useActiveUpdateModal.ts @@ -9,7 +9,9 @@ function shouldBypassTesterGate() { return window.location.hostname === 'pfcontrol.com'; } -export function useActiveUpdateModal(user: { isTester?: boolean; isAdmin?: boolean } | null) { +export function useActiveUpdateModal( + user: { isTester?: boolean; isAdmin?: boolean } | null +) { const [testerGateEnabled, setTesterGateEnabled] = useState(false); const [showUpdateModal, setShowUpdateModal] = useState(false); const [activeModal, setActiveModal] = useState(null); diff --git a/src/islands/FlightContent.tsx b/src/islands/FlightContent.tsx index a6d6bb78..02a8abfc 100644 --- a/src/islands/FlightContent.tsx +++ b/src/islands/FlightContent.tsx @@ -27,4 +27,4 @@ export default function FlightContent({ flightId }: Props) { ); -} \ No newline at end of file +} diff --git a/src/islands/HomeContent.tsx b/src/islands/HomeContent.tsx index 6cd44bf9..36144ac6 100644 --- a/src/islands/HomeContent.tsx +++ b/src/islands/HomeContent.tsx @@ -17,4 +17,4 @@ export default function HomeContent() { ); -} \ No newline at end of file +} diff --git a/src/islands/PostHogProviderWrapper.tsx b/src/islands/PostHogProviderWrapper.tsx index f742d5d7..5f90860a 100644 --- a/src/islands/PostHogProviderWrapper.tsx +++ b/src/islands/PostHogProviderWrapper.tsx @@ -14,4 +14,4 @@ export function PostHogProviderWrapper({ children }: { children: ReactNode }) { {children} ); -} \ No newline at end of file +} diff --git a/src/islands/ProfileContent.tsx b/src/islands/ProfileContent.tsx index 25288c4b..67d571f8 100644 --- a/src/islands/ProfileContent.tsx +++ b/src/islands/ProfileContent.tsx @@ -21,4 +21,4 @@ export default function ProfileContent({ username }: Props) { ); -} \ No newline at end of file +} diff --git a/src/islands/PublicChrome.tsx b/src/islands/PublicChrome.tsx index c7af6678..e26458ba 100644 --- a/src/islands/PublicChrome.tsx +++ b/src/islands/PublicChrome.tsx @@ -24,4 +24,4 @@ export function PublicFooter() { ); -} \ No newline at end of file +} diff --git a/src/islands/SubmitSessionContent.tsx b/src/islands/SubmitSessionContent.tsx index 36001508..58b3e835 100644 --- a/src/islands/SubmitSessionContent.tsx +++ b/src/islands/SubmitSessionContent.tsx @@ -10,7 +10,9 @@ interface SubmitSessionContentProps { airportIcao?: string; } -export default function SubmitSessionContent({ airportIcao }: SubmitSessionContentProps) { +export default function SubmitSessionContent({ + airportIcao, +}: SubmitSessionContentProps) { return ( @@ -20,7 +22,12 @@ export default function SubmitSessionContent({ airportIcao }: SubmitSessionConte } + element={ + + } /> @@ -29,4 +36,4 @@ export default function SubmitSessionContent({ airportIcao }: SubmitSessionConte ); -} \ No newline at end of file +} diff --git a/src/islands/loadIslandStyles.ts b/src/islands/loadIslandStyles.ts index a0f782ee..5f95c69a 100644 --- a/src/islands/loadIslandStyles.ts +++ b/src/islands/loadIslandStyles.ts @@ -1 +1 @@ -import '../index.css'; \ No newline at end of file +import '../index.css'; diff --git a/src/main.tsx b/src/main.tsx index 234e5c91..da8d8899 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,24 +1,27 @@ -import { createRoot } from "react-dom/client"; -import "./index.css"; -import App from "./App.tsx"; -import { PostHogErrorBoundary, PostHogProvider } from "@posthog/react"; -import { AuthProvider } from "./hooks/auth/AuthProvider.tsx"; -import { DataProvider } from "./hooks/data/DataProvider.tsx"; -import { SettingsProvider } from "./hooks/settings/SettingsProvider.tsx"; -import PostHogErrorFallback from "./components/PostHogErrorFallback.tsx"; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App.tsx'; +import { PostHogErrorBoundary, PostHogProvider } from '@posthog/react'; +import { AuthProvider } from './hooks/auth/AuthProvider.tsx'; +import { DataProvider } from './hooks/data/DataProvider.tsx'; +import { SettingsProvider } from './hooks/settings/SettingsProvider.tsx'; +import PostHogErrorFallback from './components/PostHogErrorFallback.tsx'; const posthogOptions = { - api_host: import.meta.env.VITE_POSTHOG_HOST || "https://us.i.posthog.com", - ui_host: "https://us.posthog.com", - defaults: "2026-01-30", - persistence: "memory" as const, + api_host: import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com', + ui_host: 'https://us.posthog.com', + defaults: '2026-01-30', + persistence: 'memory' as const, errorTracking: { autocaptureExceptions: true, }, } as const; -createRoot(document.getElementById("root")!).render( - +createRoot(document.getElementById('root')!).render( + @@ -28,5 +31,5 @@ createRoot(document.getElementById("root")!).render( - , -); \ No newline at end of file + +); diff --git a/src/pages/ACARS.tsx b/src/pages/ACARS.tsx index cb5f5c46..7c7604f8 100644 --- a/src/pages/ACARS.tsx +++ b/src/pages/ACARS.tsx @@ -865,7 +865,10 @@ NOTES: > Create Account Now - diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index a2a879c3..02535a21 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -1,87 +1,87 @@ -import { useState, useEffect, useCallback, useMemo } from "react"; -import { MdDashboard, MdSettings } from "react-icons/md"; -import AdminRefreshButton from "../components/admin/AdminRefreshButton"; -import AdminLayout from "../components/admin/AdminLayout"; -import AdminPageHeader from "../components/admin/AdminPageHeader"; -import AdminStatCards from "../components/admin/AdminStatCards"; -import AdminSectionTitle from "../components/admin/AdminSectionTitle"; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { MdDashboard, MdSettings } from 'react-icons/md'; +import AdminRefreshButton from '../components/admin/AdminRefreshButton'; +import AdminLayout from '../components/admin/AdminLayout'; +import AdminPageHeader from '../components/admin/AdminPageHeader'; +import AdminStatCards from '../components/admin/AdminStatCards'; +import AdminSectionTitle from '../components/admin/AdminSectionTitle'; import { AdminAreaChart, AdminMultiSeriesAreaChart, -} from "../components/admin/AdminChart"; +} from '../components/admin/AdminChart'; import { adminDownsizeButtonSize, adminSectionClass, ADMIN_HEADER_ACTIONS_MOBILE, ADMIN_SEGMENT_ACTIVE, ADMIN_SEGMENT_INACTIVE, -} from "../components/admin/adminConstants"; -import Loader from "../components/common/Loader"; -import { useAuth } from "../hooks/auth/useAuth"; +} from '../components/admin/adminConstants'; +import Loader from '../components/common/Loader'; +import { useAuth } from '../hooks/auth/useAuth'; import { fetchAdminStatistics, fetchAppVersion, fetchApiLogStats24h, type AdminStats, type AppVersion, -} from "../utils/fetch/admin"; -import Button from "../components/common/Button"; -import ErrorScreen from "../components/common/ErrorScreen"; +} from '../utils/fetch/admin'; +import Button from '../components/common/Button'; +import ErrorScreen from '../components/common/ErrorScreen'; -type ActivityChartView = "flights" | "sessions" | "accounts"; +type ActivityChartView = 'flights' | 'sessions' | 'accounts'; const ACTIVITY_CHART_VIEWS: { id: ActivityChartView; label: string; color: string; - periodKey: "total_flights" | "total_sessions" | null; + periodKey: 'total_flights' | 'total_sessions' | null; periodLabel?: string; }[] = [ { - id: "flights", - label: "Flights", - color: "#a78bfa", - periodKey: "total_flights", + id: 'flights', + label: 'Flights', + color: '#a78bfa', + periodKey: 'total_flights', }, { - id: "sessions", - label: "Sessions", - color: "#34d399", - periodKey: "total_sessions", + id: 'sessions', + label: 'Sessions', + color: '#34d399', + periodKey: 'total_sessions', }, { - id: "accounts", - label: "Accounts", - color: "#60a5fa", + id: 'accounts', + label: 'Accounts', + color: '#60a5fa', periodKey: null, }, ]; const ACCOUNTS_SERIES = [ - { key: "logins", label: "Logins", color: "#60a5fa", strokeDasharray: "2 3" }, + { key: 'logins', label: 'Logins', color: '#60a5fa', strokeDasharray: '2 3' }, { - key: "users", - label: "New users", - color: "#fbbf24", - strokeDasharray: "8 4", + key: 'users', + label: 'New users', + color: '#fbbf24', + strokeDasharray: '8 4', }, ] as const; const API_SERIES = [ - { key: "successful", label: "2xx", color: "#34d399" }, + { key: 'successful', label: '2xx', color: '#34d399' }, { - key: "clientErrors", - label: "4xx", - color: "#fbbf24", - strokeDasharray: "6 4", + key: 'clientErrors', + label: '4xx', + color: '#fbbf24', + strokeDasharray: '6 4', }, { - key: "serverErrors", - label: "5xx", - color: "#f87171", - strokeDasharray: "2 3", + key: 'serverErrors', + label: '5xx', + color: '#f87171', + strokeDasharray: '2 3', }, - { key: "other", label: "Other", color: "#94a3b8", strokeDasharray: "8 4" }, + { key: 'other', label: 'Other', color: '#94a3b8', strokeDasharray: '8 4' }, ] as const; export default function Admin() { @@ -92,7 +92,7 @@ export default function Admin() { const [error, setError] = useState(null); const [toast, setToast] = useState<{ message: string; - type: "success" | "error" | "info"; + type: 'success' | 'error' | 'info'; } | null>(null); const [appVersion, setAppVersion] = useState(null); const [versionLoading, setVersionLoading] = useState(false); @@ -106,7 +106,7 @@ export default function Admin() { }> >([]); const [activityChartView, setActivityChartView] = - useState("flights"); + useState('flights'); const hasPermission = (permission: string) => Boolean(user?.isAdmin || user?.rolePermissions?.[permission]); @@ -127,11 +127,11 @@ export default function Admin() { ); setStats({ ...data, periodTotals, totals: data.totals }); } catch (err) { - console.error("Error fetching admin statistics:", err); + console.error('Error fetching admin statistics:', err); setError( - err instanceof Error ? err.message : "Failed to fetch statistics" + err instanceof Error ? err.message : 'Failed to fetch statistics' ); - setToast({ message: "Failed to fetch statistics", type: "error" }); + setToast({ message: 'Failed to fetch statistics', type: 'error' }); } finally { setLoading(false); } @@ -143,8 +143,8 @@ export default function Admin() { setVersionLoading(true); setAppVersion(await fetchAppVersion()); } catch (err) { - console.error("Error fetching app version:", err); - setToast({ message: "Failed to fetch app version", type: "error" }); + console.error('Error fetching app version:', err); + setToast({ message: 'Failed to fetch app version', type: 'error' }); } finally { setVersionLoading(false); } @@ -154,8 +154,8 @@ export default function Admin() { try { setApiLogStats24h(await fetchApiLogStats24h()); } catch (err) { - console.error("Error fetching API log stats:", err); - setToast({ message: "Failed to fetch API log stats", type: "error" }); + console.error('Error fetching API log stats:', err); + setToast({ message: 'Failed to fetch API log stats', type: 'error' }); } }, []); @@ -188,7 +188,7 @@ export default function Admin() { )!; const singleSeriesChartData = useMemo(() => { - if (activityChartView === "accounts") return []; + if (activityChartView === 'accounts') return []; return activityChartData.map((row) => ({ label: row.label, value: Number(row[activityChartView]) || 0, @@ -207,7 +207,7 @@ export default function Admin() { [apiLogStats24h] ); - const btnSize = adminDownsizeButtonSize("sm"); + const btnSize = adminDownsizeButtonSize('sm'); const period = stats?.periodTotals; return ( @@ -223,7 +223,7 @@ export default function Admin() {
diff --git a/src/pages/MyFlightDetail.tsx b/src/pages/MyFlightDetail.tsx index a8046b50..5ccf2e3c 100644 --- a/src/pages/MyFlightDetail.tsx +++ b/src/pages/MyFlightDetail.tsx @@ -77,7 +77,9 @@ export default function MyFlightDetail() { const [error, setError] = useState(''); const [availableImages, setAvailableImages] = useState([]); const [customLoaded, setCustomLoaded] = useState(false); - const [sessionInfo, setSessionInfo] = useState(null); + const [sessionInfo, setSessionInfo] = useState( + null + ); const [notes, setNotes] = useState(''); const [notesSaved, setNotesSaved] = useState(false); const [copied, setCopied] = useState(false); @@ -111,7 +113,9 @@ export default function MyFlightDetail() { setLogsDiscardedDueToAge(logsData.logsDiscardedDueToAge); fetch(`${API_BASE_URL}/api/sessions/${flightData.session_id}/submit`) .then((r) => (r.ok ? r.json() : null)) - .then((data) => { if (data) setSessionInfo(data); }) + .then((data) => { + if (data) setSessionInfo(data); + }) .catch(() => {}); }) .catch(() => setError('Failed to load flight details.')) @@ -126,9 +130,13 @@ export default function MyFlightDetail() { await updateFlightNotes(id, notes); setNotesSaved(true); setTimeout(() => setNotesSaved(false), 2000); - } catch { /* silent */ } + } catch { + /* silent */ + } }, 800); - return () => { if (notesDebounceRef.current) clearTimeout(notesDebounceRef.current); }; + return () => { + if (notesDebounceRef.current) clearTimeout(notesDebounceRef.current); + }; }, [notes, id]); const statusTimeline = useMemo(() => { @@ -137,7 +145,11 @@ export default function MyFlightDetail() { const oldStatus = (log.old_data?.status as string | undefined) ?? null; const newStatus = (log.new_data?.status as string | undefined) ?? null; if (log.action === 'add' && newStatus) { - return { id: log.id, label: `Created as ${newStatus}`, at: log.created_at }; + return { + id: log.id, + label: `Created as ${newStatus}`, + at: log.created_at, + }; } if (log.action === 'update' && oldStatus !== newStatus && newStatus) { return { @@ -163,7 +175,8 @@ export default function MyFlightDetail() { const selectedImage = settings?.backgroundImage?.selectedImage; let bgImage = 'url("/assets/images/hero.webp")'; const getImageUrl = (filename: string | null): string | null => { - if (!filename || filename === 'random' || filename === 'favorites') return filename; + if (!filename || filename === 'random' || filename === 'favorites') + return filename; if (filename.startsWith('https://api.cephie.app/')) return filename; return `${API_BASE_URL}/assets/app/backgrounds/${filename}`; }; @@ -177,17 +190,25 @@ export default function MyFlightDetail() { if (favorites.length > 0) { const fav = favorites[Math.floor(Math.random() * favorites.length)]; const url = getImageUrl(fav); - if (url && url !== 'random' && url !== 'favorites') bgImage = `url(${url})`; + if (url && url !== 'random' && url !== 'favorites') + bgImage = `url(${url})`; } } else if (selectedImage) { const url = getImageUrl(selectedImage); - if (url && url !== 'random' && url !== 'favorites') bgImage = `url(${url})`; + if (url && url !== 'random' && url !== 'favorites') + bgImage = `url(${url})`; } return bgImage; - }, [settings?.backgroundImage?.selectedImage, settings?.backgroundImage?.favorites, availableImages, snaps]); + }, [ + settings?.backgroundImage?.selectedImage, + settings?.backgroundImage?.favorites, + availableImages, + snaps, + ]); useEffect(() => { - if (backgroundImage !== 'url("/assets/images/hero.webp")') setCustomLoaded(true); + if (backgroundImage !== 'url("/assets/images/hero.webp")') + setCustomLoaded(true); }, [backgroundImage]); const acarsUrl = flight?.acars_token @@ -211,10 +232,16 @@ export default function MyFlightDetail() { try { const { featured: newFeatured } = await toggleFeaturedOnProfile(id); setFeatured(newFeatured); - setFeaturedToast(newFeatured ? 'Added to profile' : 'Removed from profile'); + setFeaturedToast( + newFeatured ? 'Added to profile' : 'Removed from profile' + ); setTimeout(() => setFeaturedToast(''), 2500); } catch (err) { - setFeaturedToast(err instanceof Error && err.message === 'CAP_REACHED' ? 'Max 3 featured flights' : 'Failed to update'); + setFeaturedToast( + err instanceof Error && err.message === 'CAP_REACHED' + ? 'Max 3 featured flights' + : 'Failed to update' + ); setTimeout(() => setFeaturedToast(''), 2500); } finally { setFeaturedLoading(false); @@ -254,7 +281,11 @@ export default function MyFlightDetail() { {/* Hero — use user's banner while flight loads */}
- Banner + Banner
(
- {i < 2 &&
} + {i < 2 && ( +
+ )}
))}
@@ -368,7 +401,8 @@ export default function MyFlightDetail() { const isPFATC = sessionInfo?.isPFATC ?? false; const isAdvancedATC = sessionInfo?.isAdvancedATC ?? false; const formattedCallsign = parseCallsign(flight.callsign || '', airlines); - const hasSpokenName = formattedCallsign !== (flight.callsign || '').toUpperCase(); + const hasSpokenName = + formattedCallsign !== (flight.callsign || '').toUpperCase(); return (
@@ -381,18 +415,33 @@ export default function MyFlightDetail() { )} {lightboxSrc && ( -
setLightboxSrc(null)}> - - Snap e.stopPropagation()} /> + Snap e.stopPropagation()} + />
)} {/* Hero */}
- Banner + Banner
{hasSpokenName ? ( <> -

{formattedCallsign}

-

({flight.callsign?.toUpperCase()})

+

+ {formattedCallsign} +

+

+ ({flight.callsign?.toUpperCase()}) +

) : ( -

{flight.callsign || 'Unknown Callsign'}

+

+ {flight.callsign || 'Unknown Callsign'} +

)}
@@ -432,9 +487,13 @@ export default function MyFlightDetail() { {getDisplayStatus(flight.status)} {isAdvancedATC ? ( - Advanced ATC + + Advanced ATC + ) : isPFATC ? ( - PFATC + + PFATC + ) : null}
@@ -444,10 +503,14 @@ export default function MyFlightDetail() { onClick={handleToggleFeatured} disabled={featuredLoading} className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-semibold border transition-all ${ - featured ? 'bg-amber-600 border-amber-500 text-amber-50 hover:bg-amber-500' : 'bg-zinc-700 border-zinc-600 text-zinc-200 hover:bg-zinc-600' + featured + ? 'bg-amber-600 border-amber-500 text-amber-50 hover:bg-amber-500' + : 'bg-zinc-700 border-zinc-600 text-zinc-200 hover:bg-zinc-600' }`} > - + {featured ? 'Featured' : 'Feature'} {acarsUrl && ( @@ -455,10 +518,20 @@ export default function MyFlightDetail() { - + Back to My Flights @@ -498,8 +574,16 @@ export default function MyFlightDetail() { {snapUploading ? 'Uploading…' : 'Add Photo'}
- - {snapError &&

{snapError}

} + + {snapError && ( +

{snapError}

+ )} {snaps.length === 0 && !snapUploading ? ( - - @@ -174,14 +202,20 @@ function FlightCard({
{callsign} - {featured && } + {featured && ( + + )}
- {flight.departure || '----'} + + {flight.departure || '----'} + - {flight.arrival || '----'} + + {flight.arrival || '----'} +
@@ -190,24 +224,34 @@ function FlightCard({
{flight.created_at - ? new Date(flight.created_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) + ? new Date(flight.created_at).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }) : 'Unknown date'}
{flight.isAdvancedATC ? ( <> - Advanced ATC Session + + Advanced ATC Session + ) : flight.isPFATC ? ( <> - PFATC Session + + PFATC Session + ) : ( <> - Standard Session + + Standard Session + )}
@@ -215,15 +259,22 @@ function FlightCard({
-
-{acarsUrl && ( +
+ {acarsUrl && ( <> {dropdown} @@ -244,16 +295,22 @@ function FlightCard({
{callsign} - {featured && } + {featured && ( + + )}
{/* Details */}
- {flight.departure || '----'} + + {flight.departure || '----'} + - {flight.arrival || '----'} + + {flight.arrival || '----'} +
@@ -262,14 +319,20 @@ function FlightCard({
{flight.created_at - ? new Date(flight.created_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) + ? new Date(flight.created_at).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }) : 'Unknown date'}
{flight.isAdvancedATC ? ( <> - Advanced ATC Session + + Advanced ATC Session + ) : flight.isPFATC ? ( <> @@ -279,12 +342,13 @@ function FlightCard({ ) : ( <> - Standard Session + + Standard Session + )}
- {/* 3-dot menu */} @@ -295,7 +359,11 @@ function FlightCard({ className="px-3 py-2 rounded-2xl text-blue-400 border-2 border-blue-600 hover:bg-blue-600 hover:text-white transition-colors" aria-label="Flight options" > - {copied ? : } + {copied ? ( + + ) : ( + + )} {dropdown}
@@ -362,7 +430,9 @@ export default function MyFlights() { const handleFeaturedToggle = (id: string, newFeatured: boolean) => { setFlights((prev) => - prev.map((f) => (String(f.id) === id ? { ...f, featured_on_profile: newFeatured } : f)) + prev.map((f) => + String(f.id) === id ? { ...f, featured_on_profile: newFeatured } : f + ) ); }; @@ -527,4 +597,4 @@ export default function MyFlights() {
); -} \ No newline at end of file +} diff --git a/src/pages/PFATCFlights.tsx b/src/pages/PFATCFlights.tsx index 586ec8e6..f4a25721 100644 --- a/src/pages/PFATCFlights.tsx +++ b/src/pages/PFATCFlights.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { MapPin, Plane, @@ -16,36 +16,36 @@ import { Eye, EyeOff, FileText, -} from "lucide-react"; -import { createPortal } from "react-dom"; -import { createOverviewSocket } from "../sockets/overviewSocket"; -import { useAuth } from "../hooks/auth/useAuth"; -import { useData } from "../hooks/data/useData"; -import { useSettings } from "../hooks/settings/useSettings"; -import { getChartsForAirport } from "../utils/acars"; -import { parseCallsign } from "../utils/callsignParser"; -import { createChartHandlers } from "../utils/charts"; -import { createSectorControllerSocket } from "../sockets/sectorControllerSocket"; -import { fetchBackgrounds } from "../utils/fetch/data"; -import type { OverviewData, OverviewSession } from "../types/overview"; -import type { Flight } from "../types/flight"; -import Navbar from "../components/Navbar"; -import WindDisplay from "../components/tools/WindDisplay"; -import FrequencyDisplay from "../components/tools/FrequencyDisplay"; -import ChartDrawer from "../components/tools/ChartDrawer"; -import ContactAcarsSidebar from "../components/tools/ContactAcarsSidebar"; -import { ChatSidebar } from "../components/chat"; -import Button from "../components/common/Button"; -import Dropdown from "../components/common/Dropdown"; -import TextInput from "../components/common/TextInput"; -import StatusDropdown from "../components/dropdowns/StatusDropdown"; -import AirportDropdown from "../components/dropdowns/AirportDropdown"; -import AircraftDropdown from "../components/dropdowns/AircraftDropdown"; -import AltitudeDropdown from "../components/dropdowns/AltitudeDropdown"; -import SidDropdown from "../components/dropdowns/SidDropdown"; -import StarDropdown from "../components/dropdowns/StarDropdown"; -import ErrorScreen from "../components/common/ErrorScreen"; -import FlightDetailsModal from "../components/tools/FlightDetailModal"; +} from 'lucide-react'; +import { createPortal } from 'react-dom'; +import { createOverviewSocket } from '../sockets/overviewSocket'; +import { useAuth } from '../hooks/auth/useAuth'; +import { useData } from '../hooks/data/useData'; +import { useSettings } from '../hooks/settings/useSettings'; +import { getChartsForAirport } from '../utils/acars'; +import { parseCallsign } from '../utils/callsignParser'; +import { createChartHandlers } from '../utils/charts'; +import { createSectorControllerSocket } from '../sockets/sectorControllerSocket'; +import { fetchBackgrounds } from '../utils/fetch/data'; +import type { OverviewData, OverviewSession } from '../types/overview'; +import type { Flight } from '../types/flight'; +import Navbar from '../components/Navbar'; +import WindDisplay from '../components/tools/WindDisplay'; +import FrequencyDisplay from '../components/tools/FrequencyDisplay'; +import ChartDrawer from '../components/tools/ChartDrawer'; +import ContactAcarsSidebar from '../components/tools/ContactAcarsSidebar'; +import { ChatSidebar } from '../components/chat'; +import Button from '../components/common/Button'; +import Dropdown from '../components/common/Dropdown'; +import TextInput from '../components/common/TextInput'; +import StatusDropdown from '../components/dropdowns/StatusDropdown'; +import AirportDropdown from '../components/dropdowns/AirportDropdown'; +import AircraftDropdown from '../components/dropdowns/AircraftDropdown'; +import AltitudeDropdown from '../components/dropdowns/AltitudeDropdown'; +import SidDropdown from '../components/dropdowns/SidDropdown'; +import StarDropdown from '../components/dropdowns/StarDropdown'; +import ErrorScreen from '../components/common/ErrorScreen'; +import FlightDetailsModal from '../components/tools/FlightDetailModal'; const API_BASE_URL = import.meta.env.VITE_SERVER_URL; @@ -69,26 +69,39 @@ export default function PFATCFlights() { const [overviewData, setOverviewData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); - const [selectedAirport, setSelectedAirport] = useState(""); - const [selectedStatus, setSelectedStatus] = useState(""); - const [selectedFlightType, setSelectedFlightType] = useState(""); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedAirport, setSelectedAirport] = useState(''); + const [selectedStatus, setSelectedStatus] = useState(''); + const [selectedFlightType, setSelectedFlightType] = useState(''); const [networkSessionFilter, setNetworkSessionFilter] = useState< - "all" | "pfatc" | "advanced_atc" - >("all"); - const [expandedAirports, setExpandedAirports] = useState>(new Set()); + 'all' | 'pfatc' | 'advanced_atc' + >('all'); + const [expandedAirports, setExpandedAirports] = useState>( + new Set() + ); const [availableImages, setAvailableImages] = useState([]); const [customLoaded, setCustomLoaded] = useState(false); - const [debounceTimers, setDebounceTimers] = useState>(new Map()); - const [pendingUpdates, setPendingUpdates] = useState>>(new Map()); - const [updatingFlights, setUpdatingFlights] = useState>(new Set()); + const [debounceTimers, setDebounceTimers] = useState< + Map + >(new Map()); + const [pendingUpdates, setPendingUpdates] = useState< + Map> + >(new Map()); + const [updatingFlights, setUpdatingFlights] = useState>( + new Set() + ); const debounceTimersRef = useRef(debounceTimers); const updatingFlightsRef = useRef(updatingFlights); - const [openActionMenuId, setOpenActionMenuId] = useState(null); - const [selectedFlightForModal, setSelectedFlightForModal] = useState(null); + const [openActionMenuId, setOpenActionMenuId] = useState< + string | number | null + >(null); + const [selectedFlightForModal, setSelectedFlightForModal] = + useState(null); const [isFlightDetailModalOpen, setIsFlightDetailModalOpen] = useState(false); - const actionButtonRefs = useRef>({}); + const actionButtonRefs = useRef< + Record + >({}); const handleOpenFlightDetails = (flight: Flight) => { setSelectedFlightForModal(flight); @@ -115,7 +128,7 @@ export default function PFATCFlights() { const data = await fetchBackgrounds(); setAvailableImages(data); } catch (error) { - console.error("Error loading available images:", error); + console.error('Error loading available images:', error); } }; loadImages(); @@ -126,32 +139,37 @@ export default function PFATCFlights() { let bgImage = 'url("/assets/images/hero.webp")'; const getImageUrl = (filename: string | null): string | null => { - if (!filename || filename === "random" || filename === "favorites") { + if (!filename || filename === 'random' || filename === 'favorites') { return filename; } - if (filename.startsWith("https://api.cephie.app/")) { + if (filename.startsWith('https://api.cephie.app/')) { return filename; } return `${API_BASE_URL}/assets/app/backgrounds/${filename}`; }; - if (selectedImage === "random") { + if (selectedImage === 'random') { if (availableImages.length > 0) { const randomIndex = Math.floor(Math.random() * availableImages.length); bgImage = `url(${API_BASE_URL}${availableImages[randomIndex].path})`; } - } else if (selectedImage === "favorites") { + } else if (selectedImage === 'favorites') { const favorites = settings?.backgroundImage?.favorites || []; if (favorites.length > 0) { - const randomFav = favorites[Math.floor(Math.random() * favorites.length)]; + const randomFav = + favorites[Math.floor(Math.random() * favorites.length)]; const favImageUrl = getImageUrl(randomFav); - if (favImageUrl && favImageUrl !== "random" && favImageUrl !== "favorites") { + if ( + favImageUrl && + favImageUrl !== 'random' && + favImageUrl !== 'favorites' + ) { bgImage = `url(${favImageUrl})`; } } } else if (selectedImage) { const imageUrl = getImageUrl(selectedImage); - if (imageUrl && imageUrl !== "random" && imageUrl !== "favorites") { + if (imageUrl && imageUrl !== 'random' && imageUrl !== 'favorites') { bgImage = `url(${imageUrl})`; } } @@ -169,7 +187,7 @@ export default function PFATCFlights() { } }, [backgroundImage]); - const [selectedStation, setSelectedStation] = useState(""); + const [selectedStation, setSelectedStation] = useState(''); const [isChartDrawerOpen, setIsChartDrawerOpen] = useState(false); const [selectedChart, setSelectedChart] = useState(null); @@ -182,9 +200,14 @@ export default function PFATCFlights() { const containerRef = useRef(null!); const [isContactSidebarOpen, setIsContactSidebarOpen] = useState(false); - const [activeAcarsFlights, setActiveAcarsFlights] = useState>(new Set()); - const [activeAcarsFlightsData, setActiveAcarsFlightsData] = useState([]); - const [eventControllerViewEnabled, setEventControllerViewEnabled] = useState(false); + const [activeAcarsFlights, setActiveAcarsFlights] = useState< + Set + >(new Set()); + const [activeAcarsFlightsData, setActiveAcarsFlightsData] = useState< + Flight[] + >([]); + const [eventControllerViewEnabled, setEventControllerViewEnabled] = + useState(false); const [chatOpen, setChatOpen] = useState(false); const [unreadMentions, setUnreadMentions] = useState(0); @@ -201,20 +224,25 @@ export default function PFATCFlights() { } }, [selectedStation, chatOpen]); - const overviewSocketRef = useRef | null>(null); + const overviewSocketRef = useRef | null>(null); const isPFATCSectorController = Boolean( - user?.rolePermissions?.["pfatc_sector"] || user?.isAdmin, + user?.rolePermissions?.['pfatc_sector'] || user?.isAdmin ); const isAATCSectorController = Boolean( - user?.rolePermissions?.["aatc_sector"] || user?.isAdmin, + user?.rolePermissions?.['aatc_sector'] || user?.isAdmin ); // Combined — used for showing toolbar controls and connecting the overview socket const isEventController = isPFATCSectorController || isAATCSectorController; - const canEditFlight = (flight: { sessionIsPFATC?: boolean; sessionIsAdvancedATC?: boolean }) => { + const canEditFlight = (flight: { + sessionIsPFATC?: boolean; + sessionIsAdvancedATC?: boolean; + }) => { if (isPFATCSectorController && flight.sessionIsPFATC) return true; if (isAATCSectorController && flight.sessionIsAdvancedATC) return true; return false; @@ -232,9 +260,9 @@ export default function PFATCFlights() { chartDragStart, setChartDragStart, containerRef, - imageSize, + imageSize ), - [chartZoom, chartPan, isChartDragging, chartDragStart, imageSize], + [chartZoom, chartPan, isChartDragging, chartDragStart, imageSize] ); const handleMentionReceived = useCallback(() => { @@ -250,15 +278,17 @@ export default function PFATCFlights() { > = {}; if (data.arrivalsByAirport && data.activeSessions) { - for (const [icao, flights] of Object.entries(data.arrivalsByAirport)) { + for (const [icao, flights] of Object.entries( + data.arrivalsByAirport + )) { transformedArrivalsByAirport[icao] = flights.map((flight) => { const session = data.activeSessions.find((s) => - s.flights.some((f) => f.id === flight.id), + s.flights.some((f) => f.id === flight.id) ); return { ...flight, - sessionId: session?.sessionId || "", - departureAirport: flight.departure || "", + sessionId: session?.sessionId || '', + departureAirport: flight.departure || '', }; }); } @@ -274,7 +304,7 @@ export default function PFATCFlights() { const flightsWithPendingChanges = new Set(); debounceTimersRef.current.forEach((_, timerKey) => { - const flightId = timerKey.split("-")[0]; + const flightId = timerKey.split('-')[0]; flightsWithPendingChanges.add(flightId); }); @@ -298,7 +328,9 @@ export default function PFATCFlights() { ?.flights.find((f) => f.id === flight.id); if (existingFlight?.updated_at && flight.updated_at) { - const existingTime = new Date(existingFlight.updated_at).getTime(); + const existingTime = new Date( + existingFlight.updated_at + ).getTime(); const newTime = new Date(flight.updated_at).getTime(); if (existingTime > newTime) { return existingFlight; @@ -318,15 +350,19 @@ export default function PFATCFlights() { flightsWithPendingChanges.has(flightId) ) { const existingFlight = prev.arrivalsByAirport[icao]?.find( - (f) => f.id === flight.id, + (f) => f.id === flight.id ); return existingFlight || flight; } - const existingFlight = prev.arrivalsByAirport[icao]?.find((f) => f.id === flight.id); + const existingFlight = prev.arrivalsByAirport[icao]?.find( + (f) => f.id === flight.id + ); if (existingFlight?.updated_at && flight.updated_at) { - const existingTime = new Date(existingFlight.updated_at).getTime(); + const existingTime = new Date( + existingFlight.updated_at + ).getTime(); const newTime = new Date(flight.updated_at).getTime(); if (existingTime > newTime) { return existingFlight; @@ -347,8 +383,8 @@ export default function PFATCFlights() { setError(null); }, (error) => { - console.error("Overview socket error:", error); - setError(error.error || "Failed to connect to overview data"); + console.error('Overview socket error:', error); + setError(error.error || 'Failed to connect to overview data'); setLoading(false); }, isEventController, @@ -362,7 +398,9 @@ export default function PFATCFlights() { if (s.sessionId === sessionId) { return { ...s, - flights: s.flights.map((f) => (f.id === flight.id ? flight : f)), + flights: s.flights.map((f) => + f.id === flight.id ? flight : f + ), }; } return s; @@ -372,15 +410,16 @@ export default function PFATCFlights() { if (flight.arrival) { const arrivalIcao = flight.arrival.toUpperCase(); if (updatedArrivalsByAirport[arrivalIcao]) { - updatedArrivalsByAirport[arrivalIcao] = updatedArrivalsByAirport[arrivalIcao].map( - (f) => - f.id === flight.id - ? { - ...flight, - sessionId, - departureAirport: flight.departure || "", - } - : f, + updatedArrivalsByAirport[arrivalIcao] = updatedArrivalsByAirport[ + arrivalIcao + ].map((f) => + f.id === flight.id + ? { + ...flight, + sessionId, + departureAirport: flight.departure || '', + } + : f ); } } @@ -410,7 +449,7 @@ export default function PFATCFlights() { }); }, (error) => { - console.error("Flight operation error:", error); + console.error('Flight operation error:', error); if (error.flightId) { setPendingUpdates((prev) => { const next = new Map(prev); @@ -432,7 +471,7 @@ export default function PFATCFlights() { // The socket will automatically send new data } } - }, + } ); overviewSocketRef.current = socket; @@ -451,8 +490,8 @@ export default function PFATCFlights() { const response = await fetch( `${import.meta.env.VITE_SERVER_URL}/api/flights/acars/active`, { - credentials: "include", - }, + credentials: 'include', + } ); if (response.ok) { @@ -468,7 +507,9 @@ export default function PFATCFlights() { fetchActiveAcars(); }, [isContactSidebarOpen]); - const sectorSocketRef = useRef | null>(null); + const sectorSocketRef = useRef | null>(null); useEffect(() => { if (!isEventController || !eventControllerViewEnabled || !user?.userId) { @@ -482,7 +523,7 @@ export default function PFATCFlights() { if (!sectorSocketRef.current) { sectorSocketRef.current = createSectorControllerSocket({ userId: user.userId, - username: user.username || "Unknown", + username: user.username || 'Unknown', avatar: user.avatar || null, }); } @@ -493,7 +534,13 @@ export default function PFATCFlights() { sectorSocketRef.current = null; } }; - }, [isEventController, eventControllerViewEnabled, user?.userId, user?.username, user?.avatar]); + }, [ + isEventController, + eventControllerViewEnabled, + user?.userId, + user?.username, + user?.avatar, + ]); useEffect(() => { if (!sectorSocketRef.current) return; @@ -506,8 +553,9 @@ export default function PFATCFlights() { }, [selectedStation]); const activeAirports = useMemo( - () => overviewData?.activeSessions.map((session) => session.airportIcao) || [], - [overviewData?.activeSessions], + () => + overviewData?.activeSessions.map((session) => session.airportIcao) || [], + [overviewData?.activeSessions] ); const allFlights: FlightWithDetails[] = useMemo(() => { @@ -534,7 +582,7 @@ export default function PFATCFlights() { flightsMap.set(f.id, { ...f, sessionId: f.session_id, - departureAirport: f.departure || "", + departureAirport: f.departure || '', }); } }); @@ -542,20 +590,31 @@ export default function PFATCFlights() { }, [allFlights, activeAcarsFlightsData]); const handleSendContact = useCallback( - async (flightId: string | number, message: string, station: string, position: string) => { + async ( + flightId: string | number, + message: string, + station: string, + position: string + ) => { const flight = allPossibleFlights.find((f) => f.id === flightId); if (!flight) { - throw new Error("Flight not found"); + throw new Error('Flight not found'); } if (!overviewSocketRef.current) { - throw new Error("Overview socket not available"); + throw new Error('Overview socket not available'); } //... //... - overviewSocketRef.current.sendContact(flight.sessionId, flightId, message, station, position); + overviewSocketRef.current.sendContact( + flight.sessionId, + flightId, + message, + station, + position + ); }, - [allPossibleFlights], + [allPossibleFlights] ); const [showHidden, setShowHidden] = useState(false); @@ -580,14 +639,16 @@ export default function PFATCFlights() { flight.arrival === selectedAirport; const matchesStatus = !selectedStatus || flight.status === selectedStatus; - const matchesFlightType = !selectedFlightType || flight.flight_type === selectedFlightType; + const matchesFlightType = + !selectedFlightType || flight.flight_type === selectedFlightType; const matchesNetworkSession = - networkSessionFilter === "all" || - (networkSessionFilter === "pfatc" && + networkSessionFilter === 'all' || + (networkSessionFilter === 'pfatc' && flight.sessionIsPFATC && !flight.sessionIsAdvancedATC) || - (networkSessionFilter === "advanced_atc" && Boolean(flight.sessionIsAdvancedATC)); + (networkSessionFilter === 'advanced_atc' && + Boolean(flight.sessionIsAdvancedATC)); return ( matchesSearch && @@ -610,7 +671,7 @@ export default function PFATCFlights() { const airportSessions = useMemo(() => { return ( overviewData?.activeSessions - .filter((session) => !session.sessionId.startsWith("sector-")) + .filter((session) => !session.sessionId.startsWith('sector-')) .reduce( (acc, session) => { if (!acc[session.airportIcao]) { @@ -619,104 +680,106 @@ export default function PFATCFlights() { acc[session.airportIcao].push(session); return acc; }, - {} as Record, + {} as Record ) || {} ); }, [overviewData?.activeSessions]); const statusOptions = [ - { label: "All Statuses", value: "" }, - { label: "PENDING", value: "PENDING" }, - { label: "STUP", value: "STUP" }, - { label: "PUSH", value: "PUSH" }, - { label: "TAXI (Departure)", value: "TAXI_ORIG" }, - { label: "RWY (Departure)", value: "RWY_ORIG" }, - { label: "DEPA", value: "DEPA" }, - { label: "ENROUTE", value: "ENROUTE" }, - { label: "APP", value: "APP" }, - { label: "RWY (Arrival)", value: "RWY_ARRV" }, - { label: "TAXI (Arrival)", value: "TAXI_ARRV" }, - { label: "GATE", value: "GATE" }, + { label: 'All Statuses', value: '' }, + { label: 'PENDING', value: 'PENDING' }, + { label: 'STUP', value: 'STUP' }, + { label: 'PUSH', value: 'PUSH' }, + { label: 'TAXI (Departure)', value: 'TAXI_ORIG' }, + { label: 'RWY (Departure)', value: 'RWY_ORIG' }, + { label: 'DEPA', value: 'DEPA' }, + { label: 'ENROUTE', value: 'ENROUTE' }, + { label: 'APP', value: 'APP' }, + { label: 'RWY (Arrival)', value: 'RWY_ARRV' }, + { label: 'TAXI (Arrival)', value: 'TAXI_ARRV' }, + { label: 'GATE', value: 'GATE' }, ]; const flightTypeOptions = [ - { label: "All Types", value: "" }, - { label: "IFR", value: "IFR" }, - { label: "VFR", value: "VFR" }, + { label: 'All Types', value: '' }, + { label: 'IFR', value: 'IFR' }, + { label: 'VFR', value: 'VFR' }, ]; const sectorStations = [ - { label: "Select Station", value: "", frequency: "" }, - { label: "LECB CTR", value: "LECB_CTR", frequency: "132.355" }, - { label: "GCCC R6 CTR", value: "GCCC_R6_CTR", frequency: "123.650" }, - { label: "EGTT CTR", value: "EGTT_CTR", frequency: "127.830" }, - { label: "EFIN D CTR", value: "EFIN_D_CTR", frequency: "121.300" }, - { label: "LCCC CTR", value: "LCCC_CTR", frequency: "128.600" }, - { label: "MDCS CTR", value: "MDCS_CTR", frequency: "124.300" }, + { label: 'Select Station', value: '', frequency: '' }, + { label: 'LECB CTR', value: 'LECB_CTR', frequency: '132.355' }, + { label: 'GCCC R6 CTR', value: 'GCCC_R6_CTR', frequency: '123.650' }, + { label: 'EGTT CTR', value: 'EGTT_CTR', frequency: '127.830' }, + { label: 'EFIN D CTR', value: 'EFIN_D_CTR', frequency: '121.300' }, + { label: 'LCCC CTR', value: 'LCCC_CTR', frequency: '128.600' }, + { label: 'MDCS CTR', value: 'MDCS_CTR', frequency: '124.300' }, ]; const getStatusClass = (status: string) => { switch (status) { - case "PENDING": - return "bg-yellow-500/20 text-yellow-400 border border-yellow-500/30"; - case "CLEARED": - return "bg-green-500/20 text-green-400 border border-green-500/30"; - case "TAXI": - return "bg-pink-500/20 text-pink-400 border border-pink-500/30"; - case "TAXI_ORIG": - return "bg-pink-500/20 text-pink-400 border border-pink-500/30"; - case "TAXI_ARRV": - return "bg-pink-500/20 text-pink-400 border border-pink-500/30"; - case "DEPARTED": - return "bg-purple-500/20 text-purple-400 border border-purple-500/30"; - case "STUP": - return "bg-cyan-500/20 text-cyan-400 border border-cyan-500/30"; - case "PUSH": - return "bg-blue-500/20 text-blue-400 border border-blue-500/30"; - case "RWY": - return "bg-red-500/20 text-red-400 border border-red-500/30"; - case "RWY_ORIG": - return "bg-red-500/20 text-red-400 border border-red-500/30"; - case "RWY_ARRV": - return "bg-orange-500/20 text-orange-400 border border-orange-500/30"; - case "DEPA": - return "bg-green-500/20 text-green-400 border border-green-500/30"; - case "ENROUTE": - return "bg-purple-500/20 text-purple-400 border border-purple-500/30"; - case "APP": - return "bg-indigo-500/20 text-indigo-400 border border-indigo-500/30"; - case "GATE": - return "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"; + case 'PENDING': + return 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30'; + case 'CLEARED': + return 'bg-green-500/20 text-green-400 border border-green-500/30'; + case 'TAXI': + return 'bg-pink-500/20 text-pink-400 border border-pink-500/30'; + case 'TAXI_ORIG': + return 'bg-pink-500/20 text-pink-400 border border-pink-500/30'; + case 'TAXI_ARRV': + return 'bg-pink-500/20 text-pink-400 border border-pink-500/30'; + case 'DEPARTED': + return 'bg-purple-500/20 text-purple-400 border border-purple-500/30'; + case 'STUP': + return 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30'; + case 'PUSH': + return 'bg-blue-500/20 text-blue-400 border border-blue-500/30'; + case 'RWY': + return 'bg-red-500/20 text-red-400 border border-red-500/30'; + case 'RWY_ORIG': + return 'bg-red-500/20 text-red-400 border border-red-500/30'; + case 'RWY_ARRV': + return 'bg-orange-500/20 text-orange-400 border border-orange-500/30'; + case 'DEPA': + return 'bg-green-500/20 text-green-400 border border-green-500/30'; + case 'ENROUTE': + return 'bg-purple-500/20 text-purple-400 border border-purple-500/30'; + case 'APP': + return 'bg-indigo-500/20 text-indigo-400 border border-indigo-500/30'; + case 'GATE': + return 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'; default: - return "bg-zinc-500/20 text-zinc-400 border border-zinc-500/30"; + return 'bg-zinc-500/20 text-zinc-400 border border-zinc-500/30'; } }; const getFlightTypeColor = (flightType: string) => { switch (flightType) { - case "IFR": - return "text-blue-400"; - case "VFR": - return "text-green-400"; + case 'IFR': + return 'text-blue-400'; + case 'VFR': + return 'text-green-400'; default: - return "text-zinc-400"; + return 'text-zinc-400'; } }; const getStatusBadge = (status: string) => { const statusClass = getStatusClass(status); const displayStatus = - status === "TAXI_ORIG" - ? "TAXI" - : status === "TAXI_ARRV" - ? "TAXI" - : status === "RWY_ORIG" - ? "RWY" - : status === "RWY_ARRV" - ? "RWY" + status === 'TAXI_ORIG' + ? 'TAXI' + : status === 'TAXI_ARRV' + ? 'TAXI' + : status === 'RWY_ORIG' + ? 'RWY' + : status === 'RWY_ARRV' + ? 'RWY' : status; return ( - + {displayStatus} ); @@ -740,17 +803,22 @@ export default function PFATCFlights() { }; const handleAutoSave = useCallback( - async (flightId: string | number, field: string, value: string, originalValue: string) => { + async ( + flightId: string | number, + field: string, + value: string, + originalValue: string + ) => { if (value === originalValue) return; const flight = allFlights.find((f) => f.id === flightId); if (!flight) { - console.error("Flight not found for editing"); + console.error('Flight not found for editing'); return; } if (!overviewSocketRef.current) { - console.error("Overview socket not available"); + console.error('Overview socket not available'); return; } @@ -759,16 +827,20 @@ export default function PFATCFlights() { try { const updates: Partial = { [field]: value }; - if (field === "departure" && flight.departure !== value) { - updates.sid = ""; + if (field === 'departure' && flight.departure !== value) { + updates.sid = ''; } - if (field === "arrival" && flight.arrival !== value) { - updates.star = ""; + if (field === 'arrival' && flight.arrival !== value) { + updates.star = ''; } - overviewSocketRef.current.updateFlight(flight.sessionId, flightId, updates); + overviewSocketRef.current.updateFlight( + flight.sessionId, + flightId, + updates + ); } catch (error) { - console.error("Failed to update flight:", error); + console.error('Failed to update flight:', error); setUpdatingFlights((prev) => { const next = new Set(prev); next.delete(String(flightId)); @@ -776,23 +848,33 @@ export default function PFATCFlights() { }); } }, - [allFlights], + [allFlights] ); const handleToggleHidden = (flight: Flight) => { - handleFieldChange(flight.id, "hidden", String(!flight.hidden), String(flight.hidden || false)); + handleFieldChange( + flight.id, + 'hidden', + String(!flight.hidden), + String(flight.hidden || false) + ); setOpenActionMenuId(null); }; const handleFieldChange = useCallback( - (flightId: string | number, field: string, value: string, originalValue: string) => { + ( + flightId: string | number, + field: string, + value: string, + originalValue: string + ) => { if (!isEventController) { return; } let processedValue: string | boolean = value; - if (field === "clearance" || field === "hidden") { - processedValue = value === "true"; + if (field === 'clearance' || field === 'hidden') { + processedValue = value === 'true'; } setOverviewData((prev) => { @@ -801,14 +883,14 @@ export default function PFATCFlights() { const updatedSessions = prev.activeSessions.map((s) => ({ ...s, flights: s.flights.map((f) => - f.id === flightId ? { ...f, [field]: processedValue } : f, + f.id === flightId ? { ...f, [field]: processedValue } : f ), })); const updatedArrivalsByAirport = { ...prev.arrivalsByAirport }; Object.keys(updatedArrivalsByAirport).forEach((icao) => { - updatedArrivalsByAirport[icao] = updatedArrivalsByAirport[icao].map((f) => - f.id === flightId ? { ...f, [field]: processedValue } : f, + updatedArrivalsByAirport[icao] = updatedArrivalsByAirport[icao].map( + (f) => (f.id === flightId ? { ...f, [field]: processedValue } : f) ); }); @@ -840,25 +922,35 @@ export default function PFATCFlights() { return next; }); }, - [debounceTimers, handleAutoSave, isEventController, setOverviewData], + [debounceTimers, handleAutoSave, isEventController, setOverviewData] ); - const getCurrentValue = useCallback((flight: FlightWithDetails, field: string) => { - return String(flight[field as keyof FlightWithDetails] || ""); - }, []); + const getCurrentValue = useCallback( + (flight: FlightWithDetails, field: string) => { + return String(flight[field as keyof FlightWithDetails] || ''); + }, + [] + ); const renderEditableCell = ( flight: FlightWithDetails, field: string, - cellType: "text" | "status" | "airport" | "aircraft" | "altitude" | "sid" | "star" = "text", + cellType: + | 'text' + | 'status' + | 'airport' + | 'aircraft' + | 'altitude' + | 'sid' + | 'star' = 'text' ) => { const getMaxLength = (fieldName: string) => { switch (fieldName) { - case "callsign": + case 'callsign': return 16; - case "remark": + case 'remark': return 500; - case "squawk": + case 'squawk': return 4; default: return 50; @@ -867,19 +959,25 @@ export default function PFATCFlights() { const isUpdating = updatingFlights.has(String(flight.id)); const currentValue = getCurrentValue(flight, field); - const originalValue = String(flight[field as keyof FlightWithDetails] || ""); + const originalValue = String( + flight[field as keyof FlightWithDetails] || '' + ); const canEdit = canEditFlight(flight); const isDisabled = !canEdit || !selectedStation; if (!canEdit) { - return {currentValue || "N/A"}; + return ( + {currentValue || 'N/A'} + ); } - if (cellType === "status") { + if (cellType === 'status') { return ( handleFieldChange(flight.id, field, value, originalValue)} + onChange={(value) => + handleFieldChange(flight.id, field, value, originalValue) + } size="xs" placeholder="-" controllerType="event" @@ -888,11 +986,13 @@ export default function PFATCFlights() { ); } - if (cellType === "airport") { + if (cellType === 'airport') { return ( handleFieldChange(flight.id, field, value, originalValue)} + onChange={(value) => + handleFieldChange(flight.id, field, value, originalValue) + } size="xs" showFullName={false} className="w-full" @@ -901,11 +1001,13 @@ export default function PFATCFlights() { ); } - if (cellType === "aircraft") { + if (cellType === 'aircraft') { return ( handleFieldChange(flight.id, field, value, originalValue)} + onChange={(value) => + handleFieldChange(flight.id, field, value, originalValue) + } size="xs" showFullName={false} disabled={isDisabled} @@ -913,11 +1015,13 @@ export default function PFATCFlights() { ); } - if (cellType === "altitude") { + if (cellType === 'altitude') { return ( handleFieldChange(flight.id, field, value, originalValue)} + onChange={(value) => + handleFieldChange(flight.id, field, value, originalValue) + } size="xs" placeholder="-" disabled={isDisabled} @@ -925,12 +1029,14 @@ export default function PFATCFlights() { ); } - if (cellType === "sid") { + if (cellType === 'sid') { return ( handleFieldChange(flight.id, field, value, originalValue)} + onChange={(value) => + handleFieldChange(flight.id, field, value, originalValue) + } size="xs" placeholder="-" disabled={isDisabled} @@ -938,12 +1044,14 @@ export default function PFATCFlights() { ); } - if (cellType === "star") { + if (cellType === 'star') { return ( handleFieldChange(flight.id, field, value, originalValue)} + onChange={(value) => + handleFieldChange(flight.id, field, value, originalValue) + } size="xs" placeholder="-" disabled={isDisabled} @@ -951,21 +1059,25 @@ export default function PFATCFlights() { ); } - if (cellType === "text" && field === "callsign") { + if (cellType === 'text' && field === 'callsign') { return (
handleFieldChange(flight.id, field, value, originalValue)} + onChange={(value) => + handleFieldChange(flight.id, field, value, originalValue) + } className={`bg-zinc-900 border border-zinc-900 rounded px-2 py-1 text-white text-xs w-full ${ - isDisabled ? "opacity-50 cursor-not-allowed" : "" - } ${isUpdating ? "border-blue-500" : ""}`} + isDisabled ? 'opacity-50 cursor-not-allowed' : '' + } ${isUpdating ? 'border-blue-500' : ''}`} maxLength={getMaxLength(field)} - placeholder={currentValue ? "" : "N/A"} + placeholder={currentValue ? '' : 'N/A'} disabled={isDisabled} />
@@ -975,12 +1087,14 @@ export default function PFATCFlights() { return ( handleFieldChange(flight.id, field, value, originalValue)} + onChange={(value) => + handleFieldChange(flight.id, field, value, originalValue) + } className={`bg-zinc-900 border border-zinc-900 rounded px-2 py-1 text-white text-xs w-full ${ - isDisabled ? "opacity-50 cursor-not-allowed" : "" - } ${isUpdating ? "border-blue-500" : ""}`} + isDisabled ? 'opacity-50 cursor-not-allowed' : '' + } ${isUpdating ? 'border-blue-500' : ''}`} maxLength={getMaxLength(field)} - placeholder={currentValue ? "" : "N/A"} + placeholder={currentValue ? '' : 'N/A'} disabled={isDisabled} /> ); @@ -1007,14 +1121,17 @@ export default function PFATCFlights() { return timestampB - timestampA; } - const callsignA = (a.callsign || "").toLowerCase(); - const callsignB = (b.callsign || "").toLowerCase(); + const callsignA = (a.callsign || '').toLowerCase(); + const callsignB = (b.callsign || '').toLowerCase(); return callsignA.localeCompare(callsignB); }); const flightForModal = useMemo(() => { if (!selectedFlightForModal) return null; - return allFlights.find((f) => f.id === selectedFlightForModal.id) || selectedFlightForModal; + return ( + allFlights.find((f) => f.id === selectedFlightForModal.id) || + selectedFlightForModal + ); }, [allFlights, selectedFlightForModal]); if (loading) { @@ -1024,16 +1141,20 @@ export default function PFATCFlights() { {/* Hero with user's banner */}
- Banner + Banner
@@ -1056,14 +1177,29 @@ export default function PFATCFlights() { {/* Header row */}
{Array.from({ length: 8 }).map((_, i) => ( -
+
))}
{/* Body rows */} {Array.from({ length: 8 }).map((_, i) => ( -
+
{Array.from({ length: 8 }).map((_, j) => ( -
+
))}
))} @@ -1073,7 +1209,10 @@ export default function PFATCFlights() {
{Array.from({ length: 3 }).map((_, i) => ( -
+
@@ -1087,7 +1226,10 @@ export default function PFATCFlights() {
{Array.from({ length: 3 }).map((_, j) => ( -
+
@@ -1134,11 +1276,11 @@ export default function PFATCFlights() { className="absolute inset-0" style={{ backgroundImage, - backgroundSize: "cover", - backgroundPosition: "center", - backgroundRepeat: "no-repeat", + backgroundSize: 'cover', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', opacity: customLoaded ? 1 : 0, - transition: "opacity 0.5s ease-in-out", + transition: 'opacity 0.5s ease-in-out', }} />
@@ -1155,14 +1297,14 @@ export default function PFATCFlights() { {overviewData?.totalActiveSessions || 0} ACTIVE SESSION - {(overviewData?.totalActiveSessions || 0) === 1 ? "" : "S"} + {(overviewData?.totalActiveSessions || 0) === 1 ? '' : 'S'}
{overviewData?.totalFlights || 0} FLIGHT - {(overviewData?.totalFlights || 0) === 1 ? "" : "S"} + {(overviewData?.totalFlights || 0) === 1 ? '' : 'S'}
{isPFATCSectorController && ( @@ -1204,7 +1346,10 @@ export default function PFATCFlights() { {selectedStation && (
- +
)} @@ -1222,7 +1367,9 @@ export default function PFATCFlights() { disabled={!selectedStation} > - Contact + + Contact +
)} @@ -1338,16 +1493,36 @@ export default function PFATCFlights() { - - - - - - - - - - + + + + + + + + + + @@ -1361,10 +1536,16 @@ export default function PFATCFlights() { {sortedFlights.length === 0 ? ( - ) : ( @@ -1376,26 +1557,36 @@ export default function PFATCFlights() { @@ -1495,14 +1698,18 @@ export default function PFATCFlights() { actionButtonRefs.current[flight.id] = el; } }} - className={`flex items-center justify-center text-gray-400 hover:text-white transition-colors ${!canEditFlight(flight) || !selectedStation ? "opacity-50 cursor-not-allowed" : ""}`} + className={`flex items-center justify-center text-gray-400 hover:text-white transition-colors ${!canEditFlight(flight) || !selectedStation ? 'opacity-50 cursor-not-allowed' : ''}`} onClick={() => { setOpenActionMenuId( - openActionMenuId === flight.id ? null : flight.id, + openActionMenuId === flight.id + ? null + : flight.id ); }} title="Actions" - disabled={!canEditFlight(flight) || !selectedStation} + disabled={ + !canEditFlight(flight) || !selectedStation + } > @@ -1512,27 +1719,37 @@ export default function PFATCFlights() {
setOpenActionMenuId(null)} + onClick={() => + setOpenActionMenuId(null) + } />
{ - const btn = actionButtonRefs.current[flight.id]; + const btn = + actionButtonRefs.current[ + flight.id + ]; if (btn) { - const rect = btn.getBoundingClientRect(); + const rect = + btn.getBoundingClientRect(); return `${rect.bottom + 4}px`; } - return "0px"; + return '0px'; })(), left: (() => { - const btn = actionButtonRefs.current[flight.id]; + const btn = + actionButtonRefs.current[ + flight.id + ]; if (btn) { - const rect = btn.getBoundingClientRect(); + const rect = + btn.getBoundingClientRect(); return `${rect.right - 176}px`; } - return "0px"; + return '0px'; })(), }} > @@ -1540,27 +1757,37 @@ export default function PFATCFlights() {
, - document.body, + document.body )} )} @@ -1578,172 +1805,189 @@ export default function PFATCFlights() { Active Airports
- {Object.entries(airportSessions).map(([icao, sessions], index) => { - const totalFlights = sessions.reduce( - (sum, session) => sum + session.flightCount, - 0, - ); - const totalUsers = sessions.reduce( - (sum, session) => sum + session.activeUsers, - 0, - ); - const isExpanded = expandedAirports.has(icao); - const arrivals = overviewData?.arrivalsByAirport[icao] || []; - const departureFlights = sessions.flatMap((session) => session.flights); - - return ( -
-
-
-
-
- -
-
-

{icao}

-
-
- Active + {Object.entries(airportSessions).map( + ([icao, sessions], index) => { + const totalFlights = sessions.reduce( + (sum, session) => sum + session.flightCount, + 0 + ); + const totalUsers = sessions.reduce( + (sum, session) => sum + session.activeUsers, + 0 + ); + const isExpanded = expandedAirports.has(icao); + const arrivals = + overviewData?.arrivalsByAirport[icao] || []; + const departureFlights = sessions.flatMap( + (session) => session.flights + ); + + return ( +
+
+
+
+
+ +
+
+

+ {icao} +

+
+
+ Active +
+ {(arrivals.length > 0 || + departureFlights.length > 0) && ( + + )}
- {(arrivals.length > 0 || departureFlights.length > 0) && ( - - )} -
-
- -
- +
+ +
+ +
-
-
-
-
{totalUsers}
-
Controllers
-
-
-
- {totalFlights + arrivals.length} +
+
+
+ {totalUsers} +
+
+ Controllers +
-
Flights
-
- {sessions[0]?.activeRunway && (
-
- {sessions[0].activeRunway} +
+ {totalFlights + arrivals.length} +
+
+ Flights
-
Runway
- )} + {sessions[0]?.activeRunway && ( +
+
+ {sessions[0].activeRunway} +
+
+ Runway +
+
+ )} +
-
- {isExpanded && ( -
-
- {departureFlights.length > 0 && ( -
-

- - Departures ({departureFlights.length}) -

-
- {departureFlights.map((flight) => ( -
-
-
- {flight.callsign || "N/A"} -
-
- {flight.aircraft || "N/A"} + {isExpanded && ( +
+
+ {departureFlights.length > 0 && ( +
+

+ + Departures ({departureFlights.length}) +

+
+ {departureFlights.map((flight) => ( +
+
+
+ {flight.callsign || 'N/A'} +
+
+ {flight.aircraft || 'N/A'} +
+
+ → {flight.arrival || 'N/A'} +
-
- → {flight.arrival || "N/A"} +
+ + {flight.status || 'PENDING'} +
-
- - {flight.status || "PENDING"} - -
-
- ))} + ))} +
-
- )} + )} - {arrivals.length > 0 && ( -
-

- - Arrivals ({arrivals.length}) -

-
- {arrivals.map((flight) => ( -
-
-
- {flight.callsign || "N/A"} -
-
- {flight.aircraft || "N/A"} + {arrivals.length > 0 && ( +
+

+ + Arrivals ({arrivals.length}) +

+
+ {arrivals.map((flight) => ( +
+
+
+ {flight.callsign || 'N/A'} +
+
+ {flight.aircraft || 'N/A'} +
+
+ {flight.departureAirport} → +
-
- {flight.departureAirport} → +
+ + {flight.status || 'ENROUTE'} +
-
- - {flight.status || "ENROUTE"} - -
-
- ))} + ))} +
-
- )} + )} - {departureFlights.length === 0 && arrivals.length === 0 && ( -
- No active flights at this airport -
- )} + {departureFlights.length === 0 && + arrivals.length === 0 && ( +
+ No active flights at this airport +
+ )} +
-
- )} -
- ); - })} + )} +
+ ); + } + )}
@@ -1791,7 +2035,9 @@ export default function PFATCFlights() { onSendContact={handleSendContact} activeAcarsFlights={activeAcarsFlights} airportIcao={selectedStation} - fallbackFrequency={sectorStations.find((s) => s.value === selectedStation)?.frequency} + fallbackFrequency={ + sectorStations.find((s) => s.value === selectedStation)?.frequency + } />
); -} \ No newline at end of file +} diff --git a/src/pages/PilotProfile.tsx b/src/pages/PilotProfile.tsx index 5ac7f2c2..a8173948 100644 --- a/src/pages/PilotProfile.tsx +++ b/src/pages/PilotProfile.tsx @@ -885,4 +885,4 @@ function FeaturedFlightCard({ flight }: { flight: FeaturedFlight }) { )} ); -} \ No newline at end of file +} diff --git a/src/pages/PublicFlightView.tsx b/src/pages/PublicFlightView.tsx index d0f4ed56..ae0e4dbc 100644 --- a/src/pages/PublicFlightView.tsx +++ b/src/pages/PublicFlightView.tsx @@ -431,7 +431,10 @@ export default function PublicFlightView({
{flight.route && ( -
+
); -} \ No newline at end of file +} diff --git a/src/pages/Sessions.tsx b/src/pages/Sessions.tsx index 189c2671..c8c0f31b 100644 --- a/src/pages/Sessions.tsx +++ b/src/pages/Sessions.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState, useMemo } from "react"; -import { Link } from "react-router-dom"; -import Navbar from "../components/Navbar"; +import { useEffect, useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import Navbar from '../components/Navbar'; import { Workflow, Calendar, @@ -12,14 +12,18 @@ import { X, PlaneTakeoff, FolderOpen, -} from "lucide-react"; -import { useAuth } from "../hooks/auth/useAuth"; -import { useSettings } from "../hooks/settings/useSettings"; -import { fetchMySessions, updateSessionName, deleteSession } from "../utils/fetch/sessions"; -import type { SessionInfo } from "../types/session"; -import { fetchBackgrounds } from "../utils/fetch/data"; -import Button from "../components/common/Button"; -import TextInput from "../components/common/TextInput"; +} from 'lucide-react'; +import { useAuth } from '../hooks/auth/useAuth'; +import { useSettings } from '../hooks/settings/useSettings'; +import { + fetchMySessions, + updateSessionName, + deleteSession, +} from '../utils/fetch/sessions'; +import type { SessionInfo } from '../types/session'; +import { fetchBackgrounds } from '../utils/fetch/data'; +import Button from '../components/common/Button'; +import TextInput from '../components/common/TextInput'; const API_BASE_URL = import.meta.env.VITE_SERVER_URL; @@ -34,9 +38,9 @@ export default function Sessions() { const { settings } = useSettings(); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); + const [error, setError] = useState(''); const [editingName, setEditingName] = useState(null); - const [editNameValue, setEditNameValue] = useState(""); + const [editNameValue, setEditNameValue] = useState(''); const [savingName, setSavingName] = useState(null); const [sessionToDelete, setSessionToDelete] = useState(null); const [deleteInProgress, setDeleteInProgress] = useState(null); @@ -54,7 +58,7 @@ export default function Sessions() { .then((data) => { setSessions(data); }) - .catch(() => setError("Failed to load sessions.")) + .catch(() => setError('Failed to load sessions.')) .finally(() => setLoading(false)); }, [user]); @@ -64,7 +68,7 @@ export default function Sessions() { const data = await fetchBackgrounds(); setAvailableImages(data); } catch (error) { - console.error("Error loading available images:", error); + console.error('Error loading available images:', error); } }; loadImages(); @@ -75,32 +79,37 @@ export default function Sessions() { let bgImage = 'url("/assets/images/hero.webp")'; const getImageUrl = (filename: string | null): string | null => { - if (!filename || filename === "random" || filename === "favorites") { + if (!filename || filename === 'random' || filename === 'favorites') { return filename; } - if (filename.startsWith("https://api.cephie.app/")) { + if (filename.startsWith('https://api.cephie.app/')) { return filename; } return `${API_BASE_URL}/assets/app/backgrounds/${filename}`; }; - if (selectedImage === "random") { + if (selectedImage === 'random') { if (availableImages.length > 0) { const randomIndex = Math.floor(Math.random() * availableImages.length); bgImage = `url(${API_BASE_URL}${availableImages[randomIndex].path})`; } - } else if (selectedImage === "favorites") { + } else if (selectedImage === 'favorites') { const favorites = settings?.backgroundImage?.favorites || []; if (favorites.length > 0) { - const randomFav = favorites[Math.floor(Math.random() * favorites.length)]; + const randomFav = + favorites[Math.floor(Math.random() * favorites.length)]; const favImageUrl = getImageUrl(randomFav); - if (favImageUrl && favImageUrl !== "random" && favImageUrl !== "favorites") { + if ( + favImageUrl && + favImageUrl !== 'random' && + favImageUrl !== 'favorites' + ) { bgImage = `url(${favImageUrl})`; } } } else if (selectedImage) { const imageUrl = getImageUrl(selectedImage); - if (imageUrl && imageUrl !== "random" && imageUrl !== "favorites") { + if (imageUrl && imageUrl !== 'random' && imageUrl !== 'favorites') { bgImage = `url(${imageUrl})`; } } @@ -120,25 +129,28 @@ export default function Sessions() { const startEditingName = (sessionId: string, currentName: string) => { setEditingName(sessionId); - setEditNameValue(currentName || ""); + setEditNameValue(currentName || ''); }; const saveSessionName = async (sessionId: string) => { if (!editNameValue.trim()) { setEditingName(null); - setEditNameValue(""); + setEditNameValue(''); return; } setSavingName(sessionId); try { - const { customName } = await updateSessionName(sessionId, editNameValue.trim()); + const { customName } = await updateSessionName( + sessionId, + editNameValue.trim() + ); setSessions((prev) => - prev.map((s) => (s.sessionId === sessionId ? { ...s, customName } : s)), + prev.map((s) => (s.sessionId === sessionId ? { ...s, customName } : s)) ); setEditingName(null); - setEditNameValue(""); + setEditNameValue(''); } catch { - setError("Failed to update session name."); + setError('Failed to update session name.'); } finally { setSavingName(null); } @@ -153,10 +165,12 @@ export default function Sessions() { setDeleteInProgress(sessionToDelete); try { await deleteSession(sessionToDelete); - setSessions((prev) => prev.filter((s) => s.sessionId !== sessionToDelete)); + setSessions((prev) => + prev.filter((s) => s.sessionId !== sessionToDelete) + ); setSessionToDelete(null); } catch { - setError("Failed to delete session."); + setError('Failed to delete session.'); } finally { setDeleteInProgress(null); } @@ -168,16 +182,20 @@ export default function Sessions() {
- Banner + Banner
@@ -196,7 +214,10 @@ export default function Sessions() {
{Array.from({ length: 6 }).map((_, i) => ( -
+
@@ -231,7 +252,9 @@ export default function Sessions() {

Not logged in

-

Please log in to view your sessions.

+

+ Please log in to view your sessions. +

@@ -278,17 +301,17 @@ export default function Sessions() { {sessions.length}/{maxSessions} SESSION - {sessions.length === 1 ? "" : "S"} + {sessions.length === 1 ? '' : 'S'}

No sessions yet

-

You haven't created any sessions yet.

+

+ You haven't created any sessions yet. +

{session.customName ? session.customName - : `${session.airportIcao || "Unknown"} Session`} + : `${session.airportIcao || 'Unknown'} Session`} - {session.isLegacy && } + {session.isLegacy && ( + + )}
{session.createdAt ? new Date(session.createdAt).toLocaleString() - : "Date unavailable"} + : 'Date unavailable'}
{session.activeRunway && (
@@ -378,12 +409,16 @@ export default function Sessions() { ) : session.isPFATC ? ( <> - PFATC Session + + PFATC Session + ) : ( <> - Standard Session + + Standard Session + )}
@@ -398,7 +433,10 @@ export default function Sessions() { size="sm" variant="outline" onClick={() => - startEditingName(session.sessionId, session.customName || "") + startEditingName( + session.sessionId, + session.customName || '' + ) } > @@ -440,31 +478,39 @@ export default function Sessions() {
- {sessions.find((s) => s.sessionId === sessionToDelete)?.isLegacy && ( + {sessions.find((s) => s.sessionId === sessionToDelete) + ?.isLegacy && (
- Legacy Session + + Legacy Session +

- This session uses old encryption. Deleting it is recommended for security. + This session uses old encryption. Deleting it is recommended + for security.

)}

- Are you sure you want to delete this session? This action cannot be undone. + Are you sure you want to delete this session? This action cannot + be undone.

- Session:{" "} + Session:{' '} - {sessions.find((s) => s.sessionId === sessionToDelete)?.customName || + {sessions.find((s) => s.sessionId === sessionToDelete) + ?.customName || `${ - sessions.find((s) => s.sessionId === sessionToDelete)?.airportIcao || - "Unknown" + sessions.find((s) => s.sessionId === sessionToDelete) + ?.airportIcao || 'Unknown' } Session`}

-

ID: {sessionToDelete}

+

+ ID: {sessionToDelete} +

@@ -486,7 +532,7 @@ export default function Sessions() { Deleting... ) : ( - "Delete Session" + 'Delete Session' )}
@@ -513,7 +559,8 @@ export default function Sessions() {

- Change the name for this session. This helps you identify it more easily. + Change the name for this session. This helps you identify it + more easily.

{ - if (e.key === "Enter") saveSessionName(editingName); - if (e.key === "Escape") setEditingName(null); + if (e.key === 'Enter') saveSessionName(editingName); + if (e.key === 'Escape') setEditingName(null); }} />
@@ -546,7 +593,7 @@ export default function Sessions() { Saving... ) : ( - "Save Name" + 'Save Name' )}
@@ -555,4 +602,4 @@ export default function Sessions() { )}
); -} \ No newline at end of file +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 6a2d8639..1998e9ba 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -5,12 +5,7 @@ import { useSearchParams, useNavigate, } from 'react-router-dom'; -import { - Save, - AlertTriangle, - Check, - RotateCcw, -} from 'lucide-react'; +import { Save, AlertTriangle, Check, RotateCcw } from 'lucide-react'; import type { Settings, DepartureTableColumnSettings, diff --git a/src/pages/Submit.tsx b/src/pages/Submit.tsx index e41209c8..dd30fa41 100644 --- a/src/pages/Submit.tsx +++ b/src/pages/Submit.tsx @@ -20,24 +20,24 @@ import { Plane, HelpCircle, TowerControl, -} from "lucide-react"; -import { createFlightsSocket } from "../sockets/flightsSocket"; -import { addFlight } from "../utils/fetch/flights"; -import { useAuth } from "../hooks/auth/useAuth"; -import { useSettings } from "../hooks/settings/useSettings"; -import { fetchBackgrounds, fetchRoute } from "../utils/fetch/data"; -import type { Flight } from "../types/flight"; -import AirportDropdown from "../components/dropdowns/AirportDropdown"; -import Dropdown from "../components/common/Dropdown"; -import AircraftDropdown from "../components/dropdowns/AircraftDropdown"; -import Loader from "../components/common/Loader"; -import AccessDenied from "../components/AccessDenied"; -import CallsignInput from "../components/common/CallsignInput"; -import ControllerRatingPopup from "../components/tools/ControllerRatingPopup"; -import Modal from "../components/common/Modal"; -import { getDiscordLoginUrl } from "../utils/fetch/auth"; -import { hasAdvancedNetworkFeatures } from "../utils/sessionKind"; -import RouteMap from "../components/map/RouteMap"; +} from 'lucide-react'; +import { createFlightsSocket } from '../sockets/flightsSocket'; +import { addFlight } from '../utils/fetch/flights'; +import { useAuth } from '../hooks/auth/useAuth'; +import { useSettings } from '../hooks/settings/useSettings'; +import { fetchBackgrounds, fetchRoute } from '../utils/fetch/data'; +import type { Flight } from '../types/flight'; +import AirportDropdown from '../components/dropdowns/AirportDropdown'; +import Dropdown from '../components/common/Dropdown'; +import AircraftDropdown from '../components/dropdowns/AircraftDropdown'; +import Loader from '../components/common/Loader'; +import AccessDenied from '../components/AccessDenied'; +import CallsignInput from '../components/common/CallsignInput'; +import ControllerRatingPopup from '../components/tools/ControllerRatingPopup'; +import Modal from '../components/common/Modal'; +import { getDiscordLoginUrl } from '../utils/fetch/auth'; +import { hasAdvancedNetworkFeatures } from '../utils/sessionKind'; +import RouteMap from '../components/map/RouteMap'; const API_BASE_URL = import.meta.env.VITE_SERVER_URL; @@ -65,7 +65,10 @@ interface SubmitProps { initialAirportIcao?: string; } -export default function Submit({ standalone = true, initialAirportIcao }: SubmitProps) { +export default function Submit({ + standalone = true, + initialAirportIcao, +}: SubmitProps) { const { sessionId } = useParams<{ sessionId: string }>(); const [searchParams] = useSearchParams(); const accessId = searchParams.get('accessId') ?? undefined; @@ -105,11 +108,15 @@ export default function Submit({ standalone = true, initialAirportIcao }: Submit const [isSubmitting, setIsSubmitting] = useState(false); const [showRating, setShowRating] = useState(false); const [isGeneratingRoute, setIsGeneratingRoute] = useState(false); - const [routeFlParity, setRouteFlParity] = useState<'ODD' | 'EVEN' | null>(null); + const [routeFlParity, setRouteFlParity] = useState<'ODD' | 'EVEN' | null>( + null + ); const [routeSid, setRouteSid] = useState(); const [routeStar, setRouteStar] = useState(); const [showAccountPrompt, setShowAccountPrompt] = useState(false); - const [flightsSocket, setFlightsSocket] = useState | null>(null); + const [flightsSocket, setFlightsSocket] = useState | null>(null); const [initialLoadComplete, setInitialLoadComplete] = useState(false); useEffect(() => { @@ -281,7 +288,9 @@ export default function Submit({ standalone = true, initialAirportIcao }: Submit const needsRadarVectors = (arrival: string, flightType: string) => flightType === 'VFR' || - (!!arrival && !!form.departure && arrival.toUpperCase() === form.departure.toUpperCase()); + (!!arrival && + !!form.departure && + arrival.toUpperCase() === form.departure.toUpperCase()); const handleArrivalChange = (value: string) => { setForm((f) => ({ ...f, arrival: value })); @@ -780,9 +789,9 @@ export default function Submit({ standalone = true, initialAirportIcao }: Submit name="route" value={form.route} onChange={(e) => { - handleChange("route")(e.target.value) - setRouteSid(undefined) - setRouteStar(undefined) + handleChange('route')(e.target.value); + setRouteSid(undefined); + setRouteStar(undefined); }} placeholder="e.g. HAZEL NOVMA LEDGO" className="flex items-center w-full pl-6 pr-28 p-3 bg-gray-800 border-2 border-blue-600 rounded-full text-white font-semibold focus:outline-none focus:ring-2 focus:ring-blue-600 transition-all" @@ -804,7 +813,10 @@ export default function Submit({ standalone = true, initialAirportIcao }: Submit
{form.route.trim() && ( -
+
); -} \ No newline at end of file +} diff --git a/src/pages/admin/AdminAltDetection.tsx b/src/pages/admin/AdminAltDetection.tsx index 4cc61146..1b871f7d 100644 --- a/src/pages/admin/AdminAltDetection.tsx +++ b/src/pages/admin/AdminAltDetection.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect } from "react"; -import type { IconType } from "react-icons"; -import { Link } from "react-router-dom"; +import { useState, useEffect } from 'react'; +import type { IconType } from 'react-icons'; +import { Link } from 'react-router-dom'; import { MdCallMerge, MdFingerprint, @@ -14,20 +14,20 @@ import { MdVisibility, MdVisibilityOff, MdUnfoldMore, -} from "react-icons/md"; +} from 'react-icons/md'; import { fetchAltClusters, revealUserIP, type AltCluster, type AltClustersResponse, type ClusterMember, -} from "../../utils/fetch/admin"; -import AdminRefreshButton from "../../components/admin/AdminRefreshButton"; -import AdminLayout from "../../components/admin/AdminLayout"; -import AdminPageHeader from "../../components/admin/AdminPageHeader"; -import AdminToolbar from "../../components/admin/AdminToolbar"; -import AdminSearchInput from "../../components/admin/AdminSearchInput"; -import AdminStatStrip from "../../components/admin/AdminStatStrip"; +} from '../../utils/fetch/admin'; +import AdminRefreshButton from '../../components/admin/AdminRefreshButton'; +import AdminLayout from '../../components/admin/AdminLayout'; +import AdminPageHeader from '../../components/admin/AdminPageHeader'; +import AdminToolbar from '../../components/admin/AdminToolbar'; +import AdminSearchInput from '../../components/admin/AdminSearchInput'; +import AdminStatStrip from '../../components/admin/AdminStatStrip'; import { adminDownsizeButtonSize, adminSectionClass, @@ -37,31 +37,31 @@ import { ADMIN_TOOLBAR_MOBILE_PAIR, ADMIN_TOOLBAR_MOBILE_SEARCH, ADMIN_TOOLBAR_MOBILE_SPLIT_ITEM, -} from "../../components/admin/adminConstants"; -import Loader from "../../components/common/Loader"; -import ErrorScreen from "../../components/common/ErrorScreen"; -import Dropdown from "../../components/common/Dropdown"; -import Button from "../../components/common/Button"; -import type { DropdownOption } from "../../types/dropdown"; +} from '../../components/admin/adminConstants'; +import Loader from '../../components/common/Loader'; +import ErrorScreen from '../../components/common/ErrorScreen'; +import Dropdown from '../../components/common/Dropdown'; +import Button from '../../components/common/Button'; +import type { DropdownOption } from '../../types/dropdown'; function ScoreBadge({ score, label, }: { score: number; - label: AltCluster["score_label"]; + label: AltCluster['score_label']; }) { - const colors: Record = { - low: "bg-zinc-700 text-zinc-300 border-zinc-600", - medium: "bg-yellow-900/40 text-yellow-400 border-yellow-700/50", - high: "bg-orange-900/40 text-orange-400 border-orange-700/50", - critical: "bg-red-900/40 text-red-400 border-red-600/50", + const colors: Record = { + low: 'bg-zinc-700 text-zinc-300 border-zinc-600', + medium: 'bg-yellow-900/40 text-yellow-400 border-yellow-700/50', + high: 'bg-orange-900/40 text-orange-400 border-orange-700/50', + critical: 'bg-red-900/40 text-red-400 border-red-600/50', }; - const dot: Record = { - low: "bg-zinc-400", - medium: "bg-yellow-400", - high: "bg-orange-400", - critical: "bg-red-400", + const dot: Record = { + low: 'bg-zinc-400', + medium: 'bg-yellow-400', + high: 'bg-orange-400', + critical: 'bg-red-400', }; return ( @@ -157,7 +157,7 @@ function MemberRow({
{member.username} - {member.discriminator && member.discriminator !== "0" && ( + {member.discriminator && member.discriminator !== '0' && ( #{member.discriminator} )} @@ -183,7 +183,7 @@ function MemberRow({
{ipDisplay} @@ -250,10 +250,10 @@ function ClusterCard({ }) { const displayMembers = cluster.members.slice(0, 10); const overflow = cluster.members.length - displayMembers.length; - const btnSize = adminDownsizeButtonSize("xs"); + const btnSize = adminDownsizeButtonSize('xs'); return ( -
+
{filtered.length === 0 ? (
@@ -512,8 +512,8 @@ export default function AdminAltDetection() {

No clusters found

{clusters.length > 0 - ? "Try adjusting the filters above" - : "No accounts share signals yet — run the backfill script to populate ip_hash for existing users"} + ? 'Try adjusting the filters above' + : 'No accounts share signals yet — run the backfill script to populate ip_hash for existing users'}

) : ( @@ -541,4 +541,4 @@ export default function AdminAltDetection() { )} ); -} \ No newline at end of file +} diff --git a/src/pages/admin/AdminApiLogs.tsx b/src/pages/admin/AdminApiLogs.tsx index 5aac3f8c..1e6b2bfd 100644 --- a/src/pages/admin/AdminApiLogs.tsx +++ b/src/pages/admin/AdminApiLogs.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from 'react'; import { MdMonitorHeart, MdVisibility, @@ -6,15 +6,15 @@ import { MdPerson, MdLink, MdCalendarToday, -} from "react-icons/md"; -import AdminLayout from "../../components/admin/AdminLayout"; -import AdminModal from "../../components/admin/AdminModal"; -import AdminPageHeader from "../../components/admin/AdminPageHeader"; -import AdminToolbar from "../../components/admin/AdminToolbar"; -import AdminSearchInput from "../../components/admin/AdminSearchInput"; -import AdminIconInput from "../../components/admin/AdminIconInput"; -import AdminStatStrip from "../../components/admin/AdminStatStrip"; -import AdminTable from "../../components/admin/AdminTable"; +} from 'react-icons/md'; +import AdminLayout from '../../components/admin/AdminLayout'; +import AdminModal from '../../components/admin/AdminModal'; +import AdminPageHeader from '../../components/admin/AdminPageHeader'; +import AdminToolbar from '../../components/admin/AdminToolbar'; +import AdminSearchInput from '../../components/admin/AdminSearchInput'; +import AdminIconInput from '../../components/admin/AdminIconInput'; +import AdminStatStrip from '../../components/admin/AdminStatStrip'; +import AdminTable from '../../components/admin/AdminTable'; import { adminDownsizeButtonSize, ADMIN_TOOLBAR_HEIGHT, @@ -26,10 +26,10 @@ import { ADMIN_TOOLBAR_MOBILE_SEARCH, ADMIN_TOOLBAR_MOBILE_SPLIT_ITEM, ADMIN_TOOLBAR_MOBILE_STACK_ITEM, -} from "../../components/admin/adminConstants"; -import Dropdown from "../../components/common/Dropdown"; -import Button from "../../components/common/Button"; -import ErrorScreen from "../../components/common/ErrorScreen"; +} from '../../components/admin/adminConstants'; +import Dropdown from '../../components/common/Dropdown'; +import Button from '../../components/common/Button'; +import ErrorScreen from '../../components/common/ErrorScreen'; import { fetchApiLogs, fetchApiLogStats, @@ -37,44 +37,44 @@ import { type ApiLogsResponse, type ApiLog, type ApiLogStats, -} from "../../utils/fetch/admin"; +} from '../../utils/fetch/admin'; const methodOptions = [ - { value: "", label: "All Methods" }, - { value: "GET", label: "GET" }, - { value: "POST", label: "POST" }, - { value: "PUT", label: "PUT" }, - { value: "DELETE", label: "DELETE" }, - { value: "PATCH", label: "PATCH" }, + { value: '', label: 'All Methods' }, + { value: 'GET', label: 'GET' }, + { value: 'POST', label: 'POST' }, + { value: 'PUT', label: 'PUT' }, + { value: 'DELETE', label: 'DELETE' }, + { value: 'PATCH', label: 'PATCH' }, ]; const statusCodeOptions = [ - { value: "", label: "All Status Codes" }, - { value: "200", label: "200 - OK" }, - { value: "304", label: "304 - Not Modified" }, - { value: "400", label: "400 - Bad Request" }, - { value: "401", label: "401 - Unauthorized" }, - { value: "403", label: "403 - Forbidden" }, - { value: "404", label: "404 - Not Found" }, - { value: "500", label: "500 - Internal Server Error" }, + { value: '', label: 'All Status Codes' }, + { value: '200', label: '200 - OK' }, + { value: '304', label: '304 - Not Modified' }, + { value: '400', label: '400 - Bad Request' }, + { value: '401', label: '401 - Unauthorized' }, + { value: '403', label: '403 - Forbidden' }, + { value: '404', label: '404 - Not Found' }, + { value: '500', label: '500 - Internal Server Error' }, ]; export default function AdminApiLogs() { const [logs, setLogs] = useState([]); const [stats, setStats] = useState(null); const [error, setError] = useState(null); - const [searchFilter, setSearchFilter] = useState(""); - const [userFilter, setUserFilter] = useState(""); - const [methodFilter, setMethodFilter] = useState(""); - const [pathFilter, setPathFilter] = useState(""); - const [statusCodeFilter, setStatusCodeFilter] = useState(""); - const [dateFromFilter, setDateFromFilter] = useState(""); - const [dateToFilter, setDateToFilter] = useState(""); + const [searchFilter, setSearchFilter] = useState(''); + const [userFilter, setUserFilter] = useState(''); + const [methodFilter, setMethodFilter] = useState(''); + const [pathFilter, setPathFilter] = useState(''); + const [statusCodeFilter, setStatusCodeFilter] = useState(''); + const [dateFromFilter, setDateFromFilter] = useState(''); + const [dateToFilter, setDateToFilter] = useState(''); const [selectedLog, setSelectedLog] = useState(null); const [showDetails, setShowDetails] = useState(false); const [toast, setToast] = useState<{ message: string; - type: "success" | "error" | "info"; + type: 'success' | 'error' | 'info'; } | null>(null); const [clientPage, setClientPage] = useState(1); const [totalPages, setTotalPages] = useState(1); @@ -133,9 +133,9 @@ export default function AdminApiLogs() { setTotalPages(data.pagination.pages); } catch (err) { const errorMessage = - err instanceof Error ? err.message : "Failed to fetch API logs"; + err instanceof Error ? err.message : 'Failed to fetch API logs'; setError(errorMessage); - setToast({ message: errorMessage, type: "error" }); + setToast({ message: errorMessage, type: 'error' }); } }; @@ -144,7 +144,7 @@ export default function AdminApiLogs() { const statsData = await fetchApiLogStats(7); setStats(statsData); } catch (err) { - console.error("Failed to fetch API log stats:", err); + console.error('Failed to fetch API log stats:', err); } }; @@ -155,20 +155,20 @@ export default function AdminApiLogs() { setShowDetails(true); } catch { setToast({ - message: "Failed to fetch log details", - type: "error", + message: 'Failed to fetch log details', + type: 'error', }); } }; const clearFilters = () => { - setSearchFilter(""); - setUserFilter(""); - setMethodFilter(""); - setPathFilter(""); - setStatusCodeFilter(""); - setDateFromFilter(""); - setDateToFilter(""); + setSearchFilter(''); + setUserFilter(''); + setMethodFilter(''); + setPathFilter(''); + setStatusCodeFilter(''); + setDateFromFilter(''); + setDateToFilter(''); setClientPage(1); }; @@ -177,7 +177,7 @@ export default function AdminApiLogs() { const now = new Date(); const diffMs = now.getTime() - date.getTime(); - if (isNaN(diffMs)) return "Invalid date"; + if (isNaN(diffMs)) return 'Invalid date'; const diffSecs = Math.floor(diffMs / 1000); const diffMins = Math.floor(diffSecs / 60); @@ -187,43 +187,43 @@ export default function AdminApiLogs() { if (diffDays > 0) return `${diffDays}d ago`; if (diffHours > 0) return `${diffHours}h ago`; if (diffMins > 0) return `${diffMins}m ago`; - return "Just now"; + return 'Just now'; }; const formatDateTime = (dateString: string) => { - return new Date(dateString).toLocaleString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZone: "UTC", - timeZoneName: "short", + return new Date(dateString).toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', + timeZoneName: 'short', }); }; const getStatusColor = (statusCode: number) => { - if (statusCode >= 200 && statusCode < 300) return "text-green-400"; - if (statusCode >= 300 && statusCode < 400) return "text-yellow-400"; - if (statusCode >= 400 && statusCode < 500) return "text-orange-400"; - if (statusCode >= 500) return "text-red-400"; - return "text-gray-400"; + if (statusCode >= 200 && statusCode < 300) return 'text-green-400'; + if (statusCode >= 300 && statusCode < 400) return 'text-yellow-400'; + if (statusCode >= 400 && statusCode < 500) return 'text-orange-400'; + if (statusCode >= 500) return 'text-red-400'; + return 'text-gray-400'; }; const getMethodColor = (method: string) => { switch (method) { - case "GET": - return "text-blue-400 bg-blue-400/10 border border-blue-400/30"; - case "POST": - return "text-green-400 bg-green-400/10 border border-green-400/30"; - case "PUT": - return "text-yellow-400 bg-yellow-400/10 border border-yellow-400/30"; - case "DELETE": - return "text-red-400 bg-red-400/10 border border-red-400/30"; - case "PATCH": - return "text-purple-400 bg-purple-400/10 border border-purple-400/30"; + case 'GET': + return 'text-blue-400 bg-blue-400/10 border border-blue-400/30'; + case 'POST': + return 'text-green-400 bg-green-400/10 border border-green-400/30'; + case 'PUT': + return 'text-yellow-400 bg-yellow-400/10 border border-yellow-400/30'; + case 'DELETE': + return 'text-red-400 bg-red-400/10 border border-red-400/30'; + case 'PATCH': + return 'text-purple-400 bg-purple-400/10 border border-purple-400/30'; default: - return "text-gray-400 bg-gray-400/10 border border-gray-400/30"; + return 'text-gray-400 bg-gray-400/10 border border-gray-400/30'; } }; @@ -234,15 +234,15 @@ export default function AdminApiLogs() { {stats && ( @@ -377,7 +377,7 @@ export default function AdminApiLogs() {
- +
TimeCallsignStatusDepartureArrivalAircraftRFLCFLSIDSTAR + Time + + Callsign + + Status + + Departure + + Arrival + + Aircraft + + RFL + + CFL + + SID + + STAR + Remark
- {searchTerm || selectedAirport || selectedStatus || selectedFlightType - ? "No flights found matching your criteria" - : "No flights currently active"} + + {searchTerm || + selectedAirport || + selectedStatus || + selectedFlightType + ? 'No flights found matching your criteria' + : 'No flights currently active'}
{flight.timestamp - ? new Date(flight.timestamp).toLocaleTimeString("en-GB", { - hour: "2-digit", - minute: "2-digit", - timeZone: "UTC", + ? new Date( + flight.timestamp + ).toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', }) : flight.created_at - ? new Date(flight.created_at).toLocaleTimeString("en-GB", { - hour: "2-digit", - minute: "2-digit", - timeZone: "UTC", + ? new Date( + flight.created_at + ).toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', }) - : "N/A"} + : 'N/A'}
{flight.sessionIsAdvancedATC ? ( - + ) : flight.sessionIsPFATC ? ( - + ) : null} {flight.flight_type && ( )} - {renderEditableCell(flight, "callsign", "text")} + {renderEditableCell(flight, 'callsign', 'text')}
{canEditFlight(flight) - ? renderEditableCell(flight, "status", "status") - : getStatusBadge(flight.status || "")} + ? renderEditableCell(flight, 'status', 'status') + : getStatusBadge(flight.status || '')}
- {flight.departure || "N/A"} + {flight.departure || 'N/A'}
{canEditFlight(flight) ? ( - renderEditableCell(flight, "arrival", "airport") + renderEditableCell(flight, 'arrival', 'airport') ) : (
- {flight.arrival || "N/A"} + {flight.arrival || 'N/A'}
)}
{canEditFlight(flight) ? ( - renderEditableCell(flight, "aircraft", "aircraft") + renderEditableCell( + flight, + 'aircraft', + 'aircraft' + ) ) : ( - {flight.aircraft || "N/A"} + {flight.aircraft || 'N/A'} )} {canEditFlight(flight) ? ( - renderEditableCell(flight, "cruisingFL", "altitude") + renderEditableCell( + flight, + 'cruisingFL', + 'altitude' + ) ) : ( - {flight.cruisingFL || "-"} + {flight.cruisingFL || '-'} )} {canEditFlight(flight) ? ( - renderEditableCell(flight, "clearedFL", "altitude") + renderEditableCell( + flight, + 'clearedFL', + 'altitude' + ) ) : ( - {flight.clearedFL || "-"} + {flight.clearedFL || '-'} )} {canEditFlight(flight) ? ( - renderEditableCell(flight, "sid", "sid") + renderEditableCell(flight, 'sid', 'sid') ) : ( - {flight.sid || "-"} + {flight.sid || '-'} )} {canEditFlight(flight) ? ( - renderEditableCell(flight, "star", "star") + renderEditableCell(flight, 'star', 'star') ) : ( - {flight.star || "-"} + {flight.star || '-'} )} {canEditFlight(flight) ? ( - renderEditableCell(flight, "remark", "text") + renderEditableCell(flight, 'remark', 'text') ) : ( - {flight.remark || "-"} + {flight.remark || '-'} )}
- {log.username || "Unknown"} + {log.username || 'Unknown'}
{log.user_id && (
{log.user_id}
@@ -387,7 +387,7 @@ export default function AdminApiLogs() {
- Page {filteredLogs.length === 0 ? 0 : clientPage} of{" "} + Page {filteredLogs.length === 0 ? 0 : clientPage} of{' '} {filteredLogs.length === 0 ? 0 : filteredTotalPages} @@ -832,13 +832,13 @@ export default function AdminAudit() {

{formatIPAddress(selectedLog.ip_address, selectedLog.id)}

- {banType === "user" ? ( + {banType === 'user' ? ( { setExpiresAt(value); - setDurationPreset(value.trim() ? null : "permanent"); + setDurationPreset(value.trim() ? null : 'permanent'); }} /> { - setDurationPreset("permanent"); - setExpiresAt(""); + setDurationPreset('permanent'); + setExpiresAt(''); }} />
@@ -398,7 +398,7 @@ export default function AdminBan() { size="sm" variant="danger" > - {loading ? "Banning…" : "Apply ban"} + {loading ? 'Banning…' : 'Apply ban'} @@ -471,13 +471,13 @@ export default function AdminBan() { {[loc.city, loc.region, loc.country] .filter(Boolean) - .join(", ")} + .join(', ')}

)} - {isUser ? "User" : "IP"} + {isUser ? 'User' : 'IP'} @@ -507,7 +507,7 @@ export default function AdminBan() { className="w-6 h-6 rounded-full shrink-0" onError={(e) => { (e.target as HTMLImageElement).src = - "/assets/app/default/avatar.webp"; + '/assets/app/default/avatar.webp'; }} /> @@ -559,7 +559,7 @@ export default function AdminBan() {
{vpnGateEnabled ? ( @@ -571,19 +571,19 @@ export default function AdminBan() {

{vpnGateEnabled - ? "VPN gate is enabled" - : "VPN gate is disabled"} + ? 'VPN gate is enabled' + : 'VPN gate is disabled'}

{vpnGateEnabled - ? "Users on VPN or proxy are blocked unless they appear in the exceptions list." - : "All users can connect regardless of VPN detection."} + ? 'Users on VPN or proxy are blocked unless they appear in the exceptions list.' + : 'All users can connect regardless of VPN detection.'}

- {vpnGateEnabled ? "Enabled" : "Disabled"} + {vpnGateEnabled ? 'Enabled' : 'Disabled'} - {addExceptionLoading ? "Adding…" : "Add exception"} + {addExceptionLoading ? 'Adding…' : 'Add exception'}
@@ -680,4 +680,4 @@ export default function AdminBan() {
); -} \ No newline at end of file +} diff --git a/src/pages/admin/AdminChatReports.tsx b/src/pages/admin/AdminChatReports.tsx index b6c11742..c8e819ba 100644 --- a/src/pages/admin/AdminChatReports.tsx +++ b/src/pages/admin/AdminChatReports.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from 'react'; import { MdReport, MdVisibility, @@ -6,14 +6,14 @@ import { MdBlock, MdOpenInNew, MdTaskAlt, -} from "react-icons/md"; -import AdminRefreshButton from "../../components/admin/AdminRefreshButton"; -import AdminLayout from "../../components/admin/AdminLayout"; -import AdminModal from "../../components/admin/AdminModal"; -import AdminPageHeader from "../../components/admin/AdminPageHeader"; -import AdminToolbar from "../../components/admin/AdminToolbar"; -import AdminSearchInput from "../../components/admin/AdminSearchInput"; -import AdminTable from "../../components/admin/AdminTable"; +} from 'react-icons/md'; +import AdminRefreshButton from '../../components/admin/AdminRefreshButton'; +import AdminLayout from '../../components/admin/AdminLayout'; +import AdminModal from '../../components/admin/AdminModal'; +import AdminPageHeader from '../../components/admin/AdminPageHeader'; +import AdminToolbar from '../../components/admin/AdminToolbar'; +import AdminSearchInput from '../../components/admin/AdminSearchInput'; +import AdminTable from '../../components/admin/AdminTable'; import { adminDownsizeButtonSize, ADMIN_TH, @@ -22,17 +22,17 @@ import { ADMIN_TOOLBAR_MOBILE_COL, ADMIN_TOOLBAR_MOBILE_SEARCH, ADMIN_TOOLBAR_MOBILE_SPLIT_ROW, -} from "../../components/admin/adminConstants"; -import Loader from "../../components/common/Loader"; -import Button from "../../components/common/Button"; -import Dropdown from "../../components/common/Dropdown"; +} from '../../components/admin/adminConstants'; +import Loader from '../../components/common/Loader'; +import Button from '../../components/common/Button'; +import Dropdown from '../../components/common/Dropdown'; import { fetchChatReports, updateChatReportStatus, deleteChatReport, type ChatReport, -} from "../../utils/fetch/admin"; -import ErrorScreen from "../../components/common/ErrorScreen"; +} from '../../utils/fetch/admin'; +import ErrorScreen from '../../components/common/ErrorScreen'; export default function AdminChatReports() { const [reports, setReports] = useState([]); @@ -41,19 +41,19 @@ export default function AdminChatReports() { const [page, setPage] = useState(1); const [limit] = useState(50); const [totalPages, setTotalPages] = useState(1); - const [search, setSearch] = useState(""); - const [filterReporter, setFilterReporter] = useState("all"); + const [search, setSearch] = useState(''); + const [filterReporter, setFilterReporter] = useState('all'); const [selectedReport, setSelectedReport] = useState(null); const [showModal, setShowModal] = useState(false); const [toast, setToast] = useState<{ message: string; - type: "success" | "error" | "info"; + type: 'success' | 'error' | 'info'; } | null>(null); const filterOptions = [ - { value: "all", label: "All Reports" }, - { value: "automod", label: "Automod Only" }, - { value: "user", label: "User Reports Only" }, + { value: 'all', label: 'All Reports' }, + { value: 'automod', label: 'Automod Only' }, + { value: 'user', label: 'User Reports Only' }, ]; useEffect(() => { @@ -67,15 +67,15 @@ export default function AdminChatReports() { const data = await fetchChatReports( page, limit, - filterReporter === "all" ? undefined : filterReporter + filterReporter === 'all' ? undefined : filterReporter ); setReports(data.reports); setTotalPages(data.pagination.pages); } catch (err) { const errorMessage = - err instanceof Error ? err.message : "Failed to fetch reports"; + err instanceof Error ? err.message : 'Failed to fetch reports'; setError(errorMessage); - setToast({ message: errorMessage, type: "error" }); + setToast({ message: errorMessage, type: 'error' }); } finally { setLoading(false); } @@ -87,23 +87,23 @@ export default function AdminChatReports() { }; const handleDismissReport = async (reportId: number) => { - if (!confirm("Are you sure you want to dismiss this report?")) return; + if (!confirm('Are you sure you want to dismiss this report?')) return; try { await deleteChatReport(reportId); - setToast({ message: "Report dismissed", type: "success" }); + setToast({ message: 'Report dismissed', type: 'success' }); fetchReports(); } catch { - setToast({ message: "Failed to dismiss report", type: "error" }); + setToast({ message: 'Failed to dismiss report', type: 'error' }); } }; const handleMarkResolved = async (reportId: number) => { try { - await updateChatReportStatus(reportId, "resolved"); - setToast({ message: "Report marked as resolved", type: "success" }); + await updateChatReportStatus(reportId, 'resolved'); + setToast({ message: 'Report marked as resolved', type: 'success' }); fetchReports(); } catch { - setToast({ message: "Failed to update report", type: "error" }); + setToast({ message: 'Failed to update report', type: 'error' }); } }; @@ -118,7 +118,7 @@ export default function AdminChatReports() { r.reported_user_id.includes(search) ); - const btnSize = adminDownsizeButtonSize("sm"); + const btnSize = adminDownsizeButtonSize('sm'); return ( setToast(null)}> @@ -183,11 +183,11 @@ export default function AdminChatReports() {
{
- {report.reporter_user_id === "automod" - ? "Automod" - : report.reporter_username || "Unknown"} + {report.reporter_user_id === 'automod' + ? 'Automod' + : report.reporter_username || 'Unknown'} - {report.reporter_user_id !== "automod" && ( + {report.reporter_user_id !== 'automod' && ( {report.reporter_user_id} @@ -212,7 +212,7 @@ export default function AdminChatReports() { {
- {report.reported_username || "Unknown"} + {report.reported_username || 'Unknown'} {report.reported_user_id} @@ -236,12 +236,12 @@ export default function AdminChatReports() {
- {report.status || "pending"} + {report.status || 'pending'} @@ -286,21 +286,21 @@ export default function AdminChatReports() { >
{

- {report.reporter_user_id === "automod" - ? "Automod" - : report.reporter_username || "Unknown"} + {report.reporter_user_id === 'automod' + ? 'Automod' + : report.reporter_username || 'Unknown'}

- {report.reporter_user_id !== "automod" && ( + {report.reporter_user_id !== 'automod' && (

{report.reporter_user_id}

@@ -312,14 +312,14 @@ export default function AdminChatReports() { {report.reported_username

- {report.reported_username || "Unknown"} + {report.reported_username || 'Unknown'}

{report.reported_user_id} @@ -328,7 +328,7 @@ export default function AdminChatReports() {

- Message:{" "} + Message:{' '} {report.message}

@@ -338,12 +338,12 @@ export default function AdminChatReports() {

- {report.status || "pending"} + {report.status || 'pending'}

{formatTimestamp(report.created_at)} @@ -412,17 +412,17 @@ export default function AdminChatReports() { @@ -434,27 +434,27 @@ export default function AdminChatReports() {

{

- Reporter:{" "} - {selectedReport.reporter_user_id === "automod" - ? "Automod" - : `${selectedReport.reporter_username || "Unknown"} (${selectedReport.reporter_user_id})`} + Reporter:{' '} + {selectedReport.reporter_user_id === 'automod' + ? 'Automod' + : `${selectedReport.reporter_username || 'Unknown'} (${selectedReport.reporter_user_id})`}

{

- Reported User:{" "} - {selectedReport.reported_username || "Unknown"} ( + Reported User:{' '} + {selectedReport.reported_username || 'Unknown'} ( {selectedReport.reported_user_id})

@@ -475,17 +475,17 @@ export default function AdminChatReports() { Reason: {selectedReport.reason}

- Session:{" "} + Session:{' '} - {selectedReport.session_id}{" "} + {selectedReport.session_id}{' '}

- Timestamp:{" "} + Timestamp:{' '} {formatTimestamp(selectedReport.created_at)}

@@ -493,4 +493,4 @@ export default function AdminChatReports() { ); -} \ No newline at end of file +} diff --git a/src/pages/admin/AdminDatabase.tsx b/src/pages/admin/AdminDatabase.tsx index ed35f5da..ae8cb3d7 100644 --- a/src/pages/admin/AdminDatabase.tsx +++ b/src/pages/admin/AdminDatabase.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { MdStorage } from "react-icons/md"; -import AdminRefreshButton from "../../components/admin/AdminRefreshButton"; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { MdStorage } from 'react-icons/md'; +import AdminRefreshButton from '../../components/admin/AdminRefreshButton'; import { Bar, BarChart, @@ -10,24 +10,24 @@ import { Tooltip, XAxis, YAxis, -} from "recharts"; -import AdminLayout from "../../components/admin/AdminLayout"; -import AdminPageHeader from "../../components/admin/AdminPageHeader"; -import AdminStatStrip from "../../components/admin/AdminStatStrip"; -import AdminSectionTitle from "../../components/admin/AdminSectionTitle"; -import AdminTable from "../../components/admin/AdminTable"; +} from 'recharts'; +import AdminLayout from '../../components/admin/AdminLayout'; +import AdminPageHeader from '../../components/admin/AdminPageHeader'; +import AdminStatStrip from '../../components/admin/AdminStatStrip'; +import AdminSectionTitle from '../../components/admin/AdminSectionTitle'; +import AdminTable from '../../components/admin/AdminTable'; import { adminSectionClass, ADMIN_TH, ADMIN_TD, ADMIN_TABLE_HEAD, -} from "../../components/admin/adminConstants"; -import Loader from "../../components/common/Loader"; -import ErrorScreen from "../../components/common/ErrorScreen"; +} from '../../components/admin/adminConstants'; +import Loader from '../../components/common/Loader'; +import ErrorScreen from '../../components/common/ErrorScreen'; import { fetchAdminDatabaseStats, type AdminDatabaseStatsResponse, -} from "../../utils/fetch/admin"; +} from '../../utils/fetch/admin'; export default function AdminDatabase() { const [data, setData] = useState(null); @@ -40,7 +40,7 @@ export default function AdminDatabase() { setData(await fetchAdminDatabaseStats()); } catch (err) { setError( - err instanceof Error ? err.message : "Failed to load database stats" + err instanceof Error ? err.message : 'Failed to load database stats' ); } finally { setLoading(false); @@ -94,10 +94,10 @@ export default function AdminDatabase() { ); const growthPercent = Math.max(0, data?.growthPercent30d ?? 0); - const dailyNetGrowthLabel = data?.dailyNetGrowthFormatted ?? "—"; + const dailyNetGrowthLabel = data?.dailyNetGrowthFormatted ?? '—'; - const todayLabel = data?.activitySummary?.today ?? "Today"; - const yesterdayLabel = data?.activitySummary?.yesterday ?? "Yesterday"; + const todayLabel = data?.activitySummary?.today ?? 'Today'; + const yesterdayLabel = data?.activitySummary?.yesterday ?? 'Yesterday'; return ( @@ -124,14 +124,14 @@ export default function AdminDatabase() { <>
Table sizes (top 12) @@ -157,16 +157,16 @@ export default function AdminDatabase() { type="category" dataKey="name" width={120} - tick={{ fill: "#a1a1aa", fontSize: 10 }} + tick={{ fill: '#a1a1aa', fontSize: 10 }} axisLine={false} tickLine={false} /> [`${v} MB`, "Size"]} + formatter={(v: number) => [`${v} MB`, 'Size']} labelFormatter={(name) => String(name)} contentStyle={{ - background: "#09090b", - border: "1px solid #3f3f46", + background: '#09090b', + border: '1px solid #3f3f46', borderRadius: 8, }} /> @@ -190,11 +190,11 @@ export default function AdminDatabase() { [`${v} GB`, "Projected"]} + formatter={(v: number) => [`${v} GB`, 'Projected']} labelFormatter={(label) => String(label)} contentStyle={{ - background: "#09090b", - border: "1px solid #3f3f46", + background: '#09090b', + border: '1px solid #3f3f46', borderRadius: 8, }} /> @@ -317,4 +317,4 @@ export default function AdminDatabase() { ) : null} ); -} \ No newline at end of file +} diff --git a/src/pages/admin/AdminDevelopers.tsx b/src/pages/admin/AdminDevelopers.tsx index 10899ffb..1b97c9e4 100644 --- a/src/pages/admin/AdminDevelopers.tsx +++ b/src/pages/admin/AdminDevelopers.tsx @@ -1,20 +1,20 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { MdVpnKey, MdSchedule, MdEdit, MdDelete, MdCode } from "react-icons/md"; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { MdVpnKey, MdSchedule, MdEdit, MdDelete, MdCode } from 'react-icons/md'; const REFRESH_ICON_MIN_SPIN_MS = 500; const APPLICATIONS_FETCH_LIMIT = 100; -import AdminRefreshButton from "../../components/admin/AdminRefreshButton"; -import AdminLayout from "../../components/admin/AdminLayout"; -import AdminModal from "../../components/admin/AdminModal"; -import AdminPageHeader from "../../components/admin/AdminPageHeader"; -import AdminToolbar from "../../components/admin/AdminToolbar"; -import AdminSearchInput from "../../components/admin/AdminSearchInput"; -import AdminStatStrip from "../../components/admin/AdminStatStrip"; -import AdminTable from "../../components/admin/AdminTable"; -import AdminDeveloperEditModal from "../../components/admin/AdminDeveloperEditModal"; -import AdminDeveloperApplicationReviewModal from "../../components/admin/AdminDeveloperApplicationReviewModal"; -import DeveloperDiscordAvatar from "../../components/admin/DeveloperDiscordAvatar"; +import AdminRefreshButton from '../../components/admin/AdminRefreshButton'; +import AdminLayout from '../../components/admin/AdminLayout'; +import AdminModal from '../../components/admin/AdminModal'; +import AdminPageHeader from '../../components/admin/AdminPageHeader'; +import AdminToolbar from '../../components/admin/AdminToolbar'; +import AdminSearchInput from '../../components/admin/AdminSearchInput'; +import AdminStatStrip from '../../components/admin/AdminStatStrip'; +import AdminTable from '../../components/admin/AdminTable'; +import AdminDeveloperEditModal from '../../components/admin/AdminDeveloperEditModal'; +import AdminDeveloperApplicationReviewModal from '../../components/admin/AdminDeveloperApplicationReviewModal'; +import DeveloperDiscordAvatar from '../../components/admin/DeveloperDiscordAvatar'; import { adminDownsizeButtonSize, ADMIN_TABLE_HEAD, @@ -25,11 +25,11 @@ import { ADMIN_TOOLBAR_MOBILE_PAIR, ADMIN_TOOLBAR_MOBILE_SEARCH, ADMIN_TOOLBAR_MOBILE_SPLIT_ITEM, -} from "../../components/admin/adminConstants"; -import Loader from "../../components/common/Loader"; -import Button from "../../components/common/Button"; -import Dropdown from "../../components/common/Dropdown"; -import ErrorScreen from "../../components/common/ErrorScreen"; +} from '../../components/admin/adminConstants'; +import Loader from '../../components/common/Loader'; +import Button from '../../components/common/Button'; +import Dropdown from '../../components/common/Dropdown'; +import ErrorScreen from '../../components/common/ErrorScreen'; import { fetchAdminDeveloperApplications, fetchAdminDevelopers, @@ -40,37 +40,37 @@ import { deleteAdminDeveloperAccount, type AdminDeveloperApplication, type AdminDeveloperSummary, -} from "../../utils/fetch/adminDevelopers"; +} from '../../utils/fetch/adminDevelopers'; -type Section = "applications" | "developers"; +type Section = 'applications' | 'developers'; -const APP_FILTERS = ["pending", "approved", "rejected", "all"] as const; +const APP_FILTERS = ['pending', 'approved', 'rejected', 'all'] as const; type AppFilter = (typeof APP_FILTERS)[number]; const sectionOptions = [ - { value: "applications", label: "Applications" }, - { value: "developers", label: "Developers" }, + { value: 'applications', label: 'Applications' }, + { value: 'developers', label: 'Developers' }, ]; const appFilterOptions = [ - { value: "pending", label: "Pending" }, - { value: "approved", label: "Approved" }, - { value: "rejected", label: "Rejected" }, - { value: "all", label: "All statuses" }, + { value: 'pending', label: 'Pending' }, + { value: 'approved', label: 'Approved' }, + { value: 'rejected', label: 'Rejected' }, + { value: 'all', label: 'All statuses' }, ]; export default function AdminDevelopers() { - const [section, setSection] = useState
("applications"); + const [section, setSection] = useState
('applications'); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [applications, setApplications] = useState( [] ); - const [appFilter, setAppFilter] = useState("pending"); - const [appSearch, setAppSearch] = useState(""); - const [devSearch, setDevSearch] = useState(""); + const [appFilter, setAppFilter] = useState('pending'); + const [appSearch, setAppSearch] = useState(''); + const [devSearch, setDevSearch] = useState(''); const [developers, setDevelopers] = useState([]); - const [rejectNote, setRejectNote] = useState(""); + const [rejectNote, setRejectNote] = useState(''); const [rejectId, setRejectId] = useState(null); const [busyId, setBusyId] = useState(null); const [editUserId, setEditUserId] = useState(null); @@ -80,7 +80,7 @@ export default function AdminDevelopers() { const [refreshIconBusy, setRefreshIconBusy] = useState(false); const [toast, setToast] = useState<{ message: string; - type: "success" | "error" | "info"; + type: 'success' | 'error' | 'info'; } | null>(null); const loadSeqRef = useRef(0); const refreshIconClearTimerRef = useRef | null>( @@ -119,9 +119,9 @@ export default function AdminDevelopers() { setApplications(appRes.applications); setDevelopers(devRes.developers); } catch (e) { - const message = e instanceof Error ? e.message : "Failed to load"; + const message = e instanceof Error ? e.message : 'Failed to load'; setError(message); - setToast({ message, type: "error" }); + setToast({ message, type: 'error' }); } finally { if (showPageLoader) { setLoading(false); @@ -158,16 +158,16 @@ export default function AdminDevelopers() { ); const appCounts = useMemo(() => { - const pending = applications.filter((a) => a.status === "pending").length; - const approved = applications.filter((a) => a.status === "approved").length; - const rejected = applications.filter((a) => a.status === "rejected").length; + const pending = applications.filter((a) => a.status === 'pending').length; + const approved = applications.filter((a) => a.status === 'approved').length; + const rejected = applications.filter((a) => a.status === 'rejected').length; return { pending, approved, rejected, total: applications.length }; }, [applications]); const filteredApplications = useMemo(() => { const q = appSearch.trim().toLowerCase(); return applications.filter((a) => { - if (appFilter !== "all" && a.status !== appFilter) return false; + if (appFilter !== 'all' && a.status !== appFilter) return false; if (!q) return true; return ( a.username.toLowerCase().includes(q) || @@ -193,7 +193,7 @@ export default function AdminDevelopers() { [developers, editUserId] ); - const btnSize = adminDownsizeButtonSize("sm"); + const btnSize = adminDownsizeButtonSize('sm'); const handleApproveFromReview = async ( appId: number, @@ -207,10 +207,10 @@ export default function AdminDevelopers() { try { await approveDeveloperApplication(appId, body); setReviewApp(null); - setToast({ message: "Application approved", type: "success" }); + setToast({ message: 'Application approved', type: 'success' }); await load(); } catch (e) { - throw e instanceof Error ? e : new Error("Approve failed"); + throw e instanceof Error ? e : new Error('Approve failed'); } finally { setBusyId(null); } @@ -222,13 +222,13 @@ export default function AdminDevelopers() { try { await rejectDeveloperApplication(rejectId, rejectNote); setRejectId(null); - setRejectNote(""); - setToast({ message: "Application rejected", type: "success" }); + setRejectNote(''); + setToast({ message: 'Application rejected', type: 'success' }); await load(); } catch (e) { setToast({ - message: e instanceof Error ? e.message : "Reject failed", - type: "error", + message: e instanceof Error ? e.message : 'Reject failed', + type: 'error', }); } finally { setBusyId(null); @@ -236,17 +236,17 @@ export default function AdminDevelopers() { }; const handleSuspend = async (userId: string) => { - if (!confirm("Suspend this developer? Their API keys will stop working.")) + if (!confirm('Suspend this developer? Their API keys will stop working.')) return; setBusyId(userId); try { await suspendDeveloperProfile(userId); - setToast({ message: "Developer suspended", type: "success" }); + setToast({ message: 'Developer suspended', type: 'success' }); await load(); } catch (e) { setToast({ - message: e instanceof Error ? e.message : "Suspend failed", - type: "error", + message: e instanceof Error ? e.message : 'Suspend failed', + type: 'error', }); } finally { setBusyId(null); @@ -257,12 +257,12 @@ export default function AdminDevelopers() { setBusyId(userId); try { await reactivateDeveloperProfile(userId); - setToast({ message: "Developer reactivated", type: "success" }); + setToast({ message: 'Developer reactivated', type: 'success' }); await load(); } catch (e) { setToast({ - message: e instanceof Error ? e.message : "Reactivate failed", - type: "error", + message: e instanceof Error ? e.message : 'Reactivate failed', + type: 'error', }); } finally { setBusyId(null); @@ -272,7 +272,7 @@ export default function AdminDevelopers() { const handleDeleteDeveloper = async (userId: string) => { if ( !confirm( - "Permanently delete this developer? This removes their developer profile, all API keys, application history, and developer API usage logs. The user account itself is not deleted." + 'Permanently delete this developer? This removes their developer profile, all API keys, application history, and developer API usage logs. The user account itself is not deleted.' ) ) return; @@ -280,12 +280,12 @@ export default function AdminDevelopers() { try { await deleteAdminDeveloperAccount(userId); setEditUserId((cur) => (cur === userId ? null : cur)); - setToast({ message: "Developer deleted", type: "success" }); + setToast({ message: 'Developer deleted', type: 'success' }); await load(); } catch (e) { setToast({ - message: e instanceof Error ? e.message : "Delete failed", - type: "error", + message: e instanceof Error ? e.message : 'Delete failed', + type: 'error', }); } finally { setBusyId(null); @@ -294,11 +294,11 @@ export default function AdminDevelopers() { const formatDate = (iso: string) => new Date(iso).toLocaleString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', }); return ( @@ -330,19 +330,19 @@ export default function AdminDevelopers() { d.status === "active").length, + label: 'Active developers', + value: developers.filter((d) => d.status === 'active').length, sub: `${developers.length} total profiles`, }, ]} /> - {section === "applications" ? ( + {section === 'applications' ? ( <> - {section === "applications" ? ( + {section === 'applications' ? ( <>
@@ -461,7 +461,7 @@ export default function AdminDevelopers() { {formatDate(a.createdAt)}
- {a.status === "pending" ? ( + {a.status === 'pending' ? (
@@ -751,7 +751,7 @@ export default function AdminDevelopers() { open={rejectId != null} onClose={() => { setRejectId(null); - setRejectNote(""); + setRejectNote(''); }} title="Reject application" size="sm" @@ -763,7 +763,7 @@ export default function AdminDevelopers() { size={btnSize} onClick={() => { setRejectId(null); - setRejectNote(""); + setRejectNote(''); }} > Cancel @@ -809,4 +809,4 @@ export default function AdminDevelopers() { )} ); -} \ No newline at end of file +} diff --git a/src/pages/admin/AdminFeedback.tsx b/src/pages/admin/AdminFeedback.tsx index e77985ed..5211c1dc 100644 --- a/src/pages/admin/AdminFeedback.tsx +++ b/src/pages/admin/AdminFeedback.tsx @@ -1,11 +1,11 @@ -import { useState, useEffect } from "react"; -import { MdStar, MdMessage, MdDelete, MdPeople } from "react-icons/md"; -import AdminRefreshButton from "../../components/admin/AdminRefreshButton"; -import AdminLayout from "../../components/admin/AdminLayout"; -import AdminPageHeader from "../../components/admin/AdminPageHeader"; -import AdminStatStrip from "../../components/admin/AdminStatStrip"; -import AdminToolbar from "../../components/admin/AdminToolbar"; -import AdminSearchInput from "../../components/admin/AdminSearchInput"; +import { useState, useEffect } from 'react'; +import { MdStar, MdMessage, MdDelete, MdPeople } from 'react-icons/md'; +import AdminRefreshButton from '../../components/admin/AdminRefreshButton'; +import AdminLayout from '../../components/admin/AdminLayout'; +import AdminPageHeader from '../../components/admin/AdminPageHeader'; +import AdminStatStrip from '../../components/admin/AdminStatStrip'; +import AdminToolbar from '../../components/admin/AdminToolbar'; +import AdminSearchInput from '../../components/admin/AdminSearchInput'; import { adminDownsizeButtonSize, adminSectionClass, @@ -13,18 +13,18 @@ import { ADMIN_TOOLBAR_MOBILE_SEARCH, ADMIN_TOOLBAR_MOBILE_SPLIT_ITEM, ADMIN_TOOLBAR_MOBILE_SPLIT_ROW, -} from "../../components/admin/adminConstants"; -import Loader from "../../components/common/Loader"; -import Button from "../../components/common/Button"; -import ErrorScreen from "../../components/common/ErrorScreen"; -import Dropdown from "../../components/common/Dropdown"; +} from '../../components/admin/adminConstants'; +import Loader from '../../components/common/Loader'; +import Button from '../../components/common/Button'; +import ErrorScreen from '../../components/common/ErrorScreen'; +import Dropdown from '../../components/common/Dropdown'; import { fetchFeedback, fetchFeedbackStats, deleteFeedback, type Feedback, type FeedbackStats, -} from "../../utils/fetch/feedback"; +} from '../../utils/fetch/feedback'; export default function AdminFeedback() { const [feedback, setFeedback] = useState([]); @@ -33,20 +33,20 @@ export default function AdminFeedback() { ); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [search, setSearch] = useState(""); - const [filterRating, setFilterRating] = useState("all"); + const [search, setSearch] = useState(''); + const [filterRating, setFilterRating] = useState('all'); const [toast, setToast] = useState<{ message: string; - type: "success" | "error" | "info"; + type: 'success' | 'error' | 'info'; } | null>(null); const filterOptions = [ - { value: "all", label: "All Ratings" }, - { value: "5", label: "5 Stars" }, - { value: "4", label: "4 Stars" }, - { value: "3", label: "3 Stars" }, - { value: "2", label: "2 Stars" }, - { value: "1", label: "1 Star" }, + { value: 'all', label: 'All Ratings' }, + { value: '5', label: '5 Stars' }, + { value: '4', label: '4 Stars' }, + { value: '3', label: '3 Stars' }, + { value: '2', label: '2 Stars' }, + { value: '1', label: '1 Star' }, ]; useEffect(() => { @@ -65,11 +65,11 @@ export default function AdminFeedback() { setFeedback(feedbackData); } catch (err) { const errorMessage = - err instanceof Error ? err.message : "Failed to fetch feedback"; + err instanceof Error ? err.message : 'Failed to fetch feedback'; setError(errorMessage); setToast({ message: errorMessage, - type: "error", + type: 'error', }); } finally { setLoading(false); @@ -82,25 +82,25 @@ export default function AdminFeedback() { (item.comment && item.comment.toLowerCase().includes(search.toLowerCase())); const matchesRating = - filterRating === "all" || item.rating.toString() === filterRating; + filterRating === 'all' || item.rating.toString() === filterRating; return matchesSearch && matchesRating; }); const handleDeleteFeedback = async (id: number) => { - if (!confirm("Are you sure you want to delete this feedback?")) return; + if (!confirm('Are you sure you want to delete this feedback?')) return; try { await deleteFeedback(id); setToast({ - message: "Feedback deleted successfully", - type: "success", + message: 'Feedback deleted successfully', + type: 'success', }); fetchData(); } catch (err) { setToast({ message: - err instanceof Error ? err.message : "Failed to delete feedback", - type: "error", + err instanceof Error ? err.message : 'Failed to delete feedback', + type: 'error', }); } }; @@ -112,7 +112,7 @@ export default function AdminFeedback() { ))} @@ -132,7 +132,7 @@ export default function AdminFeedback() { features: parseInt(match[3]), easeOfUse: parseInt(match[4]), overall: parseInt(match[5]), - additionalComment: comment.split("\n\n")[1] || null, + additionalComment: comment.split('\n\n')[1] || null, }; } @@ -195,21 +195,21 @@ export default function AdminFeedback() { columns={4} items={[ { - label: "Average", + label: 'Average', value: - Number(feedbackStats.average_rating)?.toFixed(1) || "0.0", + Number(feedbackStats.average_rating)?.toFixed(1) || '0.0', }, - { label: "Total", value: feedbackStats.total_feedback }, - { label: "5 stars", value: feedbackStats.five_star }, - { label: "4 stars", value: feedbackStats.four_star }, - { label: "3 stars", value: feedbackStats.three_star }, - { label: "2 stars", value: feedbackStats.two_star }, - { label: "1 star", value: feedbackStats.one_star }, + { label: 'Total', value: feedbackStats.total_feedback }, + { label: '5 stars', value: feedbackStats.five_star }, + { label: '4 stars', value: feedbackStats.four_star }, + { label: '3 stars', value: feedbackStats.three_star }, + { label: '2 stars', value: feedbackStats.two_star }, + { label: '1 star', value: feedbackStats.one_star }, ]} /> )} -
+
{filteredFeedback.length === 0 ? (
@@ -253,7 +253,7 @@ export default function AdminFeedback() {
- Page {filteredLogs.length === 0 ? 0 : clientPage} of{" "} + Page {filteredLogs.length === 0 ? 0 : clientPage} of{' '} {filteredLogs.length === 0 ? 0 : filteredTotalPages}
@@ -712,4 +712,4 @@ export default function AdminFlightLogs() { ); -} \ No newline at end of file +} diff --git a/src/pages/admin/AdminNotifications.tsx b/src/pages/admin/AdminNotifications.tsx index 3de89213..49ced954 100644 --- a/src/pages/admin/AdminNotifications.tsx +++ b/src/pages/admin/AdminNotifications.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from 'react'; import { MdNotifications, MdAdd, @@ -7,33 +7,33 @@ import { MdCheck, MdClose, MdVisibility, -} from "react-icons/md"; -import AdminLayout from "../../components/admin/AdminLayout"; -import AdminModal from "../../components/admin/AdminModal"; -import AdminPageHeader from "../../components/admin/AdminPageHeader"; -import AdminTable from "../../components/admin/AdminTable"; -import AdminSectionTitle from "../../components/admin/AdminSectionTitle"; +} from 'react-icons/md'; +import AdminLayout from '../../components/admin/AdminLayout'; +import AdminModal from '../../components/admin/AdminModal'; +import AdminPageHeader from '../../components/admin/AdminPageHeader'; +import AdminTable from '../../components/admin/AdminTable'; +import AdminSectionTitle from '../../components/admin/AdminSectionTitle'; import { adminDownsizeButtonSize, adminSectionClass, ADMIN_TABLE_HEAD, ADMIN_TH, ADMIN_TD, -} from "../../components/admin/adminConstants"; -import Loader from "../../components/common/Loader"; -import Button from "../../components/common/Button"; -import ErrorScreen from "../../components/common/ErrorScreen"; -import Dropdown from "../../components/common/Dropdown"; -import TextInput from "../../components/common/TextInput"; -import Checkbox from "../../components/common/Checkbox"; -import UpdateModalsSection from "../../components/admin/UpdateModalsSection"; +} from '../../components/admin/adminConstants'; +import Loader from '../../components/common/Loader'; +import Button from '../../components/common/Button'; +import ErrorScreen from '../../components/common/ErrorScreen'; +import Dropdown from '../../components/common/Dropdown'; +import TextInput from '../../components/common/TextInput'; +import Checkbox from '../../components/common/Checkbox'; +import UpdateModalsSection from '../../components/admin/UpdateModalsSection'; import { fetchNotifications, addNotification, updateNotification, deleteNotification, type Notification, -} from "../../utils/fetch/admin"; +} from '../../utils/fetch/admin'; export default function AdminNotifications() { const [notifications, setNotifications] = useState([]); @@ -44,21 +44,21 @@ export default function AdminNotifications() { useState(null); const [toast, setToast] = useState<{ message: string; - type: "success" | "error" | "info"; + type: 'success' | 'error' | 'info'; } | null>(null); const [newNotification, setNewNotification] = useState({ - type: "info" as "info" | "warning" | "success" | "error", - text: "", + type: 'info' as 'info' | 'warning' | 'success' | 'error', + text: '', show: false, - customColor: "", + customColor: '', }); const typeOptions = [ - { value: "info", label: "Info" }, - { value: "warning", label: "Warning" }, - { value: "success", label: "Success" }, - { value: "error", label: "Error" }, + { value: 'info', label: 'Info' }, + { value: 'warning', label: 'Warning' }, + { value: 'success', label: 'Success' }, + { value: 'error', label: 'Error' }, ]; useEffect(() => { @@ -72,7 +72,7 @@ export default function AdminNotifications() { setNotifications(data); } catch (err) { setError( - err instanceof Error ? err.message : "Failed to fetch notifications" + err instanceof Error ? err.message : 'Failed to fetch notifications' ); } finally { setLoading(false); @@ -88,19 +88,19 @@ export default function AdminNotifications() { custom_color: newNotification.customColor?.trim() || null, }); setToast({ - message: "Notification added successfully", - type: "success", + message: 'Notification added successfully', + type: 'success', }); setShowAddModal(false); setNewNotification({ - type: "info", - text: "", + type: 'info', + text: '', show: false, - customColor: "", + customColor: '', }); fetchAllNotifications(); } catch { - setToast({ message: "Failed to add notification", type: "error" }); + setToast({ message: 'Failed to add notification', type: 'error' }); } }; @@ -115,15 +115,15 @@ export default function AdminNotifications() { }; await updateNotification(id, cleanedUpdates); setToast({ - message: "Notification updated successfully", - type: "success", + message: 'Notification updated successfully', + type: 'success', }); setEditingNotification(null); fetchAllNotifications(); } catch { setToast({ - message: "Failed to update notification", - type: "error", + message: 'Failed to update notification', + type: 'error', }); } }; @@ -132,27 +132,27 @@ export default function AdminNotifications() { try { await deleteNotification(id); setToast({ - message: "Notification deleted successfully", - type: "success", + message: 'Notification deleted successfully', + type: 'success', }); fetchAllNotifications(); } catch { setToast({ - message: "Failed to delete notification", - type: "error", + message: 'Failed to delete notification', + type: 'error', }); } }; const getNotificationIcon = (type: string) => { switch (type) { - case "info": + case 'info': return ; - case "warning": + case 'warning': return ; - case "success": + case 'success': return ; - case "error": + case 'error': return ; default: return ; @@ -174,7 +174,7 @@ export default function AdminNotifications() {
{notif.text}
} @@ -368,11 +368,11 @@ export default function AdminNotifications() { editingNotification ? setEditingNotification({ ...editingNotification, - type: value as "info" | "warning" | "success" | "error", + type: value as 'info' | 'warning' | 'success' | 'error', }) : setNewNotification({ ...newNotification, - type: value as "info" | "warning" | "success" | "error", + type: value as 'info' | 'warning' | 'success' | 'error', }) } placeholder="Select type" @@ -407,7 +407,7 @@ export default function AdminNotifications() { @@ -442,4 +442,4 @@ export default function AdminNotifications() { ); -} \ No newline at end of file +} diff --git a/src/pages/admin/AdminRatings.tsx b/src/pages/admin/AdminRatings.tsx index 7de46db0..f48e6206 100644 --- a/src/pages/admin/AdminRatings.tsx +++ b/src/pages/admin/AdminRatings.tsx @@ -1,10 +1,10 @@ -import { useState, useEffect, useCallback, useMemo } from "react"; -import { MdStar, MdThumbUp } from "react-icons/md"; -import { Link } from "react-router-dom"; -import AdminLayout from "../../components/admin/AdminLayout"; -import AdminPageHeader from "../../components/admin/AdminPageHeader"; -import AdminSectionTitle from "../../components/admin/AdminSectionTitle"; -import AdminTable from "../../components/admin/AdminTable"; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { MdStar, MdThumbUp } from 'react-icons/md'; +import { Link } from 'react-router-dom'; +import AdminLayout from '../../components/admin/AdminLayout'; +import AdminPageHeader from '../../components/admin/AdminPageHeader'; +import AdminSectionTitle from '../../components/admin/AdminSectionTitle'; +import AdminTable from '../../components/admin/AdminTable'; import { adminDownsizeButtonSize, adminSectionClass, @@ -12,20 +12,20 @@ import { ADMIN_TH, ADMIN_TD, ADMIN_TABLE_HEAD, -} from "../../components/admin/adminConstants"; +} from '../../components/admin/adminConstants'; import { AdminAreaChart, AdminMultiSeriesAreaChart, -} from "../../components/admin/AdminChart"; -import Loader from "../../components/common/Loader"; -import Button from "../../components/common/Button"; +} from '../../components/admin/AdminChart'; +import Loader from '../../components/common/Loader'; +import Button from '../../components/common/Button'; import { fetchControllerRatingStats, fetchControllerDailyRatingStats, type ControllerRatingStats, type DailyRatingStats, -} from "../../utils/fetch/admin"; -import ErrorScreen from "../../components/common/ErrorScreen"; +} from '../../utils/fetch/admin'; +import ErrorScreen from '../../components/common/ErrorScreen'; const getAvatarUrl = (userId: string, avatar: string | null) => { if (!avatar) return null; @@ -40,7 +40,7 @@ export default function AdminRatings() { const [error, setError] = useState(null); const [toast, setToast] = useState<{ message: string; - type: "success" | "error" | "info"; + type: 'success' | 'error' | 'info'; } | null>(null); const fetchData = useCallback(async () => { @@ -54,8 +54,8 @@ export default function AdminRatings() { setStats(statsData); setDailyStats(dailyData); } catch (error) { - console.error("Error fetching rating statistics:", error); - setError("Failed to fetch rating statistics"); + console.error('Error fetching rating statistics:', error); + setError('Failed to fetch rating statistics'); } finally { setLoading(false); } @@ -97,8 +97,8 @@ export default function AdminRatings() { @@ -120,7 +120,7 @@ export default function AdminRatings() { ) : stats ? ( <>
Ratings count @@ -130,7 +130,7 @@ export default function AdminRatings() { {p.rating_count} - {" "} + {' '} ratings
@@ -333,4 +333,4 @@ export default function AdminRatings() { )} ); -} \ No newline at end of file +} diff --git a/src/pages/admin/AdminRoles.tsx b/src/pages/admin/AdminRoles.tsx index f45fa407..fd3103ec 100644 --- a/src/pages/admin/AdminRoles.tsx +++ b/src/pages/admin/AdminRoles.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo } from 'react'; import { MdAdd, MdEdit, @@ -9,16 +9,16 @@ import { MdAdminPanelSettings, MdDragIndicator, MdFilterList, -} from "react-icons/md"; -import AdminRefreshButton from "../../components/admin/AdminRefreshButton"; -import AdminLayout from "../../components/admin/AdminLayout"; -import AdminModal from "../../components/admin/AdminModal"; -import AdminPageHeader from "../../components/admin/AdminPageHeader"; -import AdminToolbar from "../../components/admin/AdminToolbar"; -import AdminSearchInput from "../../components/admin/AdminSearchInput"; -import AdminStatStrip from "../../components/admin/AdminStatStrip"; -import AdminTable from "../../components/admin/AdminTable"; -import AdminTextInput from "../../components/admin/AdminTextInput"; +} from 'react-icons/md'; +import AdminRefreshButton from '../../components/admin/AdminRefreshButton'; +import AdminLayout from '../../components/admin/AdminLayout'; +import AdminModal from '../../components/admin/AdminModal'; +import AdminPageHeader from '../../components/admin/AdminPageHeader'; +import AdminToolbar from '../../components/admin/AdminToolbar'; +import AdminSearchInput from '../../components/admin/AdminSearchInput'; +import AdminStatStrip from '../../components/admin/AdminStatStrip'; +import AdminTable from '../../components/admin/AdminTable'; +import AdminTextInput from '../../components/admin/AdminTextInput'; import { adminDownsizeButtonSize, adminSectionClass, @@ -27,11 +27,11 @@ import { ADMIN_TH, ADMIN_TD, ADMIN_TOOLBAR_HEIGHT, -} from "../../components/admin/adminConstants"; -import Loader from "../../components/common/Loader"; -import Button from "../../components/common/Button"; -import ErrorScreen from "../../components/common/ErrorScreen"; -import Dropdown from "../../components/common/Dropdown"; +} from '../../components/admin/adminConstants'; +import Loader from '../../components/common/Loader'; +import Button from '../../components/common/Button'; +import ErrorScreen from '../../components/common/ErrorScreen'; +import Dropdown from '../../components/common/Dropdown'; import { fetchRoles, createRole, @@ -43,20 +43,20 @@ import { updateRolePriorities, type Role, type UserWithRole, -} from "../../utils/fetch/admin"; +} from '../../utils/fetch/admin'; import { getIconComponent, AVAILABLE_ICONS, AVAILABLE_PERMISSIONS, PRESET_COLORS, -} from "../../utils/roles"; +} from '../../utils/roles'; function RoleBadge({ role, compact }: { role: Role; compact?: boolean }) { const RoleIcon = getIconComponent(role.icon); return ( {role.name} @@ -107,21 +107,21 @@ export default function AdminRoles() { const [showCreateModal, setShowCreateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [selectedRole, setSelectedRole] = useState(null); - const [formName, setFormName] = useState(""); - const [formDescription, setFormDescription] = useState(""); + const [formName, setFormName] = useState(''); + const [formDescription, setFormDescription] = useState(''); const [formPermissions, setFormPermissions] = useState< Record >({}); - const [formColor, setFormColor] = useState("#6366F1"); - const [formIcon, setFormIcon] = useState("Star"); + const [formColor, setFormColor] = useState('#6366F1'); + const [formIcon, setFormIcon] = useState('Star'); const [formPriority, setFormPriority] = useState(0); const [submitting, setSubmitting] = useState(false); - const [userSearch, setUserSearch] = useState(""); - const [roleFilter, setRoleFilter] = useState("all"); + const [userSearch, setUserSearch] = useState(''); + const [roleFilter, setRoleFilter] = useState('all'); const [draggedId, setDraggedId] = useState(null); const [toast, setToast] = useState<{ message: string; - type: "success" | "error" | "info"; + type: 'success' | 'error' | 'info'; } | null>(null); const [showAddRoleModal, setShowAddRoleModal] = useState(false); const [selectedUserForRole, setSelectedUserForRole] = useState( @@ -151,17 +151,17 @@ export default function AdminRoles() { user.username.toLowerCase().includes(q) || user.id.toLowerCase().includes(q); if (!matchesSearch) return false; - if (roleFilter === "all") return true; - if (roleFilter === "no-role") return !user.roles?.length; + if (roleFilter === 'all') return true; + if (roleFilter === 'no-role') return !user.roles?.length; return ( user.roles?.some((role) => role.id.toString() === roleFilter) ?? false ); }); }, [users, userSearch, roleFilter]); - const hasUserFilters = userSearch.trim() !== "" || roleFilter !== "all"; + const hasUserFilters = userSearch.trim() !== '' || roleFilter !== 'all'; - const btnSize = adminDownsizeButtonSize("sm"); + const btnSize = adminDownsizeButtonSize('sm'); const toolbarBtnClass = `shrink-0 ${ADMIN_TOOLBAR_HEIGHT} py-0`; useEffect(() => { @@ -180,27 +180,27 @@ export default function AdminRoles() { setUsers(usersData); } catch (err) { const errorMessage = - err instanceof Error ? err.message : "Failed to fetch data"; + err instanceof Error ? err.message : 'Failed to fetch data'; setError(errorMessage); - setToast({ message: errorMessage, type: "error" }); + setToast({ message: errorMessage, type: 'error' }); } finally { setLoading(false); } }; const resetForm = () => { - setFormName(""); - setFormDescription(""); + setFormName(''); + setFormDescription(''); setFormPermissions({}); - setFormColor("#6366F1"); - setFormIcon("Star"); + setFormColor('#6366F1'); + setFormIcon('Star'); setFormPriority(0); setSelectedRole(null); }; const handleCreateRole = async () => { if (!formName.trim()) { - setToast({ message: "Role name is required", type: "error" }); + setToast({ message: 'Role name is required', type: 'error' }); return; } try { @@ -213,14 +213,14 @@ export default function AdminRoles() { icon: formIcon, priority: isNaN(formPriority) ? 0 : formPriority, }); - setToast({ message: "Role created successfully", type: "success" }); + setToast({ message: 'Role created successfully', type: 'success' }); setShowCreateModal(false); resetForm(); await fetchData(); } catch (e) { setToast({ - message: e instanceof Error ? e.message : "Failed to create role", - type: "error", + message: e instanceof Error ? e.message : 'Failed to create role', + type: 'error', }); } finally { setSubmitting(false); @@ -229,11 +229,11 @@ export default function AdminRoles() { const handleEditRole = async () => { if (!selectedRole || !formName.trim()) { - setToast({ message: "Role name is required", type: "error" }); + setToast({ message: 'Role name is required', type: 'error' }); return; } if (!selectedRole.id || isNaN(selectedRole.id)) { - setToast({ message: "Invalid role ID", type: "error" }); + setToast({ message: 'Invalid role ID', type: 'error' }); return; } try { @@ -246,14 +246,14 @@ export default function AdminRoles() { icon: formIcon, priority: isNaN(formPriority) ? 0 : formPriority, }); - setToast({ message: "Role updated successfully", type: "success" }); + setToast({ message: 'Role updated successfully', type: 'success' }); setShowEditModal(false); resetForm(); await fetchData(); } catch (e) { setToast({ - message: e instanceof Error ? e.message : "Failed to update role", - type: "error", + message: e instanceof Error ? e.message : 'Failed to update role', + type: 'error', }); } finally { setSubmitting(false); @@ -262,7 +262,7 @@ export default function AdminRoles() { const handleDeleteRole = async (role: Role) => { if (!role.id || isNaN(role.id)) { - setToast({ message: "Invalid role ID", type: "error" }); + setToast({ message: 'Invalid role ID', type: 'error' }); return; } if ( @@ -274,12 +274,12 @@ export default function AdminRoles() { } try { await deleteRole(role.id); - setToast({ message: "Role deleted successfully", type: "success" }); + setToast({ message: 'Role deleted successfully', type: 'success' }); await fetchData(); } catch (e) { setToast({ - message: e instanceof Error ? e.message : "Failed to delete role", - type: "error", + message: e instanceof Error ? e.message : 'Failed to delete role', + type: 'error', }); } }; @@ -287,14 +287,14 @@ export default function AdminRoles() { const handleAssignRole = async (userId: string, roleId: number) => { try { await assignRoleToUser(userId, roleId); - setToast({ message: "Role assigned successfully", type: "success" }); + setToast({ message: 'Role assigned successfully', type: 'success' }); setShowAddRoleModal(false); setSelectedUserForRole(null); await fetchData(); } catch (e) { setToast({ - message: e instanceof Error ? e.message : "Failed to assign role", - type: "error", + message: e instanceof Error ? e.message : 'Failed to assign role', + type: 'error', }); } }; @@ -302,12 +302,12 @@ export default function AdminRoles() { const handleRemoveRole = async (userId: string, roleId: number) => { try { await removeRoleFromUser(userId, roleId); - setToast({ message: "Role removed successfully", type: "success" }); + setToast({ message: 'Role removed successfully', type: 'success' }); await fetchData(); } catch (e) { setToast({ - message: e instanceof Error ? e.message : "Failed to remove role", - type: "error", + message: e instanceof Error ? e.message : 'Failed to remove role', + type: 'error', }); } }; @@ -315,12 +315,12 @@ export default function AdminRoles() { const openEditModal = (role: Role) => { setSelectedRole(role); setFormName(role.name); - setFormDescription(role.description || ""); + setFormDescription(role.description || ''); setFormPermissions(role.permissions); - setFormColor(role.color || "#6366F1"); - setFormIcon(role.icon || "Star"); + setFormColor(role.color || '#6366F1'); + setFormIcon(role.icon || 'Star'); setFormPriority( - typeof role.priority === "number" && !isNaN(role.priority) + typeof role.priority === 'number' && !isNaN(role.priority) ? role.priority : 0 ); @@ -357,26 +357,26 @@ export default function AdminRoles() { priority: roles.length - index, })); if (rolePriorities.length === 0) { - setToast({ message: "No valid roles to update", type: "error" }); + setToast({ message: 'No valid roles to update', type: 'error' }); return; } try { await updateRolePriorities(rolePriorities); - setToast({ message: "Role order saved", type: "success" }); + setToast({ message: 'Role order saved', type: 'success' }); await fetchData(); } catch (e) { setToast({ message: - e instanceof Error ? e.message : "Failed to update role priorities", - type: "error", + e instanceof Error ? e.message : 'Failed to update role priorities', + type: 'error', }); await fetchData(); } }; const clearUserFilters = () => { - setUserSearch(""); - setRoleFilter("all"); + setUserSearch(''); + setRoleFilter('all'); }; const roleFormFields = ( @@ -407,8 +407,8 @@ export default function AdminRoles() { onClick={() => setFormIcon(iconOption.value)} className={`p-2.5 rounded-xl border transition-colors ${ formIcon === iconOption.value - ? "border-blue-600 bg-blue-600/15" - : "border-zinc-700/80 bg-zinc-900/40 hover:border-zinc-600" + ? 'border-blue-600 bg-blue-600/15' + : 'border-zinc-700/80 bg-zinc-900/40 hover:border-zinc-600' }`} title={iconOption.label} > @@ -428,8 +428,8 @@ export default function AdminRoles() { onClick={() => setFormColor(color)} className={`h-9 rounded-lg border-2 transition-all ${ formColor === color - ? "border-white ring-2 ring-white/40" - : "border-transparent" + ? 'border-white ring-2 ring-white/40' + : 'border-transparent' }`} style={{ backgroundColor: color }} title={color} @@ -487,8 +487,8 @@ export default function AdminRoles() { } className={`shrink-0 w-8 h-8 rounded-lg border-2 flex items-center justify-center transition-colors ${ formPermissions[permission.key] - ? "bg-blue-600 border-blue-600" - : "border-zinc-600 hover:border-zinc-500" + ? 'bg-blue-600 border-blue-600' + : 'border-zinc-600 hover:border-zinc-500' }`} aria-pressed={!!formPermissions[permission.key]} > @@ -505,8 +505,8 @@ export default function AdminRoles() { const roleFilterOptions = useMemo( () => [ - { value: "all", label: "All users" }, - { value: "no-role", label: "No role" }, + { value: 'all', label: 'All users' }, + { value: 'no-role', label: 'No role' }, ...validRoles.map((role) => ({ value: role.id.toString(), label: role.name, @@ -560,14 +560,14 @@ export default function AdminRoles() { -
+

Drag rows to set display priority (top = highest). Changes save on drop. @@ -604,7 +604,7 @@ export default function AdminRoles() { onDragOver={(e) => handleDragOver(e, index)} onDrop={handleDrop} className={`hover:bg-zinc-800/30 ${ - draggedId === role.id ? "opacity-60" : "" + draggedId === role.id ? 'opacity-60' : '' }`} >

@@ -690,7 +690,7 @@ export default function AdminRoles() {

)}

- {role.user_count ?? 0} members · priority{" "} + {role.user_count ?? 0} members · priority{' '} {role.priority}

@@ -950,7 +950,7 @@ export default function AdminRoles() { variant="primary" size={btnSize} > - {submitting ? "Creating…" : "Create role"} + {submitting ? 'Creating…' : 'Create role'} } @@ -961,7 +961,7 @@ export default function AdminRoles() { setShowEditModal(false)} - title={selectedRole ? `Edit ${selectedRole.name}` : "Edit role"} + title={selectedRole ? `Edit ${selectedRole.name}` : 'Edit role'} size="xl" footer={ <> @@ -978,7 +978,7 @@ export default function AdminRoles() { variant="primary" size={btnSize} > - {submitting ? "Saving…" : "Save changes"} + {submitting ? 'Saving…' : 'Save changes'} } @@ -1027,4 +1027,4 @@ export default function AdminRoles() { ); -} \ No newline at end of file +} diff --git a/src/pages/admin/AdminSessions.tsx b/src/pages/admin/AdminSessions.tsx index 050dc7fb..208329c8 100644 --- a/src/pages/admin/AdminSessions.tsx +++ b/src/pages/admin/AdminSessions.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { MdShowChart, MdGridOn, @@ -14,15 +14,15 @@ import { MdStorage, MdCellTower, MdExpandMore, -} from "react-icons/md"; -import AdminRefreshButton from "../../components/admin/AdminRefreshButton"; -import AdminLayout from "../../components/admin/AdminLayout"; -import AdminModal from "../../components/admin/AdminModal"; -import AdminPageHeader from "../../components/admin/AdminPageHeader"; -import AdminToggleSwitch from "../../components/admin/AdminToggleSwitch"; -import AdminToolbar from "../../components/admin/AdminToolbar"; -import AdminSearchInput from "../../components/admin/AdminSearchInput"; -import AdminTable from "../../components/admin/AdminTable"; +} from 'react-icons/md'; +import AdminRefreshButton from '../../components/admin/AdminRefreshButton'; +import AdminLayout from '../../components/admin/AdminLayout'; +import AdminModal from '../../components/admin/AdminModal'; +import AdminPageHeader from '../../components/admin/AdminPageHeader'; +import AdminToggleSwitch from '../../components/admin/AdminToggleSwitch'; +import AdminToolbar from '../../components/admin/AdminToolbar'; +import AdminSearchInput from '../../components/admin/AdminSearchInput'; +import AdminTable from '../../components/admin/AdminTable'; import { adminDownsizeButtonSize, adminSectionClass, @@ -36,11 +36,11 @@ import { ADMIN_TOOLBAR_MOBILE_PAIR, ADMIN_TOOLBAR_MOBILE_SEARCH, ADMIN_TOOLBAR_MOBILE_SPLIT_ITEM, -} from "../../components/admin/adminConstants"; -import Loader from "../../components/common/Loader"; -import Button from "../../components/common/Button"; -import { getIconComponent } from "../../utils/roles"; -import Dropdown from "../../components/common/Dropdown"; +} from '../../components/admin/adminConstants'; +import Loader from '../../components/common/Loader'; +import Button from '../../components/common/Button'; +import { getIconComponent } from '../../utils/roles'; +import Dropdown from '../../components/common/Dropdown'; import { fetchAdminSessions, deleteAdminSession, @@ -49,46 +49,46 @@ import { setEventMode, type AdminSession, type EventModeState, -} from "../../utils/fetch/admin"; -import ErrorScreen from "../../components/common/ErrorScreen"; +} from '../../utils/fetch/admin'; +import ErrorScreen from '../../components/common/ErrorScreen'; -type ViewMode = "grid" | "list"; -type SortBy = "date" | "airport" | "creator" | "controllers" | "flights"; +type ViewMode = 'grid' | 'list'; +type SortBy = 'date' | 'airport' | 'creator' | 'controllers' | 'flights'; const sortOptions = [ - { value: "date", label: "Sort by Date" }, - { value: "airport", label: "Sort by Airport" }, - { value: "creator", label: "Sort by Creator" }, - { value: "controllers", label: "Sort by Controllers" }, - { value: "flights", label: "Sort by Flights" }, + { value: 'date', label: 'Sort by Date' }, + { value: 'airport', label: 'Sort by Airport' }, + { value: 'creator', label: 'Sort by Creator' }, + { value: 'controllers', label: 'Sort by Controllers' }, + { value: 'flights', label: 'Sort by Flights' }, ]; function adminNetworkSessionKind( session: AdminSession -): "standard" | "pfatc" | "advanced_atc" { - if (session.is_advanced_atc) return "advanced_atc"; - if (session.is_pfatc) return "pfatc"; - return "standard"; +): 'standard' | 'pfatc' | 'advanced_atc' { + if (session.is_advanced_atc) return 'advanced_atc'; + if (session.is_pfatc) return 'pfatc'; + return 'standard'; } const adminSessionKindStyles: Record< - "standard" | "pfatc" | "advanced_atc", + 'standard' | 'pfatc' | 'advanced_atc', { hover: string; iconBg: string; iconClass: string } > = { pfatc: { - hover: "hover:border-blue-500/50", - iconBg: "bg-blue-500/20", - iconClass: "text-blue-500", + hover: 'hover:border-blue-500/50', + iconBg: 'bg-blue-500/20', + iconClass: 'text-blue-500', }, advanced_atc: { - hover: "hover:border-purple-500/50", - iconBg: "bg-purple-500/20", - iconClass: "text-purple-500", + hover: 'hover:border-purple-500/50', + iconBg: 'bg-purple-500/20', + iconClass: 'text-purple-500', }, standard: { - hover: "hover:border-green-500/50", - iconBg: "bg-green-500/20", - iconClass: "text-green-400", + hover: 'hover:border-green-500/50', + iconBg: 'bg-green-500/20', + iconClass: 'text-green-400', }, }; @@ -119,20 +119,20 @@ export default function AdminSessions() { }); const [eventModeLoading, setEventModeLoading] = useState(false); const [eventModeOpen, setEventModeOpen] = useState(false); - const [viewMode, setViewMode] = useState("grid"); - const [sortBy, setSortBy] = useState("date"); + const [viewMode, setViewMode] = useState('grid'); + const [sortBy, setSortBy] = useState('date'); const [selectedSession, setSelectedSession] = useState( null ); const [showModal, setShowModal] = useState(false); const [toast, setToast] = useState<{ message: string; - type: "success" | "error" | "info"; + type: 'success' | 'error' | 'info'; } | null>(null); const [page, setPage] = useState(1); const [limit] = useState(100); const [totalPages, setTotalPages] = useState(1); - const [search, setSearch] = useState(searchParams.get("search") || ""); + const [search, setSearch] = useState(searchParams.get('search') || ''); useEffect(() => { fetchSessions(); @@ -154,11 +154,11 @@ export default function AdminSessions() { const updated = await setEventMode({ [field]: !eventMode[field] }); setEventModeState(updated); setToast({ - message: `${field === "pfatcEventMode" ? "PFATC" : "AATC"} event mode ${updated[field] ? "enabled" : "disabled"}`, - type: "success", + message: `${field === 'pfatcEventMode' ? 'PFATC' : 'AATC'} event mode ${updated[field] ? 'enabled' : 'disabled'}`, + type: 'success', }); } catch { - setToast({ message: "Failed to update event mode", type: "error" }); + setToast({ message: 'Failed to update event mode', type: 'error' }); } finally { setEventModeLoading(false); } @@ -180,11 +180,11 @@ export default function AdminSessions() { setTotalPages(data.pagination.pages); } catch (err) { const errorMessage = - err instanceof Error ? err.message : "Failed to fetch sessions"; + err instanceof Error ? err.message : 'Failed to fetch sessions'; setError(errorMessage); setToast({ message: errorMessage, - type: "error", + type: 'error', }); } finally { setLoading(false); @@ -196,19 +196,19 @@ export default function AdminSessions() { filtered.sort((a, b) => { switch (sortBy) { - case "date": + case 'date': return ( new Date(b.created_at).getTime() - new Date(a.created_at).getTime() ); - case "airport": + case 'airport': return a.airport_icao.localeCompare(b.airport_icao); - case "creator": + case 'creator': return (a.username || a.created_by).localeCompare( b.username || b.created_by ); - case "controllers": + case 'controllers': return (b.active_user_count || 0) - (a.active_user_count || 0); - case "flights": + case 'flights': return (b.flight_count || 0) - (a.flight_count || 0); default: return 0; @@ -222,18 +222,18 @@ export default function AdminSessions() { try { await logSessionJoin(session.session_id); const url = `${window.location.origin}/view/${session.session_id}/?accessId=${session.access_id}`; - window.open(url, "_blank"); + window.open(url, '_blank'); } catch (err) { - console.error("Error logging session join:", err); + console.error('Error logging session join:', err); const url = `${window.location.origin}/view/${session.session_id}/?accessId=${session.access_id}`; - window.open(url, "_blank"); + window.open(url, '_blank'); } }; const handleDeleteSession = async (sessionId: string) => { if ( !confirm( - "Are you sure you want to delete this session? This action cannot be undone." + 'Are you sure you want to delete this session? This action cannot be undone.' ) ) { return; @@ -242,8 +242,8 @@ export default function AdminSessions() { try { await deleteAdminSession(sessionId); setToast({ - message: "Session deleted successfully", - type: "success", + message: 'Session deleted successfully', + type: 'success', }); setShowModal(false); setSelectedSession(null); @@ -251,8 +251,8 @@ export default function AdminSessions() { } catch (err) { setToast({ message: - err instanceof Error ? err.message : "Failed to delete session", - type: "error", + err instanceof Error ? err.message : 'Failed to delete session', + type: 'error', }); } }; @@ -262,8 +262,8 @@ export default function AdminSessions() { const now = new Date(); const diffMs = now.getTime() - date.getTime(); - if (isNaN(diffMs) || isNaN(date.getTime())) return "Unknown"; - if (diffMs < 0) return "Just now"; + if (isNaN(diffMs) || isNaN(date.getTime())) return 'Unknown'; + if (diffMs < 0) return 'Just now'; const diffSecs = Math.floor(diffMs / 1000); const diffMins = Math.floor(diffSecs / 60); @@ -274,19 +274,19 @@ export default function AdminSessions() { if (diffHours > 0) return `${diffHours}h ago`; if (diffMins > 0) return `${diffMins}m ago`; if (diffSecs > 0) return `${diffSecs}s ago`; - return "Just now"; + return 'Just now'; }; const formatDateTime = (dateString: string) => { const date = new Date(dateString); - return date.toLocaleString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZone: "UTC", - timeZoneName: "short", + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', + timeZoneName: 'short', }); }; @@ -297,12 +297,12 @@ export default function AdminSessions() { ) => { if (!avatar) return null; - if (avatar.startsWith("http")) { + if (avatar.startsWith('http')) { return avatar; } - const isAnimated = avatar.startsWith("a_"); - const extension = isAnimated ? "gif" : "png"; + const isAnimated = avatar.startsWith('a_'); + const extension = isAnimated ? 'gif' : 'png'; return `https://cdn.discordapp.com/avatars/${userId}/${avatar}.${extension}?size=${size}`; }; @@ -323,7 +323,7 @@ export default function AdminSessions() { .iconBg }`} > - {adminNetworkSessionKind(session) === "standard" ? ( + {adminNetworkSessionKind(session) === 'standard' ? ( @@ -343,7 +343,7 @@ export default function AdminSessions() {
@@ -619,10 +619,10 @@ export default function AdminSessions() {
@@ -636,14 +636,14 @@ export default function AdminSessions() {

{eventMode.pfatcEventMode - ? "Only PFATC Sector Controllers can create PFATC sessions" - : "Anyone can create PFATC sessions"} + ? 'Only PFATC Sector Controllers can create PFATC sessions' + : 'Anyone can create PFATC sessions'}

handleToggleEventMode("pfatcEventMode")} + onChange={() => handleToggleEventMode('pfatcEventMode')} disabled={eventModeLoading} aria-label="Toggle PFATC event mode" /> @@ -652,10 +652,10 @@ export default function AdminSessions() {
@@ -669,14 +669,14 @@ export default function AdminSessions() {

{eventMode.aatcEventMode - ? "Only AATC Sector Controllers can create Advanced ATC sessions" - : "Anyone can create Advanced ATC sessions"} + ? 'Only AATC Sector Controllers can create Advanced ATC sessions' + : 'Anyone can create Advanced ATC sessions'}

handleToggleEventMode("aatcEventMode")} + onChange={() => handleToggleEventMode('aatcEventMode')} disabled={eventModeLoading} aria-label="Toggle AATC event mode" /> @@ -698,11 +698,11 @@ export default function AdminSessions() { ) : filteredSessions.length === 0 ? (
{search - ? "No sessions found matching your search." - : "No active sessions."} + ? 'No sessions found matching your search.' + : 'No active sessions.'}
) : ( - <>{viewMode === "grid" ? renderSessionGrid() : renderSessionList()} + <>{viewMode === 'grid' ? renderSessionGrid() : renderSessionList()} )}
@@ -742,7 +742,7 @@ export default function AdminSessions() { onClick={() => handleJoinSession(selectedSession)} className="flex items-center justify-center gap-2" variant="primary" - size={adminDownsizeButtonSize("sm")} + size={adminDownsizeButtonSize('sm')} > Join Session @@ -751,7 +751,7 @@ export default function AdminSessions() { onClick={() => handleDeleteSession(selectedSession.session_id)} className="flex items-center justify-center gap-2" variant="danger" - size={adminDownsizeButtonSize("sm")} + size={adminDownsizeButtonSize('sm')} > Delete Session @@ -771,7 +771,7 @@ export default function AdminSessions() { ].iconBg }`} > - {adminNetworkSessionKind(selectedSession) === "standard" ? ( + {adminNetworkSessionKind(selectedSession) === 'standard' ? ( @@ -807,7 +807,7 @@ export default function AdminSessions() { Active Runway - {selectedSession.active_runway || "N/A"} + {selectedSession.active_runway || 'N/A'}
@@ -850,7 +850,7 @@ export default function AdminSessions() { )}
- {selectedSession.username || "Unknown User"} + {selectedSession.username || 'Unknown User'}
{selectedSession.created_by} @@ -888,14 +888,14 @@ export default function AdminSessions() { alt={user.username} className="w-8 h-8 rounded-full transition-all" style={{ - border: `2px solid ${highestRole?.color || "#71717a"}`, + border: `2px solid ${highestRole?.color || '#71717a'}`, }} /> ) : (
@@ -926,12 +926,12 @@ export default function AdminSessions() {
- {user.username}{" "} + {user.username}{' '} ({user.id})
- {user.position && user.position !== "POSITION" && ( + {user.position && user.position !== 'POSITION' && (
{user.position}
@@ -948,4 +948,4 @@ export default function AdminSessions() { ); -} \ No newline at end of file +} diff --git a/src/pages/admin/AdminTesters.tsx b/src/pages/admin/AdminTesters.tsx index bd37cb08..c7fb803e 100644 --- a/src/pages/admin/AdminTesters.tsx +++ b/src/pages/admin/AdminTesters.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from 'react'; import { MdPeople, MdDelete, @@ -7,15 +7,15 @@ import { MdCheck, MdGppBad, MdNotes, -} from "react-icons/md"; -import AdminLayout from "../../components/admin/AdminLayout"; -import AdminPageHeader from "../../components/admin/AdminPageHeader"; -import AdminToolbar from "../../components/admin/AdminToolbar"; -import AdminSearchInput from "../../components/admin/AdminSearchInput"; -import AdminIconInput from "../../components/admin/AdminIconInput"; -import AdminTable from "../../components/admin/AdminTable"; -import AdminStatStrip from "../../components/admin/AdminStatStrip"; -import AdminSectionTitle from "../../components/admin/AdminSectionTitle"; +} from 'react-icons/md'; +import AdminLayout from '../../components/admin/AdminLayout'; +import AdminPageHeader from '../../components/admin/AdminPageHeader'; +import AdminToolbar from '../../components/admin/AdminToolbar'; +import AdminSearchInput from '../../components/admin/AdminSearchInput'; +import AdminIconInput from '../../components/admin/AdminIconInput'; +import AdminTable from '../../components/admin/AdminTable'; +import AdminStatStrip from '../../components/admin/AdminStatStrip'; +import AdminSectionTitle from '../../components/admin/AdminSectionTitle'; import { adminDownsizeButtonSize, adminSectionClass, @@ -23,37 +23,37 @@ import { ADMIN_TABLE_HEAD, ADMIN_TH, ADMIN_TD, -} from "../../components/admin/adminConstants"; -import Loader from "../../components/common/Loader"; -import Button from "../../components/common/Button"; -import ErrorScreen from "../../components/common/ErrorScreen"; +} from '../../components/admin/adminConstants'; +import Loader from '../../components/common/Loader'; +import Button from '../../components/common/Button'; +import ErrorScreen from '../../components/common/ErrorScreen'; import { fetchTesters, addTester, removeTester, updateTesterSettings, type Tester, -} from "../../utils/fetch/testers"; -import { getTesterSettings } from "../../utils/fetch/data"; +} from '../../utils/fetch/testers'; +import { getTesterSettings } from '../../utils/fetch/data'; export default function AdminTesters() { const [testers, setTesters] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); + const [searchTerm, setSearchTerm] = useState(''); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [totalTesters, setTotalTesters] = useState(0); const [addingTester, setAddingTester] = useState(false); const [removingTester, setRemovingTester] = useState(null); - const [newTesterUserId, setNewTesterUserId] = useState(""); - const [newTesterNotes, setNewTesterNotes] = useState(""); + const [newTesterUserId, setNewTesterUserId] = useState(''); + const [newTesterNotes, setNewTesterNotes] = useState(''); const [gateEnabled, setGateEnabled] = useState(true); const [updatingGate, setUpdatingGate] = useState(false); const [toast, setToast] = useState<{ message: string; - type: "success" | "error" | "info"; + type: 'success' | 'error' | 'info'; } | null>(null); const fetchTestersData = async () => { @@ -71,7 +71,7 @@ export default function AdminTesters() { setTotalTesters(testersData.pagination.total); setGateEnabled(settings.tester_gate_enabled); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to fetch testers"); + setError(err instanceof Error ? err.message : 'Failed to fetch testers'); } finally { setLoading(false); } @@ -83,7 +83,7 @@ export default function AdminTesters() { const handleAddTester = async () => { if (!newTesterUserId.trim()) { - setToast({ message: "User ID is required", type: "error" }); + setToast({ message: 'User ID is required', type: 'error' }); return; } @@ -91,14 +91,14 @@ export default function AdminTesters() { setAddingTester(true); await addTester(newTesterUserId.trim(), newTesterNotes.trim()); - setToast({ message: "Tester added successfully", type: "success" }); - setNewTesterUserId(""); - setNewTesterNotes(""); + setToast({ message: 'Tester added successfully', type: 'success' }); + setNewTesterUserId(''); + setNewTesterNotes(''); fetchTestersData(); } catch (err) { setToast({ - message: err instanceof Error ? err.message : "Failed to add tester", - type: "error", + message: err instanceof Error ? err.message : 'Failed to add tester', + type: 'error', }); } finally { setAddingTester(false); @@ -111,14 +111,14 @@ export default function AdminTesters() { await removeTester(userId); setToast({ - message: "Tester removed successfully", - type: "success", + message: 'Tester removed successfully', + type: 'success', }); fetchTestersData(); } catch (err) { setToast({ - message: err instanceof Error ? err.message : "Failed to remove tester", - type: "error", + message: err instanceof Error ? err.message : 'Failed to remove tester', + type: 'error', }); } finally { setRemovingTester(null); @@ -132,14 +132,14 @@ export default function AdminTesters() { setGateEnabled(!gateEnabled); setToast({ - message: `Tester gate ${!gateEnabled ? "enabled" : "disabled"}`, - type: "success", + message: `Tester gate ${!gateEnabled ? 'enabled' : 'disabled'}`, + type: 'success', }); } catch (err) { setToast({ message: - err instanceof Error ? err.message : "Failed to update settings", - type: "error", + err instanceof Error ? err.message : 'Failed to update settings', + type: 'error', }); } finally { setUpdatingGate(false); @@ -155,17 +155,17 @@ export default function AdminTesters() { /> -
+
Tester gate
{gateEnabled ? ( @@ -176,27 +176,27 @@ export default function AdminTesters() {

- {gateEnabled ? "Gate enabled" : "Gate disabled"} + {gateEnabled ? 'Gate enabled' : 'Gate disabled'}

{gateEnabled - ? "Only approved testers can access the application" - : "All users can access the application"} + ? 'Only approved testers can access the application' + : 'All users can access the application'}

@@ -321,13 +321,13 @@ export default function AdminTesters() {
{new Date(tester.created_at).toLocaleDateString()} {tester.notes || "—"}{tester.notes || '—'}
{!tableUser.is_admin && (
)} -
VPN: {tableUser.is_vpn ? "Yes" : "No"}
+
VPN: {tableUser.is_vpn ? 'Yes' : 'No'}
Sessions: {tableUser.current_sessions_count ?? 0}
Role: @@ -883,7 +883,7 @@ export default function AdminUsers() { Cached: Redis cache status {!tableUser.is_admin && (
- Current Roles:{" "} + Current Roles:{' '} {selectedUserForRole.roles && selectedUserForRole.roles.length > 0 ? (
@@ -1033,7 +1033,7 @@ export default function AdminUsers() { })}
) : ( - "No Roles" + 'No Roles' )}
@@ -1044,7 +1044,7 @@ export default function AdminUsers() { @@ -1059,7 +1059,7 @@ export default function AdminUsers() { ]} value="" onChange={(val) => { - if (val !== "") handleAssignRole(parseInt(val)); + if (val !== '') handleAssignRole(parseInt(val)); }} size="sm" className="w-full" @@ -1156,7 +1156,7 @@ export default function AdminUsers() { string, boolean >, - "Departure" + 'Departure' )} {renderTableColumns( selectedUser.settings @@ -1164,7 +1164,7 @@ export default function AdminUsers() { string, boolean >, - "Arrivals" + 'Arrivals' )} @@ -1175,31 +1175,31 @@ export default function AdminUsers() {

- Tutorial:{" "} + Tutorial:{' '} - {selectedUser.settings.tutorialCompleted ? "Yes" : "No"} + {selectedUser.settings.tutorialCompleted ? 'Yes' : 'No'}

Linked accounts on profile: - {" "} + {' '} {selectedUser.settings.displayLinkedAccountsOnProfile - ? "Yes" - : "No"} + ? 'Yes' + : 'No'}

@@ -1234,4 +1234,4 @@ export default function AdminUsers() { ); -} \ No newline at end of file +} diff --git a/src/pages/admin/AdminWebsockets.tsx b/src/pages/admin/AdminWebsockets.tsx index bf3b0209..24281838 100644 --- a/src/pages/admin/AdminWebsockets.tsx +++ b/src/pages/admin/AdminWebsockets.tsx @@ -1,28 +1,28 @@ -import { useCallback, useEffect, useState } from "react"; -import { MdCable } from "react-icons/md"; -import AdminRefreshButton from "../../components/admin/AdminRefreshButton"; -import AdminLayout from "../../components/admin/AdminLayout"; -import AdminPageHeader from "../../components/admin/AdminPageHeader"; -import AdminStatStrip from "../../components/admin/AdminStatStrip"; -import { adminSectionClass } from "../../components/admin/adminConstants"; -import { AdminSparkline } from "../../components/admin/AdminChart"; -import Loader from "../../components/common/Loader"; -import ErrorScreen from "../../components/common/ErrorScreen"; +import { useCallback, useEffect, useState } from 'react'; +import { MdCable } from 'react-icons/md'; +import AdminRefreshButton from '../../components/admin/AdminRefreshButton'; +import AdminLayout from '../../components/admin/AdminLayout'; +import AdminPageHeader from '../../components/admin/AdminPageHeader'; +import AdminStatStrip from '../../components/admin/AdminStatStrip'; +import { adminSectionClass } from '../../components/admin/adminConstants'; +import { AdminSparkline } from '../../components/admin/AdminChart'; +import Loader from '../../components/common/Loader'; +import ErrorScreen from '../../components/common/ErrorScreen'; import { fetchAdminWebsocketStats, type AdminWebsocketStatsResponse, -} from "../../utils/fetch/admin"; +} from '../../utils/fetch/admin'; const NS_COLORS: Record = { - flights: "#60a5fa", - chat: "#34d399", - "global-chat": "#a78bfa", - overview: "#fbbf24", - arrivals: "#f472b6", - "session-users": "#2dd4bf", - "sector-controller": "#fb7185", - "voice-chat": "#94a3b8", - notifications: "#38bdf8", + flights: '#60a5fa', + chat: '#34d399', + 'global-chat': '#a78bfa', + overview: '#fbbf24', + arrivals: '#f472b6', + 'session-users': '#2dd4bf', + 'sector-controller': '#fb7185', + 'voice-chat': '#94a3b8', + notifications: '#38bdf8', }; export default function AdminWebsockets() { @@ -36,7 +36,7 @@ export default function AdminWebsockets() { setData(await fetchAdminWebsocketStats()); } catch (err) { setError( - err instanceof Error ? err.message : "Failed to load websocket stats" + err instanceof Error ? err.message : 'Failed to load websocket stats' ); } finally { setLoading(false); @@ -85,10 +85,10 @@ export default function AdminWebsockets() { <>
{data.namespaces.map((ns) => { - const color = NS_COLORS[ns.id] ?? "#60a5fa"; + const color = NS_COLORS[ns.id] ?? '#60a5fa'; const pct = Math.min(100, (ns.connected / maxConnected) * 100); return (
@@ -151,4 +151,4 @@ export default function AdminWebsockets() { ) : null} ); -} \ No newline at end of file +} diff --git a/src/pages/developers/Console.tsx b/src/pages/developers/Console.tsx index 3ec0cd82..ec4df003 100644 --- a/src/pages/developers/Console.tsx +++ b/src/pages/developers/Console.tsx @@ -1,26 +1,36 @@ -import { Link } from "react-router-dom"; -import { useCallback, useMemo, useState } from "react"; -import { Loader2, Eye, EyeOff, ChevronRight, ChevronDown, Search } from "lucide-react"; +import { Link } from 'react-router-dom'; +import { useCallback, useMemo, useState } from 'react'; +import { + Loader2, + Eye, + EyeOff, + ChevronRight, + ChevronDown, + Search, +} from 'lucide-react'; import { DeveloperRequestsAreaChart, DeveloperScopeDonutChart, -} from "../../components/developers/DeveloperUsageCharts"; -import DeveloperPillSegmentedControl from "./DeveloperPillSegmentedControl"; -import { cardClass } from "./constants"; -import { useDeveloperPortal, type DeveloperUsageChartWindow } from "./developerPortalContext"; +} from '../../components/developers/DeveloperUsageCharts'; +import DeveloperPillSegmentedControl from './DeveloperPillSegmentedControl'; +import { cardClass } from './constants'; +import { + useDeveloperPortal, + type DeveloperUsageChartWindow, +} from './developerPortalContext'; function formatMaskedIp(ip: string): string { - if (ip.includes(".") && !ip.includes(":")) { - const parts = ip.split("."); + if (ip.includes('.') && !ip.includes(':')) { + const parts = ip.split('.'); if (parts.length === 4) { return `${parts[0]}.${parts[1]}.*.*`; } } - if (ip.includes(":")) { - const first = ip.split(":").find((s) => s.length > 0); - return first ? `${first}:****` : "****"; + if (ip.includes(':')) { + const first = ip.split(':').find((s) => s.length > 0); + return first ? `${first}:****` : '****'; } - return "••••••••"; + return '••••••••'; } export default function DeveloperConsole() { @@ -34,9 +44,13 @@ export default function DeveloperConsole() { scopeLabelMap, } = useDeveloperPortal(); - const [revealedCallIds, setRevealedCallIds] = useState>(new Set()); - const [expandedCallIds, setExpandedCallIds] = useState>(new Set()); - const [callsSearch, setCallsSearch] = useState(""); + const [revealedCallIds, setRevealedCallIds] = useState>( + new Set() + ); + const [expandedCallIds, setExpandedCallIds] = useState>( + new Set() + ); + const [callsSearch, setCallsSearch] = useState(''); const callsQuery = callsSearch.trim().toLowerCase(); const filteredRecent = useMemo(() => { @@ -51,9 +65,9 @@ export default function DeveloperConsole() { scopeLabel, String(r.statusCode), String(r.durationMs), - r.clientIp ?? "", + r.clientIp ?? '', ] - .join(" ") + .join(' ') .toLowerCase(); return hay.includes(callsQuery); }); @@ -78,10 +92,10 @@ export default function DeveloperConsole() { }, []); const rangeButtons: { id: DeveloperUsageChartWindow; label: string }[] = [ - { id: "24h", label: "24h" }, - { id: 7, label: "7d" }, - { id: 14, label: "14d" }, - { id: 30, label: "30d" }, + { id: '24h', label: '24h' }, + { id: 7, label: '7d' }, + { id: 14, label: '14d' }, + { id: 30, label: '30d' }, ]; if (loading) { @@ -95,9 +109,12 @@ export default function DeveloperConsole() { if (!profileActive) { return (
-

Usage dashboard

+

+ Usage dashboard +

- Charts and request logs appear here once your developer application is approved. + Charts and request logs appear here once your developer application is + approved.

-

Request volume

+

+ Request volume +

{summary?.totalInRange ?? 0} requests - {summary?.granularity === "hour" - ? " in the rolling window." - : " in the selected calendar days."} + {summary?.granularity === 'hour' + ? ' in the rolling window.' + : ' in the selected calendar days.'}

@@ -146,10 +165,15 @@ export default function DeveloperConsole() {
{!summary?.byScope.length ? (
-

No usage in this period yet.

+

+ No usage in this period yet. +

) : ( - + )}
@@ -157,10 +181,13 @@ export default function DeveloperConsole() {
-

Latest API calls

+

+ Latest API calls +

- One line per call for a quick scan. Click a row to expand scope, full path, timing, - and IP (use the eye to show or hide the full address). + One line per call for a quick scan. Click a row to expand scope, + full path, timing, and IP (use the eye to show or hide the full + address).

@@ -182,14 +209,16 @@ export default function DeveloperConsole() {
) : recent.length === 0 ? (
-

No calls logged in this period yet.

+

+ No calls logged in this period yet. +

) : filteredRecent.length === 0 ? (

No calls match your search.

); -} \ No newline at end of file +} diff --git a/src/pages/developers/DeveloperLayout.tsx b/src/pages/developers/DeveloperLayout.tsx index 9528d2a1..384ee4d6 100644 --- a/src/pages/developers/DeveloperLayout.tsx +++ b/src/pages/developers/DeveloperLayout.tsx @@ -1,37 +1,41 @@ -import { Suspense, useEffect, useState } from "react"; -import { Outlet, useSearchParams } from "react-router-dom"; -import { Code2, RefreshCw, AlertCircle, Loader2 } from "lucide-react"; -import Navbar from "../../components/Navbar"; -import DeveloperSubnav from "./DeveloperSubnav"; -import { API_EXT_BASE } from "./constants"; -import { DeveloperPortalProvider, useDeveloperPortal } from "./developerPortalContext"; +import { Suspense, useEffect, useState } from 'react'; +import { Outlet, useSearchParams } from 'react-router-dom'; +import { Code2, RefreshCw, AlertCircle, Loader2 } from 'lucide-react'; +import Navbar from '../../components/Navbar'; +import DeveloperSubnav from './DeveloperSubnav'; +import { API_EXT_BASE } from './constants'; +import { + DeveloperPortalProvider, + useDeveloperPortal, +} from './developerPortalContext'; const notifyRemovedBannerClass = - "mb-6 flex items-start gap-2 rounded-2xl border border-emerald-800/45 bg-emerald-950/40 px-4 py-3 text-emerald-100 text-sm ring-1 ring-emerald-900/30"; + 'mb-6 flex items-start gap-2 rounded-2xl border border-emerald-800/45 bg-emerald-950/40 px-4 py-3 text-emerald-100 text-sm ring-1 ring-emerald-900/30'; const notifyWarnBannerClass = - "mb-6 flex items-start gap-2 rounded-2xl border border-amber-800/40 bg-amber-950/35 px-4 py-3 text-amber-100 text-sm ring-1 ring-amber-900/25"; + 'mb-6 flex items-start gap-2 rounded-2xl border border-amber-800/40 bg-amber-950/35 px-4 py-3 text-amber-100 text-sm ring-1 ring-amber-900/25'; function DeveloperShell() { - const { error, loading, dashLoading, refresh, loadApplication } = useDeveloperPortal(); + const { error, loading, dashLoading, refresh, loadApplication } = + useDeveloperPortal(); const [refreshSpinOnce, setRefreshSpinOnce] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); const [notifyEmailBanner, setNotifyEmailBanner] = useState< - "removed" | "invalid" | "stale" | null + 'removed' | 'invalid' | 'stale' | null >(null); useEffect(() => { - const v = searchParams.get("notifyEmailRemoved"); + const v = searchParams.get('notifyEmailRemoved'); if (v === null) return; const next = new URLSearchParams(searchParams); - next.delete("notifyEmailRemoved"); + next.delete('notifyEmailRemoved'); setSearchParams(next, { replace: true }); - if (v === "1") { - setNotifyEmailBanner("removed"); + if (v === '1') { + setNotifyEmailBanner('removed'); void loadApplication(); - } else if (v === "stale") { - setNotifyEmailBanner("stale"); + } else if (v === 'stale') { + setNotifyEmailBanner('stale'); } else { - setNotifyEmailBanner("invalid"); + setNotifyEmailBanner('invalid'); } }, [searchParams, setSearchParams, loadApplication]); @@ -50,13 +54,18 @@ function DeveloperShell() {
- Developers BETA + Developers{' '} + BETA
-

Developer API

+

+ Developer API +

- Base URL:{" "} - {API_EXT_BASE} + Base URL:{' '} + + {API_EXT_BASE} +

)} - {notifyEmailBanner === "stale" && ( + {notifyEmailBanner === 'stale' && (
- That unsubscribe link is no longer valid, or your notification address was already - cleared. + That unsubscribe link is no longer valid, or your notification + address was already cleared.
)} - {notifyEmailBanner === "invalid" && ( + {notifyEmailBanner === 'invalid' && (
This unsubscribe link is invalid or has expired. @@ -149,4 +158,4 @@ export default function DeveloperLayout() { ); -} \ No newline at end of file +} diff --git a/src/pages/developers/DeveloperPillSegmentedControl.tsx b/src/pages/developers/DeveloperPillSegmentedControl.tsx index 862c9540..1c31a3f4 100644 --- a/src/pages/developers/DeveloperPillSegmentedControl.tsx +++ b/src/pages/developers/DeveloperPillSegmentedControl.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo } from 'react'; export type PillTab = { id: T; @@ -9,16 +9,18 @@ type Props = { tabs: PillTab[]; value: T; onChange: (id: T) => void; - "aria-label": string; + 'aria-label': string; className?: string; }; -export default function DeveloperPillSegmentedControl({ +export default function DeveloperPillSegmentedControl< + T extends string | number, +>({ tabs, value, onChange, - "aria-label": ariaLabel, - className = "", + 'aria-label': ariaLabel, + className = '', }: Props) { const activeIndex = useMemo(() => { const i = tabs.findIndex((t) => t.id === value); @@ -29,7 +31,7 @@ export default function DeveloperPillSegmentedControl const btnClass = (active: boolean) => `relative z-10 flex flex-1 min-w-0 items-center justify-center rounded-full px-2 py-2 text-xs font-semibold transition-colors sm:px-3 ${ - active ? "text-white" : "text-zinc-400 hover:text-zinc-200" + active ? 'text-white' : 'text-zinc-400 hover:text-zinc-200' }`; return ( @@ -41,7 +43,8 @@ export default function DeveloperPillSegmentedControl
0 ? `calc((100% - 0.5rem) / ${tabCount})` : undefined, + width: + tabCount > 0 ? `calc((100% - 0.5rem) / ${tabCount})` : undefined, left: tabCount > 0 ? `calc(0.25rem + ${activeIndex} * ((100% - 0.5rem) / ${tabCount}))` @@ -66,4 +69,4 @@ export default function DeveloperPillSegmentedControl })}
); -} \ No newline at end of file +} diff --git a/src/pages/developers/DeveloperSubnav.tsx b/src/pages/developers/DeveloperSubnav.tsx index b9eacea4..81f28d26 100644 --- a/src/pages/developers/DeveloperSubnav.tsx +++ b/src/pages/developers/DeveloperSubnav.tsx @@ -1,6 +1,6 @@ -import { useMemo } from "react"; -import { NavLink, useLocation } from "react-router-dom"; -import { LayoutDashboard, KeyRound, BookOpen, Home } from "lucide-react"; +import { useMemo } from 'react'; +import { NavLink, useLocation } from 'react-router-dom'; +import { LayoutDashboard, KeyRound, BookOpen, Home } from 'lucide-react'; const TAB_COUNT = 4; @@ -8,15 +8,15 @@ export default function DeveloperSubnav() { const { pathname } = useLocation(); const activeIndex = useMemo(() => { - if (pathname.includes("/developers/docs")) return 3; - if (pathname.includes("/developers/keys")) return 2; - if (pathname.includes("/developers/console")) return 1; + if (pathname.includes('/developers/docs')) return 3; + if (pathname.includes('/developers/keys')) return 2; + if (pathname.includes('/developers/console')) return 1; return 0; }, [pathname]); const linkClass = (isActive: boolean) => `relative z-10 flex flex-1 items-center justify-center gap-1.5 rounded-full px-2 py-2 text-sm font-semibold transition-colors sm:gap-2 sm:px-3 ${ - isActive ? "text-white" : "text-zinc-400 hover:text-zinc-200" + isActive ? 'text-white' : 'text-zinc-400 hover:text-zinc-200' }`; return ( @@ -32,24 +32,37 @@ export default function DeveloperSubnav() { }} aria-hidden /> - linkClass(isActive)}> + linkClass(isActive)} + > Overview - linkClass(isActive)}> + linkClass(isActive)} + > Usage - linkClass(isActive)}> + linkClass(isActive)} + > API keys Keys - linkClass(isActive)}> + linkClass(isActive)} + > API reference Docs ); -} \ No newline at end of file +} diff --git a/src/pages/developers/Docs.tsx b/src/pages/developers/Docs.tsx index 23b0c7dc..42d2f08c 100644 --- a/src/pages/developers/Docs.tsx +++ b/src/pages/developers/Docs.tsx @@ -1,11 +1,21 @@ -import { useEffect, useMemo, useState } from "react"; -import { BookOpen, Copy, Check, Loader2, ChevronRight, Search } from "lucide-react"; -import type { DeveloperApiDocEndpoint, DeveloperApiPublicSpec } from "../../types/developerApiSpec"; -import { fetchDeveloperApiDocs } from "../../utils/fetch/developer"; -import { cardClass } from "./constants"; +import { useEffect, useMemo, useState } from 'react'; +import { + BookOpen, + Copy, + Check, + Loader2, + ChevronRight, + Search, +} from 'lucide-react'; +import type { + DeveloperApiDocEndpoint, + DeveloperApiPublicSpec, +} from '../../types/developerApiSpec'; +import { fetchDeveloperApiDocs } from '../../utils/fetch/developer'; +import { cardClass } from './constants'; function endpointBodySummary(summary: string): string { - const idx = summary.indexOf(" — "); + const idx = summary.indexOf(' — '); if (idx !== -1) return summary.slice(idx + 3).trim(); return summary.trim(); } @@ -23,14 +33,14 @@ function endpointMatchesQuery(e: DeveloperApiDocEndpoint, q: string): boolean { e.fullUrlExample, e.responseSummary, e.responseContentType, - e.requestBodySummary ?? "", - e.requestBodyExampleJson ?? "", + e.requestBodySummary ?? '', + e.requestBodyExampleJson ?? '', ]; for (const p of e.pathParams ?? []) { - chunks.push(p.name, p.description, p.example ?? ""); + chunks.push(p.name, p.description, p.example ?? ''); } for (const qe of e.queryParams ?? []) { - chunks.push(qe.name, qe.description, qe.example ?? ""); + chunks.push(qe.name, qe.description, qe.example ?? ''); } for (const h of e.requestHeaders) { chunks.push(h.name, h.description); @@ -40,32 +50,38 @@ function endpointMatchesQuery(e: DeveloperApiDocEndpoint, q: string): boolean { function methodStyle(method: string) { const m = method.toUpperCase(); - if (m === "GET") + if (m === 'GET') return { - pill: "bg-sky-950/55 text-sky-400/90 border-sky-800/70", + pill: 'bg-sky-950/55 text-sky-400/90 border-sky-800/70', }; - if (m === "POST") + if (m === 'POST') return { - pill: "bg-amber-950/50 text-amber-300/90 border-amber-900/55", + pill: 'bg-amber-950/50 text-amber-300/90 border-amber-900/55', }; - if (m === "PUT" || m === "PATCH") + if (m === 'PUT' || m === 'PATCH') return { - pill: "bg-violet-950/50 text-violet-300/90 border-violet-900/55", + pill: 'bg-violet-950/50 text-violet-300/90 border-violet-900/55', }; - if (m === "DELETE") + if (m === 'DELETE') return { - pill: "bg-rose-950/50 text-rose-300/90 border-rose-900/55", + pill: 'bg-rose-950/50 text-rose-300/90 border-rose-900/55', }; return { - pill: "bg-zinc-800/80 text-zinc-300 border-zinc-600/70", + pill: 'bg-zinc-800/80 text-zinc-300 border-zinc-600/70', }; } -function ParamTable({ title, rows }: { title: string; rows: { cells: string[] }[] }) { +function ParamTable({ + title, + rows, +}: { + title: string; + rows: { cells: string[] }[]; +}) { if (rows.length === 0) return null; const showTitle = Boolean(title?.trim()); return ( -
+
{showTitle ? (

{title.trim()} @@ -81,8 +97,8 @@ function ParamTable({ title, rows }: { title: string; rows: { cells: string[] }[ key={j} className={`px-3 py-2.5 align-top leading-snug ${ j === 0 - ? "font-mono text-zinc-200 w-[28%] shrink-0 text-[13px]" - : "text-zinc-400" + ? 'font-mono text-zinc-200 w-[28%] shrink-0 text-[13px]' + : 'text-zinc-400' }`} > {cell} @@ -108,13 +124,16 @@ function EndpointCard({ }) { const rowKey = e.endpointKey ?? `${e.method}:${e.pathTemplate}`; const ms = methodStyle(e.method); - const base = import.meta.env.VITE_SERVER_URL || "https://your-host.example.com"; + const base = + import.meta.env.VITE_SERVER_URL || 'https://your-host.example.com'; const pathRows = e.pathParams?.map((p) => ({ cells: [ p.name, - [p.description, p.example ? `e.g. ${p.example}` : ""].filter(Boolean).join(" · "), + [p.description, p.example ? `e.g. ${p.example}` : ''] + .filter(Boolean) + .join(' · '), ], })) ?? []; @@ -122,12 +141,15 @@ function EndpointCard({ e.queryParams?.map((q) => ({ cells: [ q.name, - `${q.required ? "Required" : "Optional"} · ${q.description}${q.example ? ` · e.g. ${q.example}` : ""}`, + `${q.required ? 'Required' : 'Optional'} · ${q.description}${q.example ? ` · e.g. ${q.example}` : ''}`, ], })) ?? []; const headerRows = e.requestHeaders.map((h) => ({ - cells: [h.name, `${h.required ? "Required" : "Optional"} · ${h.description}`], + cells: [ + h.name, + `${h.required ? 'Required' : 'Optional'} · ${h.description}`, + ], })); return ( @@ -146,11 +168,17 @@ function EndpointCard({ {e.scopeId}

-

{e.title}

+

+ {e.title} +

{(() => { const body = endpointBodySummary(e.summary); if (!body) return null; - return

{body}

; + return ( +

+ {body} +

+ ); })()}
@@ -160,10 +188,16 @@ function EndpointCard({
-

Response

+

+ Response +

- {e.responseContentType} - {e.responseSummary} + + {e.responseContentType} + + + {e.responseSummary} +
@@ -175,7 +209,9 @@ function EndpointCard({ Request body

{e.requestBodySummary ? ( -

{e.requestBodySummary}

+

+ {e.requestBodySummary} +

) : null} {e.requestBodyExampleJson ? (
@@ -193,8 +229,11 @@ function EndpointCard({
                   type="button"
                   onClick={() =>
                     void onCopy(
-                      e.exampleCurl.replace("https://your-host.example.com", base),
-                      rowKey,
+                      e.exampleCurl.replace(
+                        'https://your-host.example.com',
+                        base
+                      ),
+                      rowKey
                     )
                   }
                   className="flex items-center gap-1.5 text-sm font-medium text-sky-400/95 hover:text-sky-300 mr-2"
@@ -208,7 +247,7 @@ function EndpointCard({
                 
               
-                {e.exampleCurl.replace("https://your-host.example.com", base)}
+                {e.exampleCurl.replace('https://your-host.example.com', base)}
               
@@ -223,7 +262,7 @@ export default function DeveloperDocs() { const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [copied, setCopied] = useState(null); - const [endpointSearch, setEndpointSearch] = useState(""); + const [endpointSearch, setEndpointSearch] = useState(''); useEffect(() => { let cancelled = false; @@ -234,7 +273,8 @@ export default function DeveloperDocs() { const s = await fetchDeveloperApiDocs(); if (!cancelled) setSpec(s); } catch (e) { - if (!cancelled) setErr(e instanceof Error ? e.message : "Failed to load API docs"); + if (!cancelled) + setErr(e instanceof Error ? e.message : 'Failed to load API docs'); } finally { if (!cancelled) setLoading(false); } @@ -263,12 +303,14 @@ export default function DeveloperDocs() {
-

API reference

+

+ API reference +

- Built from the same route definitions as production. Each route needs a key that - includes its scope. Open "Overview and authentication" for surface area, keys, - and header styles. + Built from the same route definitions as production. Each route + needs a key that includes its scope. Open "Overview and + authentication" for surface area, keys, and header styles.

@@ -281,10 +323,11 @@ export default function DeveloperDocs() { )} {err && (
- {err}{" "} + {err}{' '} - (Fallback: open /developer-api-docs.json from the - last build.) + (Fallback: open{' '} + /developer-api-docs.json from + the last build.)
)} @@ -296,7 +339,9 @@ export default function DeveloperDocs() {

Spec

-

v{spec.specVersion}

+

+ v{spec.specVersion} +

{new Date(spec.generatedAt).toLocaleString()}

@@ -324,7 +369,9 @@ export default function DeveloperDocs() {
-

Auth headers

+

+ Auth headers +

({ @@ -333,7 +380,9 @@ export default function DeveloperDocs() { />
-

Rate limits

+

+ Rate limits +

{spec.rateLimiting.description}

@@ -344,7 +393,9 @@ export default function DeveloperDocs() {
-

Endpoints

+

+ Endpoints +

{endpointQuery ? (

- Showing {filteredEndpoints.length} of {spec.endpoints.length} endpoints + Showing {filteredEndpoints.length} of {spec.endpoints.length}{' '} + endpoints {filteredEndpoints.length === 0 ? ` — no matches for "${endpointSearch.trim()}".` - : null}{" "} + : null}{' '} {filteredEndpoints.length === 0 ? (

- After you close this message, you cannot open this page again to copy the same secret. - Copy it now and store it in a password manager or other safe place. + After you close this message, you cannot open this page again to + copy the same secret. Copy it now and store it in a password manager + or other safe place.

@@ -156,7 +162,7 @@ export default function DeveloperKeys() { ) : ( )} - {curlCopied ? "Copied" : "Sample curl"} + {curlCopied ? 'Copied' : 'Sample curl'} {curlSample ? ( @@ -183,20 +189,29 @@ export default function DeveloperKeys() { onClick={() => setCreateOpen((v) => !v)} className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-zinc-100 text-zinc-950 hover:bg-white text-xs font-semibold transition-colors" > - {createOpen ? : } - {createOpen ? "Cancel" : "New key"} + {createOpen ? ( + + ) : ( + + )} + {createOpen ? 'Cancel' : 'New key'}

- Each key lists its max requests per minute (sliding window; 429 when exceeded). + Each key lists its max requests per minute (sliding window; 429 when + exceeded).

{createOpen && (
-

Create a new API key

+

+ Create a new API key +

- + setNewKeyName(e.target.value)} @@ -208,7 +223,9 @@ export default function DeveloperKeys() {

- Keys using only your approved scopes are issued immediately. Extra scopes require - admin approval. + Keys using only your approved scopes are issued immediately. Extra + scopes require admin approval.

Base URL: {API_EXT_BASE} @@ -254,12 +275,16 @@ export default function DeveloperKeys() { )} {visibleKeys.map((k) => { - const st = k.revokedAt ? "revoked" : (k.status ?? "active"); + const st = k.revokedAt ? 'revoked' : (k.status ?? 'active'); const isRevoked = !!k.revokedAt; const expanded = expandedKeyIds.has(k.id); const scopeIdsForKey = - st === "pending" && k.requestedScopes?.length ? k.requestedScopes : (k.scopes ?? []); - const keyScopeCatalog = catalog.filter((c) => scopeIdsForKey.includes(c.id)); + st === 'pending' && k.requestedScopes?.length + ? k.requestedScopes + : (k.scopes ?? []); + const keyScopeCatalog = catalog.filter((c) => + scopeIdsForKey.includes(c.id) + ); const rpmEffective = k.rateLimitPerMinute != null && Number.isFinite(k.rateLimitPerMinute) && @@ -276,26 +301,28 @@ export default function DeveloperKeys() { key={k.id} className={`overflow-hidden rounded-xl border transition-colors ${ isRevoked - ? "border-zinc-800/60 bg-zinc-900/20 opacity-60" - : "border-zinc-800 bg-zinc-800/30" + ? 'border-zinc-800/60 bg-zinc-900/20 opacity-60' + : 'border-zinc-800 bg-zinc-800/30' }`} >

- {!isRevoked && st === "active" && ( + {!isRevoked && st === 'active' && ( )}
); -} \ No newline at end of file +} diff --git a/src/pages/developers/Overview.tsx b/src/pages/developers/Overview.tsx index ff02696c..e22729a5 100644 --- a/src/pages/developers/Overview.tsx +++ b/src/pages/developers/Overview.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; import { Loader2, LayoutDashboard, @@ -11,39 +11,39 @@ import { Clock, Sparkles, Mail, -} from "lucide-react"; -import DeveloperAccessRequestForm from "../../components/developers/DeveloperAccessRequestForm"; -import { cardClass, statusBadgeClass } from "./constants"; -import { useDeveloperPortal } from "./developerPortalContext"; +} from 'lucide-react'; +import DeveloperAccessRequestForm from '../../components/developers/DeveloperAccessRequestForm'; +import { cardClass, statusBadgeClass } from './constants'; +import { useDeveloperPortal } from './developerPortalContext'; const devNoticeSuccessClass = - "flex items-start gap-3 rounded-2xl border border-emerald-800/45 bg-emerald-950/40 px-4 py-3 text-emerald-50 ring-1 ring-emerald-900/30"; + 'flex items-start gap-3 rounded-2xl border border-emerald-800/45 bg-emerald-950/40 px-4 py-3 text-emerald-50 ring-1 ring-emerald-900/30'; const devNoticeClass = - "flex items-start gap-3 rounded-2xl border border-amber-800/40 bg-amber-950/35 px-4 py-3 text-amber-50 ring-1 ring-amber-900/25"; + 'flex items-start gap-3 rounded-2xl border border-amber-800/40 bg-amber-950/35 px-4 py-3 text-amber-50 ring-1 ring-amber-900/25'; -const ADMIN_NOTICE_SUCCESS_PREFIX = "[[success]]"; +const ADMIN_NOTICE_SUCCESS_PREFIX = '[[success]]'; function parseAdminNoticeDetail(raw: string | null | undefined): { - variant: "success" | "default"; + variant: 'success' | 'default'; body: string; } { - const text = raw?.trim() ?? ""; + const text = raw?.trim() ?? ''; if (text.startsWith(ADMIN_NOTICE_SUCCESS_PREFIX)) { const body = text .slice(ADMIN_NOTICE_SUCCESS_PREFIX.length) - .replace(/^\s*\n+/, "") + .replace(/^\s*\n+/, '') .trimEnd(); - return { variant: "success", body }; + return { variant: 'success', body }; } - return { variant: "default", body: text }; + return { variant: 'default', body: text }; } const devPendingBannerClass = - "flex items-start gap-3 rounded-2xl border border-sky-800/40 bg-sky-950/35 px-4 py-3 text-sky-50 ring-1 ring-sky-900/25"; + 'flex items-start gap-3 rounded-2xl border border-sky-800/40 bg-sky-950/35 px-4 py-3 text-sky-50 ring-1 ring-sky-900/25'; const devPendingCardClass = - "rounded-3xl border border-zinc-700/80 bg-linear-to-br from-zinc-900/95 via-zinc-900/90 to-sky-950/20 p-6 sm:p-8 shadow-xl ring-1 ring-zinc-700/45 text-zinc-200"; + 'rounded-3xl border border-zinc-700/80 bg-linear-to-br from-zinc-900/95 via-zinc-900/90 to-sky-950/20 p-6 sm:p-8 shadow-xl ring-1 ring-zinc-700/45 text-zinc-200'; export default function DeveloperOverview() { const { @@ -74,27 +74,27 @@ export default function DeveloperOverview() { saveNotificationEmail, } = useDeveloperPortal(); - const [emailDraft, setEmailDraft] = useState(""); + const [emailDraft, setEmailDraft] = useState(''); useEffect(() => { - setEmailDraft(notificationEmail ?? ""); + setEmailDraft(notificationEmail ?? ''); }, [notificationEmail]); const [scopeRequestOpen, setScopeRequestOpen] = useState(false); - const [rqWho, setRqWho] = useState(""); - const [rqWhy, setRqWhy] = useState(""); + const [rqWho, setRqWho] = useState(''); + const [rqWhy, setRqWhy] = useState(''); const [rqScopes, setRqScopes] = useState>(new Set()); const catalogForNewScopes = useMemo( () => catalog.filter((c) => !approvedScopes.includes(c.id)), - [catalog, approvedScopes], + [catalog, approvedScopes] ); - const scopeRequestPending = appState?.latestApplication?.status === "pending"; + const scopeRequestPending = appState?.latestApplication?.status === 'pending'; const openScopeRequest = () => { setError(null); - setRqWho(""); - setRqWhy(""); + setRqWho(''); + setRqWhy(''); setRqScopes(new Set()); setScopeRequestOpen(true); }; @@ -108,8 +108,8 @@ export default function DeveloperOverview() { }); if (ok) { setScopeRequestOpen(false); - setRqWho(""); - setRqWhy(""); + setRqWho(''); + setRqWhy(''); setRqScopes(new Set()); } }; @@ -133,37 +133,45 @@ export default function DeveloperOverview() { {showAdminNotice && (

- {adminNoticeParsed.variant === "success" - ? "Application approved" - : "Something changed on your account"} + {adminNoticeParsed.variant === 'success' + ? 'Application approved' + : 'Something changed on your account'}

{adminNoticeParagraphs.length > 0 ? ( - adminNoticeParagraphs.map((para, i) =>

{para.trim()}

) + adminNoticeParagraphs.map((para, i) => ( +

{para.trim()}

+ )) ) : (

{adminNoticeDetail?.trim() || - "An admin updated your scopes, a key, or rate limits. Peek at Keys or the API reference when you have a minute."} + 'An admin updated your scopes, a key, or rate limits. Peek at Keys or the API reference when you have a minute.'}

)}
@@ -172,9 +180,9 @@ export default function DeveloperOverview() { type="button" onClick={() => void dismissAdminNotice()} className={`shrink-0 p-1.5 rounded-lg transition-colors ${ - adminNoticeParsed.variant === "success" - ? "text-emerald-200/85 hover:bg-emerald-900/45 hover:text-emerald-50" - : "text-amber-200/80 hover:bg-amber-900/40 hover:text-amber-50" + adminNoticeParsed.variant === 'success' + ? 'text-emerald-200/85 hover:bg-emerald-900/45 hover:text-emerald-50' + : 'text-amber-200/80 hover:bg-amber-900/40 hover:text-amber-50' }`} aria-label="Dismiss notification" > @@ -190,14 +198,15 @@ export default function DeveloperOverview() { We're reviewing a scope request

- Hang tight — your current access still works while we take a look. We'll follow - up when it's sorted. + Hang tight — your current access still works while we take a + look. We'll follow up when it's sorted.

)}

- You're all set. Jump into usage, keys, or the live API reference whenever you like. + You're all set. Jump into usage, keys, or the live API reference + whenever you like.

{catalogForNewScopes.length > 0 && !scopeRequestOpen && ( @@ -260,7 +269,9 @@ export default function DeveloperOverview() {
-

API reference

+

+ API reference +

Routes, parameters, and curl examples.

@@ -274,10 +285,13 @@ export default function DeveloperOverview() {
-

Email alerts

+

+ Email alerts +

- (Optional) We'll email you when an administrator updates your scopes, API - keys, rate limits, or account status (same summary as the in-portal notice). + (Optional) We'll email you when an administrator updates + your scopes, API keys, rate limits, or account status (same + summary as the in-portal notice).

@@ -297,8 +311,9 @@ export default function DeveloperOverview() { type="button" disabled={ notificationEmailSaving || - (emailDraft.trim() === "" && notificationEmail === null) || - (emailDraft.trim() !== "" && emailDraft.trim() === (notificationEmail ?? "")) + (emailDraft.trim() === '' && notificationEmail === null) || + (emailDraft.trim() !== '' && + emailDraft.trim() === (notificationEmail ?? '')) } onClick={() => { setError(null); @@ -306,7 +321,11 @@ export default function DeveloperOverview() { }} className="inline-flex justify-center items-center gap-2 shrink-0 px-6 py-2.5 rounded-xl border border-violet-600/50 bg-violet-950/40 text-violet-100 text-sm font-semibold hover:bg-violet-900/45 hover:border-violet-500/55 transition-colors disabled:opacity-40 disabled:pointer-events-none ring-1 ring-violet-900/25" > - {notificationEmailSaving ? : "Save"} + {notificationEmailSaving ? ( + + ) : ( + 'Save' + )}
@@ -337,7 +356,7 @@ export default function DeveloperOverview() {
Suspended @@ -346,8 +365,8 @@ export default function DeveloperOverview() { Your developer access is on pause

- API keys won't work until an administrator turns access back on. If this looks wrong, - reach out{" "} + API keys won't work until an administrator turns access back on. + If this looks wrong, reach out{' '}

- Thanks for applying! A human will read it soon. When you're approved you'll be - able to create keys from the Keys tab. + Thanks for applying! A human will read it soon. When you're + approved you'll be able to create keys from the Keys tab.

{appState?.latestApplication && (
-

About you

+

+ About you +

{appState.latestApplication.whoText}

@@ -395,7 +418,9 @@ export default function DeveloperOverview() {

-

Scopes you asked for

+

+ Scopes you asked for +

{appState.latestApplication.requestedScopes.map((id) => ( ); -} \ No newline at end of file +} diff --git a/src/pages/developers/constants.ts b/src/pages/developers/constants.ts index 28acda8e..d689de35 100644 --- a/src/pages/developers/constants.ts +++ b/src/pages/developers/constants.ts @@ -1,19 +1,19 @@ export const API_EXT_BASE = `${import.meta.env.VITE_SERVER_URL}/api/ext/v1`; -export function cardClass(extra = "") { - return `rounded-2xl border border-zinc-800 bg-zinc-900/90 backdrop-blur-xl p-5 shadow-xl ring-1 ring-zinc-700/50${extra ? ` ${extra}` : ""}`; +export function cardClass(extra = '') { + return `rounded-2xl border border-zinc-800 bg-zinc-900/90 backdrop-blur-xl p-5 shadow-xl ring-1 ring-zinc-700/50${extra ? ` ${extra}` : ''}`; } export function statusBadgeClass(status: string): string { const s = status.toLowerCase(); - if (s === "active" || s === "approved") { - return "bg-emerald-950/55 text-emerald-300 ring-1 ring-emerald-800/40"; + if (s === 'active' || s === 'approved') { + return 'bg-emerald-950/55 text-emerald-300 ring-1 ring-emerald-800/40'; } - if (s === "pending") { - return "bg-amber-950/50 text-amber-200 ring-1 ring-amber-800/35"; + if (s === 'pending') { + return 'bg-amber-950/50 text-amber-200 ring-1 ring-amber-800/35'; } - if (s === "revoked" || s === "rejected" || s === "suspended") { - return "bg-red-950/40 text-red-300 ring-1 ring-red-900/40"; + if (s === 'revoked' || s === 'rejected' || s === 'suspended') { + return 'bg-red-950/40 text-red-300 ring-1 ring-red-900/40'; } - return "bg-zinc-800 text-zinc-400 ring-1 ring-zinc-700/50"; -} \ No newline at end of file + return 'bg-zinc-800 text-zinc-400 ring-1 ring-zinc-700/50'; +} diff --git a/src/pages/developers/developerPortalContext.tsx b/src/pages/developers/developerPortalContext.tsx index f7b69758..09ac877c 100644 --- a/src/pages/developers/developerPortalContext.tsx +++ b/src/pages/developers/developerPortalContext.tsx @@ -9,7 +9,7 @@ import { type Dispatch, type ReactNode, type SetStateAction, -} from "react"; +} from 'react'; import { fetchDeveloperApplication, fetchDeveloperCatalog, @@ -27,11 +27,14 @@ import { type DeveloperScopeCatalogEntry, type DeveloperDashboardSummary, type DeveloperKeyRow, -} from "../../utils/fetch/developer"; -import { API_EXT_BASE } from "./constants"; -import { buildSampleCurlForScopes, type SampleCurlResult } from "../../utils/developerSampleCurl"; +} from '../../utils/fetch/developer'; +import { API_EXT_BASE } from './constants'; +import { + buildSampleCurlForScopes, + type SampleCurlResult, +} from '../../utils/developerSampleCurl'; -export type DeveloperUsageChartWindow = "24h" | 7 | 14 | 30; +export type DeveloperUsageChartWindow = '24h' | 7 | 14 | 30; type DeveloperPortalContextValue = { loading: boolean; @@ -69,7 +72,11 @@ type DeveloperPortalContextValue = { loadApplication: () => Promise; loadDashboard: () => Promise; refresh: () => void; - toggleScope: (id: string, set: Set, update: (s: Set) => void) => void; + toggleScope: ( + id: string, + set: Set, + update: (s: Set) => void + ) => void; handleApply: () => Promise; scopeExpansionSubmitting: boolean; submitScopeExpansionRequest: (input: { @@ -94,12 +101,15 @@ type DeveloperPortalContextValue = { saveNotificationEmail: (email: string | null) => Promise; }; -const DeveloperPortalContext = createContext(null); +const DeveloperPortalContext = + createContext(null); export function useDeveloperPortal() { const v = useContext(DeveloperPortalContext); if (!v) { - throw new Error("useDeveloperPortal must be used within DeveloperPortalProvider"); + throw new Error( + 'useDeveloperPortal must be used within DeveloperPortalProvider' + ); } return v; } @@ -108,21 +118,28 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [catalog, setCatalog] = useState([]); - const [appState, setAppState] = useState(null); + const [appState, setAppState] = useState( + null + ); - const [who, setWho] = useState(""); - const [why, setWhy] = useState(""); + const [who, setWho] = useState(''); + const [why, setWhy] = useState(''); const [selectedScopes, setSelectedScopes] = useState>(new Set()); const [submitting, setSubmitting] = useState(false); - const [scopeExpansionSubmitting, setScopeExpansionSubmitting] = useState(false); + const [scopeExpansionSubmitting, setScopeExpansionSubmitting] = + useState(false); - const [usageChartWindow, setUsageChartWindow] = useState(14); - const [summary, setSummary] = useState(null); + const [usageChartWindow, setUsageChartWindow] = + useState(14); + const [summary, setSummary] = useState( + null + ); const [keys, setKeys] = useState([]); - const [keyDefaultRateLimitPerMinute, setKeyDefaultRateLimitPerMinute] = useState(120); + const [keyDefaultRateLimitPerMinute, setKeyDefaultRateLimitPerMinute] = + useState(120); const [dashLoading, setDashLoading] = useState(false); - const [newKeyName, setNewKeyName] = useState(""); + const [newKeyName, setNewKeyName] = useState(''); const [newKeyScopes, setNewKeyScopes] = useState>(new Set()); const [createdSecret, setCreatedSecret] = useState(null); const [curlExampleScopes, setCurlExampleScopes] = useState([]); @@ -136,11 +153,14 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { setLoading(true); setError(null); try { - const [cat, app] = await Promise.all([fetchDeveloperCatalog(), fetchDeveloperApplication()]); + const [cat, app] = await Promise.all([ + fetchDeveloperCatalog(), + fetchDeveloperApplication(), + ]); setCatalog(cat); setAppState(app); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load"); + setError(e instanceof Error ? e.message : 'Failed to load'); } finally { setLoading(false); } @@ -151,7 +171,9 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { try { const [s, keysPayload] = await Promise.all([ fetchDeveloperDashboardSummary( - usageChartWindow === "24h" ? { hours: 24 } : { days: usageChartWindow }, + usageChartWindow === '24h' + ? { hours: 24 } + : { days: usageChartWindow } ), fetchDeveloperKeys(), ]); @@ -159,7 +181,7 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { setKeys(keysPayload.keys); setKeyDefaultRateLimitPerMinute(keysPayload.defaultRateLimitPerMinute); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load dashboard"); + setError(e instanceof Error ? e.message : 'Failed to load dashboard'); } finally { setDashLoading(false); } @@ -169,14 +191,18 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { void loadApplication(); }, [loadApplication]); - const profileActive = appState?.profile?.status === "active"; - const profileSuspended = appState?.profile?.status === "suspended"; - const pending = appState?.latestApplication?.status === "pending"; - const approvedScopesKey = JSON.stringify(appState?.profile?.approvedScopes ?? []); + const profileActive = appState?.profile?.status === 'active'; + const profileSuspended = appState?.profile?.status === 'suspended'; + const pending = appState?.latestApplication?.status === 'pending'; + const approvedScopesKey = JSON.stringify( + appState?.profile?.approvedScopes ?? [] + ); const approvedScopes = useMemo(() => { try { const p = JSON.parse(approvedScopesKey) as unknown; - return Array.isArray(p) ? p.filter((x): x is string => typeof x === "string") : []; + return Array.isArray(p) + ? p.filter((x): x is string => typeof x === 'string') + : []; } catch { return []; } @@ -202,7 +228,7 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { const showAdminNotice = useMemo(() => { const p = appState?.profile; - if (!p || p.status !== "active") return false; + if (!p || p.status !== 'active') return false; const seq = p.adminNoticeSeq ?? 0; const dismissed = p.noticeDismissedSeq ?? 0; return seq > dismissed; @@ -211,13 +237,13 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { const adminNoticeDetail = useMemo(() => { if (!showAdminNotice) return null; const d = appState?.profile?.adminNoticeDetail; - if (typeof d === "string" && d.trim().length > 0) return d.trim(); + if (typeof d === 'string' && d.trim().length > 0) return d.trim(); return null; }, [showAdminNotice, appState?.profile?.adminNoticeDetail]); const notificationEmail = useMemo(() => { const v = appState?.profile?.notificationEmail; - return typeof v === "string" && v.trim().length > 0 ? v.trim() : null; + return typeof v === 'string' && v.trim().length > 0 ? v.trim() : null; }, [appState?.profile?.notificationEmail]); const saveNotificationEmail = useCallback( @@ -228,12 +254,12 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { await patchDeveloperNotificationEmail(email); await loadApplication(); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to save email"); + setError(e instanceof Error ? e.message : 'Failed to save email'); } finally { setNotificationEmailSaving(false); } }, - [loadApplication], + [loadApplication] ); const dismissAdminNotice = useCallback(async () => { @@ -242,7 +268,7 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { await dismissDeveloperAdminNotice(); await loadApplication(); } catch (e) { - setError(e instanceof Error ? e.message : "Dismiss failed"); + setError(e instanceof Error ? e.message : 'Dismiss failed'); } }, [loadApplication]); @@ -253,12 +279,12 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { else next.add(id); update(next); }, - [], + [] ); const handleApply = useCallback(async () => { if (selectedScopes.size === 0) { - setError("Pick at least one scope to send your application."); + setError('Pick at least one scope to send your application.'); return; } setSubmitting(true); @@ -270,20 +296,24 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { requestedScopes: [...selectedScopes], }); await loadApplication(); - setWho(""); - setWhy(""); + setWho(''); + setWhy(''); setSelectedScopes(new Set()); } catch (e) { - setError(e instanceof Error ? e.message : "Submit failed"); + setError(e instanceof Error ? e.message : 'Submit failed'); } finally { setSubmitting(false); } }, [who, why, selectedScopes, loadApplication]); const submitScopeExpansionRequest = useCallback( - async (input: { who: string; why: string; additionalScopes: string[] }): Promise => { + async (input: { + who: string; + why: string; + additionalScopes: string[]; + }): Promise => { if (input.additionalScopes.length === 0) { - setError("Pick at least one new scope to include in your request."); + setError('Pick at least one new scope to include in your request.'); return false; } setScopeExpansionSubmitting(true); @@ -293,18 +323,18 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { await loadApplication(); return true; } catch (e) { - setError(e instanceof Error ? e.message : "Submit failed"); + setError(e instanceof Error ? e.message : 'Submit failed'); return false; } finally { setScopeExpansionSubmitting(false); } }, - [loadApplication], + [loadApplication] ); const handleCreateKey = useCallback(async () => { if (!newKeyName.trim() || newKeyScopes.size === 0) { - setError("Key name and at least one scope are required."); + setError('Key name and at least one scope are required.'); return; } setKeyBusy(true); @@ -315,7 +345,7 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { scopes: [...newKeyScopes], }); setInfoMessage(null); - if (r.kind === "active") { + if (r.kind === 'active') { setCreatedSecret(r.secret); setCurlExampleScopes(r.scopes); } else { @@ -323,10 +353,10 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { setCurlExampleScopes([]); setInfoMessage(r.message); } - setNewKeyName(""); + setNewKeyName(''); await Promise.all([loadDashboard(), loadApplication()]); } catch (e) { - setError(e instanceof Error ? e.message : "Create key failed"); + setError(e instanceof Error ? e.message : 'Create key failed'); } finally { setKeyBusy(false); } @@ -334,43 +364,47 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { const handleRevoke = useCallback( async (id: string) => { - if (!confirm("Revoke this API key? Clients using it will stop working.")) return; + if (!confirm('Revoke this API key? Clients using it will stop working.')) + return; setKeyBusy(true); setError(null); try { await revokeDeveloperKey(id); await loadDashboard(); } catch (e) { - setError(e instanceof Error ? e.message : "Revoke failed"); + setError(e instanceof Error ? e.message : 'Revoke failed'); } finally { setKeyBusy(false); } }, - [loadDashboard], + [loadDashboard] ); const handleDeleteKey = useCallback( async (id: string) => { - if (!confirm("Permanently delete this revoked key? This cannot be undone.")) return; + if ( + !confirm('Permanently delete this revoked key? This cannot be undone.') + ) + return; setKeyBusy(true); setError(null); try { await deleteDeveloperKey(id); await loadDashboard(); } catch (e) { - setError(e instanceof Error ? e.message : "Delete failed"); + setError(e instanceof Error ? e.message : 'Delete failed'); } finally { setKeyBusy(false); } }, - [loadDashboard], + [loadDashboard] ); const handleRotateKey = useCallback( async (id: string) => { if ( !confirm( - "Rotate this key? The old secret stops working immediately. Copy the new secret when it appears.", + 'Rotate this key? The old secret stops working immediately. Copy the new secret when it appears.' ) ) return; @@ -382,12 +416,12 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { setCurlExampleScopes(Array.isArray(r.scopes) ? r.scopes : []); await loadDashboard(); } catch (e) { - setError(e instanceof Error ? e.message : "Rotate failed"); + setError(e instanceof Error ? e.message : 'Rotate failed'); } finally { setKeyBusy(false); } }, - [loadDashboard], + [loadDashboard] ); const copySecret = useCallback(async () => { @@ -400,7 +434,11 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { const curlSample = useMemo((): SampleCurlResult | null => { if (!createdSecret || curlExampleScopes.length === 0) return null; - return buildSampleCurlForScopes(createdSecret, API_EXT_BASE, curlExampleScopes); + return buildSampleCurlForScopes( + createdSecret, + API_EXT_BASE, + curlExampleScopes + ); }, [createdSecret, curlExampleScopes]); useEffect(() => { @@ -527,10 +565,12 @@ export function DeveloperPortalProvider({ children }: { children: ReactNode }) { notificationEmail, notificationEmailSaving, saveNotificationEmail, - ], + ] ); return ( - {children} + + {children} + ); -} \ No newline at end of file +} diff --git a/src/sockets/globalChatSocket.ts b/src/sockets/globalChatSocket.ts index 1ed64fe8..dfd1e87d 100644 --- a/src/sockets/globalChatSocket.ts +++ b/src/sockets/globalChatSocket.ts @@ -1,4 +1,4 @@ -import io from "socket.io-client"; +import io from 'socket.io-client'; const SOCKET_URL = import.meta.env.VITE_SERVER_URL; @@ -46,18 +46,18 @@ export function createGlobalChatSocket( onAirportMention?: (mention: GlobalChatMention) => void, onConnectedGlobalChatUsers?: (users: ConnectedGlobalChatUser[]) => void, onUserTyping?: (data: { userId: string; username: string }) => void, - networkKind: "pfatc" | "aatc" = "pfatc", + networkKind: 'pfatc' | 'aatc' = 'pfatc' ) { const socket = io(SOCKET_URL, { withCredentials: true, - path: "/sockets/global-chat", + path: '/sockets/global-chat', query: { userId, - station: station || "", - position: position || "", + station: station || '', + position: position || '', networkKind, }, - transports: ["websocket", "polling"], + transports: ['websocket', 'polling'], upgrade: true, reconnection: true, reconnectionDelay: 1000, @@ -65,47 +65,47 @@ export function createGlobalChatSocket( timeout: 10000, }); - socket.on("globalChatMessage", onMessage); + socket.on('globalChatMessage', onMessage); if (onMessageDeleted) { - socket.on("globalMessageDeleted", onMessageDeleted); + socket.on('globalMessageDeleted', onMessageDeleted); } if (onDeleteError) { - socket.on("deleteError", onDeleteError); + socket.on('deleteError', onDeleteError); } if (onActiveGlobalChatUsers) { - socket.on("activeGlobalChatUsers", onActiveGlobalChatUsers); + socket.on('activeGlobalChatUsers', onActiveGlobalChatUsers); } if (onMessageAutomodded) { - socket.on("messageAutomodded", onMessageAutomodded); + socket.on('messageAutomodded', onMessageAutomodded); } if (onMention) { - socket.on("globalChatMention", onMention); + socket.on('globalChatMention', onMention); } if (onAirportMention) { - socket.on("airportMention", onAirportMention); + socket.on('airportMention', onAirportMention); } if (onConnectedGlobalChatUsers) { - socket.on("connectedGlobalChatUsers", onConnectedGlobalChatUsers); + socket.on('connectedGlobalChatUsers', onConnectedGlobalChatUsers); } if (onUserTyping) { - socket.on("globalUserTyping", onUserTyping); + socket.on('globalUserTyping', onUserTyping); } return { socket, deleteMessage: (messageId: number, userId: string) => { - socket.emit("deleteGlobalMessage", { messageId, userId }); + socket.emit('deleteGlobalMessage', { messageId, userId }); }, sendTyping: (username: string) => { - socket.emit("globalTyping", { username }); + socket.emit('globalTyping', { username }); }, }; -} \ No newline at end of file +} diff --git a/src/sockets/notificationsSocket.ts b/src/sockets/notificationsSocket.ts index c7c34912..bcb9b036 100644 --- a/src/sockets/notificationsSocket.ts +++ b/src/sockets/notificationsSocket.ts @@ -17,4 +17,4 @@ export function createNotificationsSocket(onUpdate: () => void) { socket.on('notificationsUpdated', onUpdate); return socket; -} \ No newline at end of file +} diff --git a/src/sockets/overviewSocket.ts b/src/sockets/overviewSocket.ts index a6098015..c98cd4c3 100644 --- a/src/sockets/overviewSocket.ts +++ b/src/sockets/overviewSocket.ts @@ -1,5 +1,5 @@ -import io from "socket.io-client"; -import type { Flight } from "../types/flight"; +import io from 'socket.io-client'; +import type { Flight } from '../types/flight'; const SOCKET_URL = import.meta.env.VITE_SERVER_URL; @@ -45,15 +45,22 @@ export function createOverviewSocket( userId?: string, username?: string, onFlightUpdated?: (data: { sessionId: string; flight: Flight }) => void, - onFlightUpdateAck?: (_data: { flightId: string | number; updates: Partial }) => void, - onFlightError?: (error: { action: string; flightId?: string | number; error: string }) => void, + onFlightUpdateAck?: (_data: { + flightId: string | number; + updates: Partial; + }) => void, + onFlightError?: (error: { + action: string; + flightId?: string | number; + error: string; + }) => void ) { const socket = io(SOCKET_URL, { withCredentials: true, - path: "/sockets/overview", + path: '/sockets/overview', query: { ...(isEventController && { - isEventController: "true", + isEventController: 'true', userId, username, }), @@ -63,15 +70,18 @@ export function createOverviewSocket( reconnectionDelayMax: 5000, reconnectionAttempts: 10, timeout: 20000, - transports: ["websocket", "polling"], + transports: ['websocket', 'polling'], upgrade: true, autoConnect: true, }); - socket.on("connect_error", (error) => { - console.error("[Overview Socket] Connection error:", error.message); + socket.on('connect_error', (error) => { + console.error('[Overview Socket] Connection error:', error.message); - if (error.message.includes("Session ID") || error.message.includes("session")) { + if ( + error.message.includes('Session ID') || + error.message.includes('session') + ) { socket.disconnect(); setTimeout(() => { socket.connect(); @@ -80,42 +90,42 @@ export function createOverviewSocket( if (onOverviewError) { onOverviewError({ - error: error.message || "Failed to connect to overview socket", + error: error.message || 'Failed to connect to overview socket', }); } }); - socket.on("disconnect", (reason) => { - if (reason === "io server disconnect" || reason === "transport close") { + socket.on('disconnect', (reason) => { + if (reason === 'io server disconnect' || reason === 'transport close') { socket.connect(); } }); - socket.on("error", (error) => { - console.error("[Overview Socket] Socket error:", error); + socket.on('error', (error) => { + console.error('[Overview Socket] Socket error:', error); if (onOverviewError) { onOverviewError({ - error: typeof error === "string" ? error : "Socket error occurred", + error: typeof error === 'string' ? error : 'Socket error occurred', }); } }); - socket.on("overviewData", onOverviewData); + socket.on('overviewData', onOverviewData); if (onOverviewError) { - socket.on("overviewError", onOverviewError); + socket.on('overviewError', onOverviewError); } if (isEventController && onFlightUpdated) { - socket.on("flightUpdated", onFlightUpdated); + socket.on('flightUpdated', onFlightUpdated); } if (onFlightUpdateAck) { - socket.on("flightUpdateAck", onFlightUpdateAck); + socket.on('flightUpdateAck', onFlightUpdateAck); } if (onFlightError) { - socket.on("flightError", onFlightError); + socket.on('flightError', onFlightError); } return { @@ -123,17 +133,21 @@ export function createOverviewSocket( disconnect: () => { socket.disconnect(); }, - updateFlight: (sessionId: string, flightId: string | number, updates: Partial) => { - socket.emit("updateFlight", { sessionId, flightId, updates }); + updateFlight: ( + sessionId: string, + flightId: string | number, + updates: Partial + ) => { + socket.emit('updateFlight', { sessionId, flightId, updates }); }, sendContact: ( sessionId: string, flightId: string | number, message: string, station: string, - position: string, + position: string ) => { - socket.emit("contactMe", { + socket.emit('contactMe', { sessionId, flightId, message, @@ -142,4 +156,4 @@ export function createOverviewSocket( }); }, }; -} \ No newline at end of file +} diff --git a/src/sockets/voiceChatSocket.ts b/src/sockets/voiceChatSocket.ts index 3ca81c8b..8d8e426d 100644 --- a/src/sockets/voiceChatSocket.ts +++ b/src/sockets/voiceChatSocket.ts @@ -23,7 +23,11 @@ export interface VoiceConnectionState { type VoiceUsersUpdateCallback = (_users: VoiceUser[]) => void; type ConnectionStateCallback = (_state: VoiceConnectionState) => void; type TalkingCallback = (_userId: string) => void; -type AudioLevelCallback = (_userId: string, _level: number, _isTalking: boolean) => void; +type AudioLevelCallback = ( + _userId: string, + _level: number, + _isTalking: boolean +) => void; type DevicesRefreshedCallback = (_devices: MediaDeviceInfo[]) => void; export function createVoiceChatSocket( @@ -51,9 +55,10 @@ export function createVoiceChatSocket( timeout: 10000, }); - const getVolumes = typeof getUserVolumes === 'function' - ? getUserVolumes - : () => getUserVolumes as Map; + const getVolumes = + typeof getUserVolumes === 'function' + ? getUserVolumes + : () => getUserVolumes as Map; const peerConnections = new Map(); const audioStreams = new Map(); @@ -88,9 +93,11 @@ export function createVoiceChatSocket( const getOrCreateAudioContext = () => { if (!audioContext || audioContext.state === 'closed') { - audioContext = new (window.AudioContext || + audioContext = new ( + window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }) - .webkitAudioContext!)(); + .webkitAudioContext! + )(); } return audioContext; }; @@ -141,7 +148,9 @@ export function createVoiceChatSocket( const devices = await navigator.mediaDevices.enumerateDevices(); const audioInputs = devices.filter((d) => d.kind === 'audioinput'); onDevicesRefreshed(audioInputs); - } catch {/**/} + } catch { + /**/ + } }; const initializeAudio = async () => { @@ -154,7 +163,10 @@ export function createVoiceChatSocket( localStream = await navigator.mediaDevices.getUserMedia({ audio: { - deviceId: selectedAudioInputId === 'default' ? undefined : { exact: selectedAudioInputId }, + deviceId: + selectedAudioInputId === 'default' + ? undefined + : { exact: selectedAudioInputId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true, @@ -176,7 +188,11 @@ export function createVoiceChatSocket( return true; } catch (error) { console.error('[VoiceChat] Audio init error:', error); - onConnectionStateChange({ connected: false, connecting: false, error: 'Mic access failed' }); + onConnectionStateChange({ + connected: false, + connecting: false, + error: 'Mic access failed', + }); return false; } }; @@ -187,7 +203,9 @@ export function createVoiceChatSocket( pc.onicecandidate = (event) => { if (event.candidate) { const c = event.candidate; - console.log(`[VoiceChat] Sending ${c.type || '?'} candidate to ${targetUserId}: ${c.candidate}`); + console.log( + `[VoiceChat] Sending ${c.type || '?'} candidate to ${targetUserId}: ${c.candidate}` + ); socket.emit('ice-candidate', { targetUserId, candidate: c.toJSON() }); } else { console.log(`[VoiceChat] ICE gathering complete for ${targetUserId}`); @@ -195,18 +213,24 @@ export function createVoiceChatSocket( }; pc.onicegatheringstatechange = () => { - console.log(`[VoiceChat] ICE gathering state with ${targetUserId}: ${pc.iceGatheringState}`); + console.log( + `[VoiceChat] ICE gathering state with ${targetUserId}: ${pc.iceGatheringState}` + ); }; pc.onsignalingstatechange = () => { - console.log(`[VoiceChat] Signaling state with ${targetUserId}: ${pc.signalingState}`); + console.log( + `[VoiceChat] Signaling state with ${targetUserId}: ${pc.signalingState}` + ); }; pc.oniceconnectionstatechange = () => { const state = pc.iceConnectionState; console.log(`[VoiceChat] ICE state with ${targetUserId}: ${state}`); if (state === 'failed' || state === 'disconnected') { - console.warn(`[VoiceChat] ICE ${state} for ${targetUserId}, requesting reconnection...`); + console.warn( + `[VoiceChat] ICE ${state} for ${targetUserId}, requesting reconnection...` + ); socket.emit('request-reconnection', { targetUserId }); } }; @@ -215,21 +239,31 @@ export function createVoiceChatSocket( if (pc.signalingState === 'closed') return; try { const stats = await pc.getStats(); - stats.forEach(report => { - if (report.type === 'candidate-pair' && report.state === 'succeeded') { - console.info(`[VoiceChat] Active candidate pair for ${targetUserId} success!`); + stats.forEach((report) => { + if ( + report.type === 'candidate-pair' && + report.state === 'succeeded' + ) { + console.info( + `[VoiceChat] Active candidate pair for ${targetUserId} success!` + ); } }); - } catch {/**/} + } catch { + /**/ + } }, 5000); statsIntervals.set(targetUserId, sInt); const iceHangTimeout = window.setTimeout(() => { if ( - (pc.iceConnectionState === 'checking' || pc.iceConnectionState === 'new') && + (pc.iceConnectionState === 'checking' || + pc.iceConnectionState === 'new') && pc.remoteDescription !== null ) { - console.warn(`[VoiceChat] ICE stuck in ${pc.iceConnectionState} for ${targetUserId}, forcing restart...`); + console.warn( + `[VoiceChat] ICE stuck in ${pc.iceConnectionState} for ${targetUserId}, forcing restart...` + ); pc.restartIce(); } iceHangTimeouts.delete(targetUserId); @@ -237,30 +271,50 @@ export function createVoiceChatSocket( iceHangTimeouts.set(targetUserId, iceHangTimeout); pc.onconnectionstatechange = () => { - console.log(`[VoiceChat] PeerConnection state with ${targetUserId}: ${pc.connectionState}`); + console.log( + `[VoiceChat] PeerConnection state with ${targetUserId}: ${pc.connectionState}` + ); if (pc.connectionState === 'failed') { socket.emit('request-reconnection', { targetUserId }); } - if (['connected', 'failed', 'disconnected', 'closed'].includes(pc.connectionState)) { + if ( + ['connected', 'failed', 'disconnected', 'closed'].includes( + pc.connectionState + ) + ) { const t = iceHangTimeouts.get(targetUserId); - if (t !== undefined) { clearTimeout(t); iceHangTimeouts.delete(targetUserId); } + if (t !== undefined) { + clearTimeout(t); + iceHangTimeouts.delete(targetUserId); + } } }; pc.onnegotiationneeded = async () => { if (makingOfferMap.get(targetUserId) || pc.signalingState !== 'stable') { - console.log(`[VoiceChat] Skipping negotiation request for ${targetUserId} (busy/unstable)`); + console.log( + `[VoiceChat] Skipping negotiation request for ${targetUserId} (busy/unstable)` + ); return; } try { const senders = pc.getSenders(); - console.log(`[VoiceChat] Negotiation needed for ${targetUserId}. Senders:`, senders.map(s => s.track?.kind || 'no-track')); - + console.log( + `[VoiceChat] Negotiation needed for ${targetUserId}. Senders:`, + senders.map((s) => s.track?.kind || 'no-track') + ); + makingOfferMap.set(targetUserId, true); await pc.setLocalDescription(); - socket.emit('voice-offer', { targetUserId, offer: pc.localDescription }); + socket.emit('voice-offer', { + targetUserId, + offer: pc.localDescription, + }); } catch (err) { - console.error(`[VoiceChat] Negotiation error for ${targetUserId}:`, err); + console.error( + `[VoiceChat] Negotiation error for ${targetUserId}:`, + err + ); } finally { makingOfferMap.set(targetUserId, false); } @@ -271,43 +325,62 @@ export function createVoiceChatSocket( kind: event.track.kind, enabled: event.track.enabled, readyState: event.track.readyState, - muted: event.track.muted + muted: event.track.muted, }); - event.track.onmute = () => console.warn(`[VoiceChat] Remote track from ${targetUserId} became MUTED (packets stopped)`); + event.track.onmute = () => + console.warn( + `[VoiceChat] Remote track from ${targetUserId} became MUTED (packets stopped)` + ); event.track.onunmute = () => { - console.info(`[VoiceChat] Remote track from ${targetUserId} UNMUTED (packets started)`); + console.info( + `[VoiceChat] Remote track from ${targetUserId} UNMUTED (packets started)` + ); // Force playback on unmute if (audioElements.has(targetUserId)) { const a = audioElements.get(targetUserId)!; - a.play().catch(() => {/**/}); + a.play().catch(() => { + /**/ + }); } }; - + const checkTrackState = () => { console.log(`[VoiceChat] Track state for ${targetUserId}:`, { muted: event.track.muted, readyState: event.track.readyState, - enabled: event.track.enabled + enabled: event.track.enabled, }); }; - + setTimeout(checkTrackState, 1000); setTimeout(checkTrackState, 5000); - + let stream = event.streams[0]; if (!stream) stream = new MediaStream([event.track]); audioStreams.set(targetUserId, stream); const oldAudio = audioElements.get(targetUserId); - if (oldAudio) { oldAudio.pause(); oldAudio.srcObject = null; oldAudio.remove(); } + if (oldAudio) { + oldAudio.pause(); + oldAudio.srcObject = null; + oldAudio.remove(); + } const oldBoost = boostGainNodes.get(targetUserId); - if (oldBoost) { try { oldBoost.disconnect(); } catch {/**/} } + if (oldBoost) { + try { + oldBoost.disconnect(); + } catch { + /**/ + } + } const oldInterval = remoteAudioMonitorIntervals.get(targetUserId); if (oldInterval) clearInterval(oldInterval); const vol = getVolumes().get(targetUserId) ?? 100; - console.log(`[VoiceChat] Applying volume to ${targetUserId}: ${vol}% (deafened: ${isDeafenedLocally})`); + console.log( + `[VoiceChat] Applying volume to ${targetUserId}: ${vol}% (deafened: ${isDeafenedLocally})` + ); const audio = new Audio(); audio.srcObject = stream; @@ -315,61 +388,86 @@ export function createVoiceChatSocket( audio.volume = isDeafenedLocally ? 0 : Math.min(vol / 100, 1.0); audio.muted = isDeafenedLocally; document.body.appendChild(audio); - audio.play().then(() => { - console.log(`[VoiceChat] Playback started for ${targetUserId}`); - }).catch((err) => { - console.warn(`[VoiceChat] Playback blocked for ${targetUserId}, queuing retry:`, err); - const retry = async () => { - console.log(`[VoiceChat] Retrying blocked playback for ${targetUserId}`); - try { - await resumeAudioContext(); - await audio.play(); - } catch {/**/} - }; - document.addEventListener('click', retry, { once: true }); - document.addEventListener('keydown', retry, { once: true }); - }); + audio + .play() + .then(() => { + console.log(`[VoiceChat] Playback started for ${targetUserId}`); + }) + .catch((err) => { + console.warn( + `[VoiceChat] Playback blocked for ${targetUserId}, queuing retry:`, + err + ); + const retry = async () => { + console.log( + `[VoiceChat] Retrying blocked playback for ${targetUserId}` + ); + try { + await resumeAudioContext(); + await audio.play(); + } catch { + /**/ + } + }; + document.addEventListener('click', retry, { once: true }); + document.addEventListener('keydown', retry, { once: true }); + }); audioElements.set(targetUserId, audio); - resumeAudioContext().then(ctx => { - if (ctx) { - const source = ctx.createMediaStreamSource(stream); - const remoteAnalyser = ctx.createAnalyser(); - remoteAnalyser.fftSize = 256; - const zeroGain = ctx.createGain(); - zeroGain.gain.value = 0; - const boostGain = ctx.createGain(); - boostGain.gain.value = isDeafenedLocally ? 0 : Math.max(0, vol / 100 - 1); - - source.connect(remoteAnalyser); - remoteAnalyser.connect(zeroGain); - zeroGain.connect(ctx.destination); - source.connect(boostGain); - boostGain.connect(ctx.destination); - - boostGainNodes.set(targetUserId, boostGain); - - const dataArray = new Uint8Array(remoteAnalyser.frequencyBinCount); - const interval = window.setInterval(() => { - remoteAnalyser.getByteFrequencyData(dataArray); - const avg = dataArray.reduce((a, b) => a + b) / dataArray.length; - const level = Math.min(avg / 128, 1); - onAudioLevelUpdate(targetUserId, level, level > 0.1); - }, 100); - remoteAudioMonitorIntervals.set(targetUserId, interval); - } - }).catch(err => console.warn('[VoiceChat] Remote audio resume failed:', err)); + resumeAudioContext() + .then((ctx) => { + if (ctx) { + const source = ctx.createMediaStreamSource(stream); + const remoteAnalyser = ctx.createAnalyser(); + remoteAnalyser.fftSize = 256; + const zeroGain = ctx.createGain(); + zeroGain.gain.value = 0; + const boostGain = ctx.createGain(); + boostGain.gain.value = isDeafenedLocally + ? 0 + : Math.max(0, vol / 100 - 1); + + source.connect(remoteAnalyser); + remoteAnalyser.connect(zeroGain); + zeroGain.connect(ctx.destination); + source.connect(boostGain); + boostGain.connect(ctx.destination); + + boostGainNodes.set(targetUserId, boostGain); + + const dataArray = new Uint8Array(remoteAnalyser.frequencyBinCount); + const interval = window.setInterval(() => { + remoteAnalyser.getByteFrequencyData(dataArray); + const avg = dataArray.reduce((a, b) => a + b) / dataArray.length; + const level = Math.min(avg / 128, 1); + onAudioLevelUpdate(targetUserId, level, level > 0.1); + }, 100); + remoteAudioMonitorIntervals.set(targetUserId, interval); + } + }) + .catch((err) => + console.warn('[VoiceChat] Remote audio resume failed:', err) + ); }; if (addTracks && localStream) { - localStream.getTracks().forEach(track => { - const existing = pc.getTransceivers().find(t => t.receiver.track.kind === track.kind); + localStream.getTracks().forEach((track) => { + const existing = pc + .getTransceivers() + .find((t) => t.receiver.track.kind === track.kind); if (existing) { - if (existing.sender.track !== track) existing.sender.replaceTrack(track); + if (existing.sender.track !== track) + existing.sender.replaceTrack(track); existing.direction = 'sendrecv'; } else { - console.log(`[VoiceChat] Adding local track to ${targetUserId}:`, track.kind); - pc.addTransceiver(track, { direction: 'sendrecv', streams: [localStream!] }); + console.log( + `[VoiceChat] Adding local track to ${targetUserId}:`, + track.kind + ); + pc.addTransceiver(track, { + direction: 'sendrecv', + streams: [localStream!], + }); } }); } @@ -378,7 +476,6 @@ export function createVoiceChatSocket( return pc; }; - socket.on('voice-offer', async ({ fromUserId, offer }) => { let pc = peerConnections.get(fromUserId); if (!pc) pc = createPeerConnection(fromUserId, false); @@ -392,34 +489,51 @@ export function createVoiceChatSocket( ignoreOfferMap.set(fromUserId, ignoreOffer); if (ignoreOffer) { - console.log(`[VoiceChat] Ignoring offer collision from ${fromUserId} (impolite)`); + console.log( + `[VoiceChat] Ignoring offer collision from ${fromUserId} (impolite)` + ); return; } try { console.log(`[VoiceChat] Processing offer from ${fromUserId}...`); - const sdpBrief = offer.sdp?.split('\n').filter((l: string) => l.startsWith('m=') || l.startsWith('a=mid:')).join(' | '); + const sdpBrief = offer.sdp + ?.split('\n') + .filter((l: string) => l.startsWith('m=') || l.startsWith('a=mid:')) + .join(' | '); console.log(`[VoiceChat] Offer SDP: ${sdpBrief}`); await pc!.setRemoteDescription(offer); if (localStream) { - await Promise.all(localStream.getTracks().map(async track => { - const transceiver = pc!.getTransceivers().find( - t => t.receiver.track.kind === track.kind && t.sender.track === null - ); - if (transceiver) { - await transceiver.sender.replaceTrack(track); - transceiver.direction = 'sendrecv'; - } else { - const existing = pc!.getTransceivers().find(t => t.receiver.track.kind === track.kind); - if (existing && existing.sender.track !== track) await existing.sender.replaceTrack(track); - if (existing) existing.direction = 'sendrecv'; - } - })); + await Promise.all( + localStream.getTracks().map(async (track) => { + const transceiver = pc! + .getTransceivers() + .find( + (t) => + t.receiver.track.kind === track.kind && + t.sender.track === null + ); + if (transceiver) { + await transceiver.sender.replaceTrack(track); + transceiver.direction = 'sendrecv'; + } else { + const existing = pc! + .getTransceivers() + .find((t) => t.receiver.track.kind === track.kind); + if (existing && existing.sender.track !== track) + await existing.sender.replaceTrack(track); + if (existing) existing.direction = 'sendrecv'; + } + }) + ); } await pc!.setLocalDescription(); - socket.emit('voice-answer', { targetUserId: fromUserId, answer: pc!.localDescription }); + socket.emit('voice-answer', { + targetUserId: fromUserId, + answer: pc!.localDescription, + }); const buffered = candidateBufferMap.get(fromUserId) || []; for (const cand of buffered) await pc!.addIceCandidate(cand); @@ -434,14 +548,20 @@ export function createVoiceChatSocket( if (!pc) return; try { console.log(`[VoiceChat] Processing answer from ${fromUserId}...`); - const sdpBrief = answer.sdp?.split('\n').filter((l: string) => l.startsWith('m=') || l.startsWith('a=mid:')).join(' | '); + const sdpBrief = answer.sdp + ?.split('\n') + .filter((l: string) => l.startsWith('m=') || l.startsWith('a=mid:')) + .join(' | '); console.log(`[VoiceChat] Answer SDP: ${sdpBrief}`); await pc.setRemoteDescription(answer); const buffered = candidateBufferMap.get(fromUserId) || []; for (const cand of buffered) await pc!.addIceCandidate(cand); candidateBufferMap.delete(fromUserId); } catch (err) { - console.error(`[VoiceChat] Answer processing error for ${fromUserId}:`, err); + console.error( + `[VoiceChat] Answer processing error for ${fromUserId}:`, + err + ); } }); @@ -453,16 +573,23 @@ export function createVoiceChatSocket( type = typeMatch ? typeMatch[1] : 'unknown'; console.log(`[VoiceChat] Received ${type} candidate from ${fromUserId}`); } - console.log(`[VoiceChat] Received ${type} candidate from ${fromUserId}:`, candidate?.candidate?.substring(0, 100)); + console.log( + `[VoiceChat] Received ${type} candidate from ${fromUserId}:`, + candidate?.candidate?.substring(0, 100) + ); if (!pc || !pc.remoteDescription) { - if (!candidateBufferMap.has(fromUserId)) candidateBufferMap.set(fromUserId, []); + if (!candidateBufferMap.has(fromUserId)) + candidateBufferMap.set(fromUserId, []); candidateBufferMap.get(fromUserId)!.push(candidate); return; } try { await pc!.addIceCandidate(candidate); } catch (err) { - console.warn(`[VoiceChat] Failed to add candidate from ${fromUserId}:`, err); + console.warn( + `[VoiceChat] Failed to add candidate from ${fromUserId}:`, + err + ); } }); @@ -474,17 +601,24 @@ export function createVoiceChatSocket( } if (!localStream) { - console.log(`[VoiceChat] user-joined-voice from ${newUserId} ignored — not in voice yet`); + console.log( + `[VoiceChat] user-joined-voice from ${newUserId} ignored — not in voice yet` + ); return; } if (userId <= newUserId) { - console.log(`[VoiceChat] Waiting for ${newUserId} to initiate (their ID is >=)`); + console.log( + `[VoiceChat] Waiting for ${newUserId} to initiate (their ID is >=)` + ); return; } const existing = peerConnections.get(newUserId); - if (existing) { existing.close(); peerConnections.delete(newUserId); } + if (existing) { + existing.close(); + peerConnections.delete(newUserId); + } createPeerConnection(newUserId); }); @@ -494,11 +628,15 @@ export function createVoiceChatSocket( for (const peerId of peerIds) { if (userId > peerId) { if (!peerConnections.has(peerId)) { - console.log(`[VoiceChat] Initiating connection to existing peer ${peerId}`); + console.log( + `[VoiceChat] Initiating connection to existing peer ${peerId}` + ); createPeerConnection(peerId); } } else { - console.log(`[VoiceChat] Waiting for existing peer ${peerId} to initiate (their ID is >=)`); + console.log( + `[VoiceChat] Waiting for existing peer ${peerId} to initiate (their ID is >=)` + ); } } }); @@ -509,25 +647,53 @@ export function createVoiceChatSocket( } const pc = peerConnections.get(leftUserId); - if (pc) { pc.close(); peerConnections.delete(leftUserId); } + if (pc) { + pc.close(); + peerConnections.delete(leftUserId); + } const stream = audioStreams.get(leftUserId); - if (stream) { stream.getTracks().forEach(t => t.stop()); audioStreams.delete(leftUserId); } + if (stream) { + stream.getTracks().forEach((t) => t.stop()); + audioStreams.delete(leftUserId); + } const audio = audioElements.get(leftUserId); - if (audio) { audio.pause(); audio.srcObject = null; audio.remove(); audioElements.delete(leftUserId); } + if (audio) { + audio.pause(); + audio.srcObject = null; + audio.remove(); + audioElements.delete(leftUserId); + } const boost = boostGainNodes.get(leftUserId); - if (boost) { try { boost.disconnect(); } catch {/**/} boostGainNodes.delete(leftUserId); } + if (boost) { + try { + boost.disconnect(); + } catch { + /**/ + } + boostGainNodes.delete(leftUserId); + } const interval = remoteAudioMonitorIntervals.get(leftUserId); - if (interval) { clearInterval(interval); remoteAudioMonitorIntervals.delete(leftUserId); } + if (interval) { + clearInterval(interval); + remoteAudioMonitorIntervals.delete(leftUserId); + } const sInt = statsIntervals.get(leftUserId); - if (sInt) { clearInterval(sInt); statsIntervals.delete(leftUserId); } + if (sInt) { + clearInterval(sInt); + statsIntervals.delete(leftUserId); + } const hangTimeout = iceHangTimeouts.get(leftUserId); - if (hangTimeout !== undefined) { clearTimeout(hangTimeout); iceHangTimeouts.delete(leftUserId); } + if (hangTimeout !== undefined) { + clearTimeout(hangTimeout); + iceHangTimeouts.delete(leftUserId); + } candidateBufferMap.delete(leftUserId); onUserStoppedTalking(leftUserId); }); - - socket.on('connect', () => onConnectionStateChange({ connected: true, connecting: false, error: null })); + socket.on('connect', () => + onConnectionStateChange({ connected: true, connecting: false, error: null }) + ); socket.on('voice-users-update', (users: VoiceUser[]) => { onVoiceUsersUpdate(users); users.forEach((u: VoiceUser) => { @@ -539,8 +705,16 @@ export function createVoiceChatSocket( if (isTalking) onUserStartedTalking(uid); else onUserStoppedTalking(uid); }); - socket.on('voice-connected', () => onConnectionStateChange({ connected: true, connecting: false, error: null })); - socket.on('disconnect', () => onConnectionStateChange({ connected: false, connecting: false, error: 'Disconnected' })); + socket.on('voice-connected', () => + onConnectionStateChange({ connected: true, connecting: false, error: null }) + ); + socket.on('disconnect', () => + onConnectionStateChange({ + connected: false, + connecting: false, + error: 'Disconnected', + }) + ); socket.on('reconnect', () => { socket.emit('get-voice-users'); if (localStream) socket.emit('join-voice-session'); @@ -553,17 +727,26 @@ export function createVoiceChatSocket( try { const stream = localStream; if (!stream) return; - stream.getTracks().forEach(track => { - const existing = pc.getTransceivers().find(t => t.receiver.track.kind === track.kind); + stream.getTracks().forEach((track) => { + const existing = pc + .getTransceivers() + .find((t) => t.receiver.track.kind === track.kind); if (existing) { - if (existing.sender.track !== track) existing.sender.replaceTrack(track); + if (existing.sender.track !== track) + existing.sender.replaceTrack(track); existing.direction = 'sendrecv'; } else { - pc.addTransceiver(track, { direction: 'sendrecv', streams: [stream] }); + pc.addTransceiver(track, { + direction: 'sendrecv', + streams: [stream], + }); } }); await pc.setLocalDescription(); - socket.emit('voice-offer', { targetUserId: fromUserId, offer: pc.localDescription }); + socket.emit('voice-offer', { + targetUserId: fromUserId, + offer: pc.localDescription, + }); } catch (err) { console.warn('[VoiceChat] Manual reconnection failed:', err); } @@ -572,26 +755,53 @@ export function createVoiceChatSocket( const cleanupRTC = () => { stopAudioLevelMonitoring(); - peerConnections.forEach(pc => pc.close()); + peerConnections.forEach((pc) => pc.close()); peerConnections.clear(); - audioStreams.forEach(s => s.getTracks().forEach(t => t.stop())); + audioStreams.forEach((s) => s.getTracks().forEach((t) => t.stop())); audioStreams.clear(); - audioElements.forEach(a => { a.pause(); a.srcObject = null; a.remove(); }); + audioElements.forEach((a) => { + a.pause(); + a.srcObject = null; + a.remove(); + }); audioElements.clear(); - boostGainNodes.forEach(g => { try { g.disconnect(); } catch {/**/} }); + boostGainNodes.forEach((g) => { + try { + g.disconnect(); + } catch { + /**/ + } + }); boostGainNodes.clear(); - remoteAudioMonitorIntervals.forEach(i => clearInterval(i)); + remoteAudioMonitorIntervals.forEach((i) => clearInterval(i)); remoteAudioMonitorIntervals.clear(); - statsIntervals.forEach(i => clearInterval(i)); + statsIntervals.forEach((i) => clearInterval(i)); statsIntervals.clear(); - iceHangTimeouts.forEach(t => clearTimeout(t)); + iceHangTimeouts.forEach((t) => clearTimeout(t)); iceHangTimeouts.clear(); candidateBufferMap.clear(); makingOfferMap.clear(); ignoreOfferMap.clear(); - if (localStream) { localStream.getTracks().forEach(t => t.stop()); localStream = null; } - if (analyser) { try { analyser.disconnect(); } catch {/**/} analyser = null; } - if (microphone) { try { microphone.disconnect(); } catch {/**/} microphone = null; } + if (localStream) { + localStream.getTracks().forEach((t) => t.stop()); + localStream = null; + } + if (analyser) { + try { + analyser.disconnect(); + } catch { + /**/ + } + analyser = null; + } + if (microphone) { + try { + microphone.disconnect(); + } catch { + /**/ + } + microphone = null; + } }; const cleanup = () => { @@ -603,7 +813,7 @@ export function createVoiceChatSocket( return { socket, joinVoice: () => { - initializeAudio().then(ok => { + initializeAudio().then((ok) => { if (ok) { socket.emit('join-voice-session'); playSound(SOUNDS.VC_CONNECT, 0.6).catch(() => {}); @@ -612,7 +822,8 @@ export function createVoiceChatSocket( }, getVoiceUsers: () => socket.emit('get-voice-users'), setMuted: (muted: boolean) => { - if (localStream) localStream.getAudioTracks().forEach(t => t.enabled = !muted); + if (localStream) + localStream.getAudioTracks().forEach((t) => (t.enabled = !muted)); socket.emit('mute-state', { isMuted: muted }); }, setDeafened: (deafened: boolean) => { @@ -636,8 +847,10 @@ export function createVoiceChatSocket( const ok = await initializeAudio(); if (ok && localStream) { const track = localStream.getAudioTracks()[0]; - peerConnections.forEach(pc => { - const sender = pc.getSenders().find(s => s.track?.kind === 'audio'); + peerConnections.forEach((pc) => { + const sender = pc + .getSenders() + .find((s) => s.track?.kind === 'audio'); if (sender && track) sender.replaceTrack(track); }); } @@ -650,7 +863,8 @@ export function createVoiceChatSocket( audio.muted = isDeafenedLocally; } const boost = boostGainNodes.get(uid); - if (boost) boost.gain.value = isDeafenedLocally ? 0 : Math.max(0, vol / 100 - 1); + if (boost) + boost.gain.value = isDeafenedLocally ? 0 : Math.max(0, vol / 100 - 1); }, leaveVoice: () => { socket.emit('leave-voice-session'); diff --git a/src/types/developerApiSpec.ts b/src/types/developerApiSpec.ts index 8db8e425..c0fdb802 100644 --- a/src/types/developerApiSpec.ts +++ b/src/types/developerApiSpec.ts @@ -13,7 +13,12 @@ export interface DeveloperApiDocEndpoint { pathTemplate: string; fullUrlExample: string; pathParams?: { name: string; description: string; example?: string }[]; - queryParams?: { name: string; required: boolean; description: string; example?: string }[]; + queryParams?: { + name: string; + required: boolean; + description: string; + example?: string; + }[]; requestBodySummary?: string; requestBodyExampleJson?: string; requestHeaders: DeveloperApiDocHeader[]; @@ -38,4 +43,4 @@ export interface DeveloperApiPublicSpec { envVar: string; }; endpoints: DeveloperApiDocEndpoint[]; -} \ No newline at end of file +} diff --git a/src/types/overview.ts b/src/types/overview.ts index ecbc7a59..3266ed2d 100644 --- a/src/types/overview.ts +++ b/src/types/overview.ts @@ -1,4 +1,4 @@ -import type { Flight } from "./flight"; +import type { Flight } from './flight'; export interface OverviewSession { sessionId: string; @@ -17,6 +17,9 @@ export interface OverviewData { activeSessions: OverviewSession[]; totalActiveSessions: number; totalFlights: number; - arrivalsByAirport: Record; + arrivalsByAirport: Record< + string, + (Flight & { sessionId: string; departureAirport: string })[] + >; lastUpdated: string; -} \ No newline at end of file +} diff --git a/src/types/session.ts b/src/types/session.ts index eaeb1ecf..3b0fd2ca 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -1,4 +1,4 @@ -export type Position = "ALL" | "DEL" | "GND" | "TWR" | "APP"; +export type Position = 'ALL' | 'DEL' | 'GND' | 'TWR' | 'APP'; export interface SessionInfo { sessionId: string; @@ -41,4 +41,4 @@ export interface ChatMention { message: string; timestamp: number; sessionId: string; -} \ No newline at end of file +} diff --git a/src/utils/apiFetch.ts b/src/utils/apiFetch.ts index 30886c61..7fa397c8 100644 --- a/src/utils/apiFetch.ts +++ b/src/utils/apiFetch.ts @@ -8,15 +8,18 @@ * Generic 403s (e.g. "you don't own this session") are intentionally ignored * so they don't trigger a spurious user refresh. */ -import { posthog } from "./posthog"; +import { posthog } from './posthog'; -export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { +export async function apiFetch( + input: RequestInfo | URL, + init?: RequestInit +): Promise { const headers = new Headers(init?.headers); const sessionId = posthog.get_session_id?.(); - if (sessionId) headers.set("x-posthog-session-id", sessionId); + if (sessionId) headers.set('x-posthog-session-id', sessionId); try { const distinctId = posthog.get_distinct_id?.(); - if (distinctId) headers.set("x-posthog-distinct-id", distinctId); + if (distinctId) headers.set('x-posthog-distinct-id', distinctId); } catch { /* ignore */ } @@ -26,8 +29,8 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr res = await fetch(input, { ...init, headers }); } catch (error) { posthog.captureException?.(error, { - source: "apiFetch", - url: typeof input === "string" ? input : String(input), + source: 'apiFetch', + url: typeof input === 'string' ? input : String(input), }); throw error; } @@ -36,12 +39,15 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr const text = await res .clone() .text() - .catch(() => ""); - posthog.captureException?.(new Error(`HTTP ${res.status} from ${String(input)}`), { - source: "apiFetch", - status: res.status, - body_preview: text.slice(0, 500), - }); + .catch(() => ''); + posthog.captureException?.( + new Error(`HTTP ${res.status} from ${String(input)}`), + { + source: 'apiFetch', + status: res.status, + body_preview: text.slice(0, 500), + } + ); } if (res.status === 403) { @@ -50,12 +56,15 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr .clone() .json() .then((data: { error?: string }) => { - if (data?.error === "Account is banned" || data?.error === "VPN access blocked") { - window.dispatchEvent(new CustomEvent("auth:forbidden")); + if ( + data?.error === 'Account is banned' || + data?.error === 'VPN access blocked' + ) { + window.dispatchEvent(new CustomEvent('auth:forbidden')); } }) .catch(() => {}); } return res; -} \ No newline at end of file +} diff --git a/src/utils/chats.ts b/src/utils/chats.ts index 0202e1af..461b28b6 100644 --- a/src/utils/chats.ts +++ b/src/utils/chats.ts @@ -205,31 +205,31 @@ export const isMessageMentioned = ( ): boolean => { const isMentionedByUser = Boolean( 'mentions' in message && - message.mentions && - Array.isArray(message.mentions) && - currentUserId && - message.mentions.includes(currentUserId) + message.mentions && + Array.isArray(message.mentions) && + currentUserId && + message.mentions.includes(currentUserId) ); const isMentionedByUserInGlobal = Boolean( 'userMentions' in message && - message.userMentions && - Array.isArray(message.userMentions) && - currentUserId && - message.userMentions.some( - (username: string) => - username.toLowerCase() === currentUserId.toLowerCase() - ) + message.userMentions && + Array.isArray(message.userMentions) && + currentUserId && + message.userMentions.some( + (username: string) => + username.toLowerCase() === currentUserId.toLowerCase() + ) ); const isMentionedByAirport = Boolean( 'airportMentions' in message && - message.airportMentions && - Array.isArray(message.airportMentions) && - station && - message.airportMentions.some( - (icao: string) => icao.toUpperCase() === station.toUpperCase() - ) + message.airportMentions && + Array.isArray(message.airportMentions) && + station && + message.airportMentions.some( + (icao: string) => icao.toUpperCase() === station.toUpperCase() + ) ); return isMentionedByUser || isMentionedByUserInGlobal || isMentionedByAirport; diff --git a/src/utils/developerSampleCurl.ts b/src/utils/developerSampleCurl.ts index 77e37ba3..aa0eb953 100644 --- a/src/utils/developerSampleCurl.ts +++ b/src/utils/developerSampleCurl.ts @@ -1,89 +1,117 @@ const GET_SAMPLES: { scopeId: string; path: string; label: string }[] = [ - { scopeId: "data.airports", path: "/data/airports", label: "GET /data/airports" }, - { scopeId: "data.aircrafts", path: "/data/aircrafts", label: "GET /data/aircrafts" }, - { scopeId: "data.airlines", path: "/data/airlines", label: "GET /data/airlines" }, - { scopeId: "data.frequencies", path: "/data/frequencies", label: "GET /data/frequencies" }, - { scopeId: "data.backgrounds", path: "/data/backgrounds", label: "GET /data/backgrounds" }, { - scopeId: "data.find_route", - path: "/data/findRoute?from=EGLL&to=LFPG", - label: "GET /data/findRoute", + scopeId: 'data.airports', + path: '/data/airports', + label: 'GET /data/airports', }, { - scopeId: "data.airport_runways", - path: "/data/airports/EGLL/runways", - label: "GET /data/airports/…/runways", + scopeId: 'data.aircrafts', + path: '/data/aircrafts', + label: 'GET /data/aircrafts', }, { - scopeId: "data.airport_sids", - path: "/data/airports/EGLL/sids", - label: "GET /data/airports/…/sids", + scopeId: 'data.airlines', + path: '/data/airlines', + label: 'GET /data/airlines', }, { - scopeId: "data.airport_stars", - path: "/data/airports/EGLL/stars", - label: "GET /data/airports/…/stars", + scopeId: 'data.frequencies', + path: '/data/frequencies', + label: 'GET /data/frequencies', }, { - scopeId: "data.airport_status", - path: "/data/airports/EGLL/status", - label: "GET /data/airports/…/status", + scopeId: 'data.backgrounds', + path: '/data/backgrounds', + label: 'GET /data/backgrounds', }, { - scopeId: "notifications.read", - path: "/notifications/active", - label: "GET /notifications/active", + scopeId: 'data.find_route', + path: '/data/findRoute?from=EGLL&to=LFPG', + label: 'GET /data/findRoute', }, - { scopeId: "flight_logs.read", path: "/flight-logs", label: "GET /flight-logs" }, { - scopeId: "sessions.network_pfatc", - path: "/sessions/network/pfatc", - label: "GET /sessions/network/pfatc", + scopeId: 'data.airport_runways', + path: '/data/airports/EGLL/runways', + label: 'GET /data/airports/…/runways', }, { - scopeId: "sessions.network_aatc", - path: "/sessions/network/aatc", - label: "GET /sessions/network/aatc", + scopeId: 'data.airport_sids', + path: '/data/airports/EGLL/sids', + label: 'GET /data/airports/…/sids', }, - { scopeId: "sessions.list", path: "/sessions", label: "GET /sessions" }, { - scopeId: "sessions.read", - path: "/sessions/sess_abc123", - label: "GET /sessions/{id}", + scopeId: 'data.airport_stars', + path: '/data/airports/EGLL/stars', + label: 'GET /data/airports/…/stars', }, { - scopeId: "flights.list", - path: "/sessions/sess_abc123/flights", - label: "GET /sessions/…/flights", + scopeId: 'data.airport_status', + path: '/data/airports/EGLL/status', + label: 'GET /data/airports/…/status', }, { - scopeId: "flights.read", - path: "/sessions/sess_abc123/flights/550e8400-e29b-41d4-a716-446655440000", - label: "GET /sessions/…/flights/{id}", + scopeId: 'notifications.read', + path: '/notifications/active', + label: 'GET /notifications/active', }, { - scopeId: "ratings.controller_stats", - path: "/ratings/controllers/1234567/stats", - label: "GET /ratings/controllers/…/stats", + scopeId: 'flight_logs.read', + path: '/flight-logs', + label: 'GET /flight-logs', + }, + { + scopeId: 'sessions.network_pfatc', + path: '/sessions/network/pfatc', + label: 'GET /sessions/network/pfatc', + }, + { + scopeId: 'sessions.network_aatc', + path: '/sessions/network/aatc', + label: 'GET /sessions/network/aatc', + }, + { scopeId: 'sessions.list', path: '/sessions', label: 'GET /sessions' }, + { + scopeId: 'sessions.read', + path: '/sessions/sess_abc123', + label: 'GET /sessions/{id}', + }, + { + scopeId: 'flights.list', + path: '/sessions/sess_abc123/flights', + label: 'GET /sessions/…/flights', + }, + { + scopeId: 'flights.read', + path: '/sessions/sess_abc123/flights/550e8400-e29b-41d4-a716-446655440000', + label: 'GET /sessions/…/flights/{id}', + }, + { + scopeId: 'ratings.controller_stats', + path: '/ratings/controllers/1234567/stats', + label: 'GET /ratings/controllers/…/stats', }, ]; const SESSION_CREATE_BODY = JSON.stringify({ - airportIcao: "EGLL", + airportIcao: 'EGLL', isPFATC: false, isAdvancedATC: false, - activeRunway: "27L", + activeRunway: '27L', }); const FLIGHT_CREATE_BODY = JSON.stringify({ - callsign: "BAW123", - aircraft: "A320", - flight_type: "IFR", - departure: "EGLL", - arrival: "LFPG", + callsign: 'BAW123', + aircraft: 'A320', + flight_type: 'IFR', + departure: 'EGLL', + arrival: 'LFPG', }); -const FLIGHT_UPDATE_BODY = JSON.stringify({ status: "ACTIVE", runway: "27L", squawk: "1234" }); +const FLIGHT_UPDATE_BODY = JSON.stringify({ + status: 'ACTIVE', + runway: '27L', + squawk: '1234', +}); export type SampleCurlResult = { command: string; @@ -93,9 +121,9 @@ export type SampleCurlResult = { export function buildSampleCurlForScopes( secret: string, apiExtBase: string, - scopes: string[], + scopes: string[] ): SampleCurlResult { - const base = apiExtBase.replace(/\/$/, ""); + const base = apiExtBase.replace(/\/$/, ''); const set = new Set(scopes); for (const g of GET_SAMPLES) { @@ -108,30 +136,30 @@ export function buildSampleCurlForScopes( } } - if (set.has("sessions.create")) { + if (set.has('sessions.create')) { return { command: `curl -s -X POST -H "Authorization: Bearer ${secret}" -H "Content-Type: application/json" -d '${SESSION_CREATE_BODY}' "${base}/sessions"`, - label: "POST /sessions", + label: 'POST /sessions', }; } - if (set.has("flights.create")) { + if (set.has('flights.create')) { return { command: `curl -s -X POST -H "Authorization: Bearer ${secret}" -H "Content-Type: application/json" -d '${FLIGHT_CREATE_BODY}' "${base}/sessions/sess_abc123/flights"`, - label: "POST /sessions/…/flights", + label: 'POST /sessions/…/flights', }; } - if (set.has("flights.update")) { + if (set.has('flights.update')) { return { command: `curl -s -X PUT -H "Authorization: Bearer ${secret}" -H "Content-Type: application/json" -d '${FLIGHT_UPDATE_BODY}' "${base}/sessions/sess_abc123/flights/550e8400-e29b-41d4-a716-446655440000"`, - label: "PUT /sessions/…/flights/{id}", + label: 'PUT /sessions/…/flights/{id}', }; } const url = `${base}/data/airports`; return { command: `curl -s -H "Authorization: Bearer ${secret}" "${url}"`, - label: "GET /data/airports (fallback)", + label: 'GET /data/airports (fallback)', }; -} \ No newline at end of file +} diff --git a/src/utils/fetch/admin.ts b/src/utils/fetch/admin.ts index 35476b8b..3ec4384f 100644 --- a/src/utils/fetch/admin.ts +++ b/src/utils/fetch/admin.ts @@ -1,8 +1,8 @@ -import { apiFetch } from "../apiFetch.js"; -import type { Settings } from "../../types/settings"; -import type { Feedback, FeedbackStats } from "./feedback"; +import { apiFetch } from '../apiFetch.js'; +import type { Settings } from '../../types/settings'; +import type { Feedback, FeedbackStats } from './feedback'; -const API_BASE_URL = import.meta.env.VITE_SERVER_URL || ""; +const API_BASE_URL = import.meta.env.VITE_SERVER_URL || ''; export interface DailyStats { date: string; @@ -167,7 +167,7 @@ export interface Pagination { export interface Notification { id: number; - type: "info" | "warning" | "success" | "error"; + type: 'info' | 'warning' | 'success' | 'error'; text: string; show: boolean; custom_color?: string | null; @@ -210,7 +210,7 @@ export interface ChatReport { message: string; reason: string; created_at: string; - status?: "pending" | "resolved"; + status?: 'pending' | 'resolved'; avatar?: string; reported_username?: string; reporter_username?: string; @@ -232,7 +232,7 @@ export interface FlightLog { user_id: string; username: string; session_id: string; - action: "add" | "update" | "delete"; + action: 'add' | 'update' | 'delete'; flight_id: string; old_data: object | null; new_data: object | null; @@ -328,9 +328,9 @@ export interface DailyRatingStats { async function makeAdminRequest(endpoint: string, options?: RequestInit) { const response = await apiFetch(`${API_BASE_URL}/api/admin${endpoint}`, { - credentials: "include", + credentials: 'include', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, }, ...options, @@ -338,10 +338,10 @@ async function makeAdminRequest(endpoint: string, options?: RequestInit) { if (!response.ok) { if (response.status === 403) { - throw new Error("Admin access required"); + throw new Error('Admin access required'); } if (response.status === 401) { - throw new Error("Authentication required"); + throw new Error('Authentication required'); } throw new Error(`HTTP ${response.status}: ${response.statusText}`); } @@ -358,8 +358,8 @@ export async function fetchAdminStatistics( export async function fetchAdminUsers( page: number = 1, limit: number = 50, - search: string = "", - filterAdmin: string = "all" + search: string = '', + filterAdmin: string = 'all' ): Promise { const params = new URLSearchParams({ page: page.toString(), @@ -383,13 +383,13 @@ export interface AdminSessionsResponse { export async function fetchAdminSessions( page: number = 1, limit: number = 100, - search: string = "" + search: string = '' ): Promise { const params = new URLSearchParams({ page: page.toString(), limit: limit.toString(), }); - if (search) params.append("search", search); + if (search) params.append('search', search); return makeAdminRequest(`/sessions?${params.toString()}`); } @@ -399,21 +399,21 @@ export interface EventModeState { } export async function fetchEventMode(): Promise { - return makeAdminRequest("/sessions/event-mode"); + return makeAdminRequest('/sessions/event-mode'); } export async function setEventMode( state: Partial ): Promise { - return makeAdminRequest("/sessions/event-mode", { - method: "POST", + return makeAdminRequest('/sessions/event-mode', { + method: 'POST', body: JSON.stringify(state), }); } export async function revealUserIP(userId: string): Promise { return makeAdminRequest(`/users/${userId}/reveal-ip`, { - method: "POST", + method: 'POST', }); } @@ -421,7 +421,7 @@ export async function revealAuditLogIP( logId: number ): Promise<{ logId: number; ip_address: string }> { return makeAdminRequest(`/audit-logs/${logId}/reveal-ip`, { - method: "POST", + method: 'POST', }); } @@ -438,15 +438,15 @@ export async function banUser({ reason: string; expiresAt?: string; }) { - return makeAdminRequest("/bans/ban", { - method: "POST", + return makeAdminRequest('/bans/ban', { + method: 'POST', body: JSON.stringify({ userId, ip, username, reason, expiresAt }), }); } export async function unbanUser(userIdOrIp: string) { - return makeAdminRequest("/bans/unban", { - method: "POST", + return makeAdminRequest('/bans/unban', { + method: 'POST', body: JSON.stringify({ userIdOrIp }), }); } @@ -474,7 +474,7 @@ export async function fetchAuditLogs( limit: limit.toString(), ...Object.fromEntries( Object.entries(filters).filter( - ([, value]) => value != null && value !== "" + ([, value]) => value != null && value !== '' ) ), }); @@ -482,12 +482,12 @@ export async function fetchAuditLogs( const response = await apiFetch( `${API_BASE_URL}/api/admin/audit-logs?${params}`, { - credentials: "include", + credentials: 'include', } ); if (!response.ok) { - throw new Error("Failed to fetch audit logs"); + throw new Error('Failed to fetch audit logs'); } return response.json(); @@ -497,7 +497,7 @@ export async function deleteAdminSession( sessionId: string ): Promise<{ message: string; sessionId: string }> { return makeAdminRequest(`/sessions/${sessionId}`, { - method: "DELETE", + method: 'DELETE', }); } @@ -505,20 +505,20 @@ export async function logSessionJoin( sessionId: string ): Promise<{ message: string; sessionId: string }> { return makeAdminRequest(`/sessions/${sessionId}/join`, { - method: "POST", + method: 'POST', }); } export async function fetchNotifications(): Promise { - const response = await makeAdminRequest("/notifications"); + const response = await makeAdminRequest('/notifications'); return response; } export async function addNotification( - notification: Omit + notification: Omit ): Promise { - const response = await makeAdminRequest("/notifications", { - method: "POST", + const response = await makeAdminRequest('/notifications', { + method: 'POST', body: JSON.stringify(notification), }); return response; @@ -526,10 +526,10 @@ export async function addNotification( export async function updateNotification( id: number, - notification: Partial> + notification: Partial> ): Promise { const response = await makeAdminRequest(`/notifications/${id}`, { - method: "PUT", + method: 'PUT', body: JSON.stringify(notification), }); return response; @@ -537,12 +537,12 @@ export async function updateNotification( export async function deleteNotification(id: number): Promise { await makeAdminRequest(`/notifications/${id}`, { - method: "DELETE", + method: 'DELETE', }); } export async function fetchRoles(): Promise { - return makeAdminRequest("/roles"); + return makeAdminRequest('/roles'); } export async function createRole(roleData: { @@ -553,8 +553,8 @@ export async function createRole(roleData: { icon?: string; priority?: number; }): Promise { - return makeAdminRequest("/roles", { - method: "POST", + return makeAdminRequest('/roles', { + method: 'POST', body: JSON.stringify(roleData), }); } @@ -571,7 +571,7 @@ export async function updateRole( } ): Promise { return makeAdminRequest(`/roles/${id}`, { - method: "PUT", + method: 'PUT', body: JSON.stringify(roleData), }); } @@ -579,15 +579,15 @@ export async function updateRole( export async function updateRolePriorities( rolePriorities: Array<{ id: number; priority: number }> ): Promise { - return makeAdminRequest("/roles/priorities", { - method: "PUT", + return makeAdminRequest('/roles/priorities', { + method: 'PUT', body: JSON.stringify({ rolePriorities }), }); } export async function deleteRole(id: number): Promise { return makeAdminRequest(`/roles/${id}`, { - method: "DELETE", + method: 'DELETE', }); } @@ -596,7 +596,7 @@ export async function assignRoleToUser( roleId: number ): Promise { return makeAdminRequest(`/roles/assign`, { - method: "POST", + method: 'POST', body: JSON.stringify({ userId, roleId }), }); } @@ -606,17 +606,17 @@ export async function removeRoleFromUser( roleId: number ): Promise { return makeAdminRequest(`/roles/remove`, { - method: "POST", + method: 'POST', body: JSON.stringify({ userId, roleId }), }); } export async function fetchUsersWithRoles(): Promise { - return makeAdminRequest("/roles/users"); + return makeAdminRequest('/roles/users'); } export async function fetchAppVersion(): Promise { - return makeAdminRequest("/version"); + return makeAdminRequest('/version'); } export async function fetchChatReports( @@ -628,43 +628,43 @@ export async function fetchChatReports( page: page.toString(), limit: limit.toString(), }); - if (filterReporter) params.append("reporter", filterReporter); + if (filterReporter) params.append('reporter', filterReporter); const res = await apiFetch( `${API_BASE_URL}/api/admin/chat-reports?${params}`, { - credentials: "include", + credentials: 'include', } ); - if (!res.ok) throw new Error("Failed to fetch chat reports"); + if (!res.ok) throw new Error('Failed to fetch chat reports'); return res.json(); } export async function updateChatReportStatus( reportId: number, - status: "pending" | "resolved" + status: 'pending' | 'resolved' ): Promise { const res = await apiFetch( `${API_BASE_URL}/api/admin/chat-reports/${reportId}`, { - method: "PATCH", - credentials: "include", - headers: { "Content-Type": "application/json" }, + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }), } ); - if (!res.ok) throw new Error("Failed to update report status"); + if (!res.ok) throw new Error('Failed to update report status'); } export async function deleteChatReport(reportId: number): Promise { const res = await apiFetch( `${API_BASE_URL}/api/admin/chat-reports/${reportId}`, { - method: "DELETE", - credentials: "include", + method: 'DELETE', + credentials: 'include', } ); - if (!res.ok) throw new Error("Failed to delete report"); + if (!res.ok) throw new Error('Failed to delete report'); } export async function fetchFlightLogs( @@ -685,7 +685,7 @@ export async function fetchFlightLogs( limit: limit.toString(), ...Object.fromEntries( Object.entries(filters).filter( - ([, value]) => value != null && value !== "" + ([, value]) => value != null && value !== '' ) ), }); @@ -693,12 +693,12 @@ export async function fetchFlightLogs( const response = await apiFetch( `${API_BASE_URL}/api/admin/flight-logs?${params}`, { - credentials: "include", + credentials: 'include', } ); if (!response.ok) { - throw new Error("Failed to fetch flight logs"); + throw new Error('Failed to fetch flight logs'); } return response.json(); @@ -710,29 +710,29 @@ export async function revealFlightLogIP( const response = await apiFetch( `${API_BASE_URL}/api/admin/flight-logs/reveal-ip/${logId}`, { - method: "POST", - credentials: "include", + method: 'POST', + credentials: 'include', } ); if (!response.ok) { - throw new Error("Failed to reveal flight log IP"); + throw new Error('Failed to reveal flight log IP'); } return response.json(); } export async function fetchFeedback(): Promise { - return makeAdminRequest("/feedback"); + return makeAdminRequest('/feedback'); } export async function fetchFeedbackStats(): Promise { - return makeAdminRequest("/feedback/stats"); + return makeAdminRequest('/feedback/stats'); } export async function deleteFeedback(id: number): Promise { return makeAdminRequest(`/feedback/${id}`, { - method: "DELETE", + method: 'DELETE', }); } @@ -754,7 +754,7 @@ export async function fetchApiLogs( limit: limit.toString(), ...Object.fromEntries( Object.entries(filters).filter( - ([, value]) => value != null && value !== "" + ([, value]) => value != null && value !== '' ) ), }); @@ -762,12 +762,12 @@ export async function fetchApiLogs( const response = await apiFetch( `${API_BASE_URL}/api/admin/api-logs?${params}`, { - credentials: "include", + credentials: 'include', } ); if (!response.ok) { - throw new Error("Failed to fetch API logs"); + throw new Error('Failed to fetch API logs'); } return response.json(); @@ -777,12 +777,12 @@ export async function fetchApiLogStats(days: number = 7): Promise { const response = await apiFetch( `${API_BASE_URL}/api/admin/api-logs/stats?days=${days}`, { - credentials: "include", + credentials: 'include', } ); if (!response.ok) { - throw new Error("Failed to fetch API log stats"); + throw new Error('Failed to fetch API log stats'); } return response.json(); @@ -790,11 +790,11 @@ export async function fetchApiLogStats(days: number = 7): Promise { export async function fetchApiLogById(id: number): Promise { const response = await apiFetch(`${API_BASE_URL}/api/admin/api-logs/${id}`, { - credentials: "include", + credentials: 'include', }); if (!response.ok) { - throw new Error("Failed to fetch API log"); + throw new Error('Failed to fetch API log'); } return response.json(); @@ -812,19 +812,19 @@ export async function fetchApiLogStats24h(): Promise< const response = await apiFetch( `${API_BASE_URL}/api/admin/api-logs/stats-24h`, { - credentials: "include", + credentials: 'include', } ); if (!response.ok) { - throw new Error("Failed to fetch API log stats for last 24 hours"); + throw new Error('Failed to fetch API log stats for last 24 hours'); } return response.json(); } export async function fetchControllerRatingStats(): Promise { - return makeAdminRequest("/ratings/stats"); + return makeAdminRequest('/ratings/stats'); } export async function fetchControllerDailyRatingStats( @@ -854,7 +854,7 @@ export interface VpnGateResponse { export async function fetchVpnGate( page: number = 1, limit: number = 50, - search: string = "" + search: string = '' ): Promise { const params = new URLSearchParams({ page: page.toString(), @@ -867,8 +867,8 @@ export async function fetchVpnGate( export async function toggleVpnGate( enabled: boolean ): Promise<{ success: boolean; enabled: boolean }> { - return makeAdminRequest("/bans/vpn-gate/toggle", { - method: "POST", + return makeAdminRequest('/bans/vpn-gate/toggle', { + method: 'POST', body: JSON.stringify({ enabled }), }); } @@ -880,8 +880,8 @@ export async function addVpnException({ userId: string; notes?: string; }): Promise<{ success: boolean; exception: VpnException }> { - return makeAdminRequest("/bans/vpn-gate/exceptions", { - method: "POST", + return makeAdminRequest('/bans/vpn-gate/exceptions', { + method: 'POST', body: JSON.stringify({ userId, notes }), }); } @@ -892,7 +892,7 @@ export async function removeVpnException( return makeAdminRequest( `/bans/vpn-gate/exceptions/${encodeURIComponent(userId)}`, { - method: "DELETE", + method: 'DELETE', } ); } @@ -944,7 +944,7 @@ export interface AltCluster { vpn_overlap: boolean; }; score: number; - score_label: "low" | "medium" | "high" | "critical"; + score_label: 'low' | 'medium' | 'high' | 'critical'; detected_at: string; } @@ -962,9 +962,9 @@ export async function fetchAltClusters( ): Promise { const params = new URLSearchParams(); if (options.minScore != null) - params.set("minScore", String(options.minScore)); + params.set('minScore', String(options.minScore)); const qs = params.toString(); - return makeAdminRequest(`/alts${qs ? "?" + qs : ""}`); + return makeAdminRequest(`/alts${qs ? '?' + qs : ''}`); } export interface AdminWebsocketNamespace { @@ -983,7 +983,7 @@ export interface AdminWebsocketStatsResponse { } export async function fetchAdminWebsocketStats(): Promise { - return makeAdminRequest("/websockets"); + return makeAdminRequest('/websockets'); } export interface AdminDatabaseTable { @@ -1042,5 +1042,5 @@ export interface AdminDatabaseStatsResponse { } export async function fetchAdminDatabaseStats(): Promise { - return makeAdminRequest("/database"); -} \ No newline at end of file + return makeAdminRequest('/database'); +} diff --git a/src/utils/fetch/adminDevelopers.ts b/src/utils/fetch/adminDevelopers.ts index 747c26b0..84a9cc27 100644 --- a/src/utils/fetch/adminDevelopers.ts +++ b/src/utils/fetch/adminDevelopers.ts @@ -1,4 +1,4 @@ -import { apiFetch } from "../apiFetch.js"; +import { apiFetch } from '../apiFetch.js'; const API_BASE_URL = import.meta.env.VITE_SERVER_URL; @@ -23,13 +23,17 @@ export async function fetchAdminDeveloperApplications(params?: { status?: string; }): Promise<{ applications: AdminDeveloperApplication[]; total: number }> { const sp = new URLSearchParams(); - if (params?.page) sp.set("page", String(params.page)); - if (params?.limit) sp.set("limit", String(params.limit)); - if (params?.status && params.status.length > 0) sp.set("status", params.status); - const res = await apiFetch(`${API_BASE_URL}/api/admin/developers/applications?${sp.toString()}`, { - credentials: "include", - }); - if (!res.ok) throw new Error("Failed to load applications"); + if (params?.page) sp.set('page', String(params.page)); + if (params?.limit) sp.set('limit', String(params.limit)); + if (params?.status && params.status.length > 0) + sp.set('status', params.status); + const res = await apiFetch( + `${API_BASE_URL}/api/admin/developers/applications?${sp.toString()}`, + { + credentials: 'include', + } + ); + if (!res.ok) throw new Error('Failed to load applications'); return res.json(); } @@ -39,30 +43,39 @@ export async function approveDeveloperApplication( approvedScopes?: string[]; note?: string; rateLimitPerMinute?: number | null; - }, + } ): Promise { - const res = await apiFetch(`${API_BASE_URL}/api/admin/developers/applications/${id}/approve`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body ?? {}), - }); + const res = await apiFetch( + `${API_BASE_URL}/api/admin/developers/applications/${id}/approve`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body ?? {}), + } + ); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Approve failed"); + throw new Error((err as { error?: string }).error || 'Approve failed'); } } -export async function rejectDeveloperApplication(id: number, note?: string): Promise { - const res = await apiFetch(`${API_BASE_URL}/api/admin/developers/applications/${id}/reject`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ note: note ?? "" }), - }); +export async function rejectDeveloperApplication( + id: number, + note?: string +): Promise { + const res = await apiFetch( + `${API_BASE_URL}/api/admin/developers/applications/${id}/reject`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ note: note ?? '' }), + } + ); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Reject failed"); + throw new Error((err as { error?: string }).error || 'Reject failed'); } } @@ -72,11 +85,13 @@ export interface AdminScopeCatalogEntry { description: string; } -export async function fetchAdminDeveloperCatalog(): Promise { +export async function fetchAdminDeveloperCatalog(): Promise< + AdminScopeCatalogEntry[] +> { const res = await apiFetch(`${API_BASE_URL}/api/admin/developers/catalog`, { - credentials: "include", + credentials: 'include', }); - if (!res.ok) throw new Error("Failed to load scope catalog"); + if (!res.ok) throw new Error('Failed to load scope catalog'); const data = await res.json(); return data.scopes as AdminScopeCatalogEntry[]; } @@ -96,11 +111,16 @@ export interface AdminDeveloperSummary { noticeDismissedSeq: number; } -export async function fetchAdminDevelopers(): Promise<{ developers: AdminDeveloperSummary[] }> { - const res = await apiFetch(`${API_BASE_URL}/api/admin/developers/developers`, { - credentials: "include", - }); - if (!res.ok) throw new Error("Failed to load developers"); +export async function fetchAdminDevelopers(): Promise<{ + developers: AdminDeveloperSummary[]; +}> { + const res = await apiFetch( + `${API_BASE_URL}/api/admin/developers/developers`, + { + credentials: 'include', + } + ); + if (!res.ok) throw new Error('Failed to load developers'); return res.json(); } @@ -121,13 +141,13 @@ export interface AdminDeveloperKeyRow { } export async function fetchAdminDeveloperKeys( - userId: string, + userId: string ): Promise<{ keys: AdminDeveloperKeyRow[] }> { const res = await apiFetch( `${API_BASE_URL}/api/admin/developers/${encodeURIComponent(userId)}/keys`, - { credentials: "include" }, + { credentials: 'include' } ); - if (!res.ok) throw new Error("Failed to load keys"); + if (!res.ok) throw new Error('Failed to load keys'); return res.json(); } @@ -138,20 +158,20 @@ export async function approveAdminDeveloperKey( approvedScopes: string[]; rateLimitPerMinute?: number | null; note?: string; - }, + } ): Promise<{ secret: string; prefix: string }> { const res = await apiFetch( `${API_BASE_URL}/api/admin/developers/${encodeURIComponent(userId)}/keys/${encodeURIComponent(keyId)}/approve`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), - }, + } ); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Approve key failed"); + throw new Error((err as { error?: string }).error || 'Approve key failed'); } return res.json(); } @@ -159,96 +179,111 @@ export async function approveAdminDeveloperKey( export async function rejectAdminDeveloperKey( userId: string, keyId: string, - note?: string, + note?: string ): Promise { const res = await apiFetch( `${API_BASE_URL}/api/admin/developers/${encodeURIComponent(userId)}/keys/${encodeURIComponent(keyId)}/reject`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ note: note ?? "" }), - }, + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ note: note ?? '' }), + } ); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Reject key failed"); + throw new Error((err as { error?: string }).error || 'Reject key failed'); } } export async function patchAdminDeveloperKey( userId: string, keyId: string, - body: { scopes: string[]; rateLimitPerMinute?: number | null }, + body: { scopes: string[]; rateLimitPerMinute?: number | null } ): Promise { const res = await apiFetch( `${API_BASE_URL}/api/admin/developers/${encodeURIComponent(userId)}/keys/${encodeURIComponent(keyId)}`, { - method: "PATCH", - credentials: "include", - headers: { "Content-Type": "application/json" }, + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), - }, + } ); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Update key failed"); + throw new Error((err as { error?: string }).error || 'Update key failed'); } } -export async function revokeAdminDeveloperKey(userId: string, keyId: string): Promise { +export async function revokeAdminDeveloperKey( + userId: string, + keyId: string +): Promise { const res = await apiFetch( `${API_BASE_URL}/api/admin/developers/${encodeURIComponent(userId)}/keys/${encodeURIComponent(keyId)}/revoke`, - { method: "POST", credentials: "include" }, + { method: 'POST', credentials: 'include' } ); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Revoke failed"); + throw new Error((err as { error?: string }).error || 'Revoke failed'); } } export async function patchAdminDeveloperProfileScopes( userId: string, - approvedScopes: string[], + approvedScopes: string[] ): Promise { const res = await apiFetch( `${API_BASE_URL}/api/admin/developers/profiles/${encodeURIComponent(userId)}/scopes`, { - method: "PATCH", - credentials: "include", - headers: { "Content-Type": "application/json" }, + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approvedScopes }), - }, + } ); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Update scopes failed"); + throw new Error( + (err as { error?: string }).error || 'Update scopes failed' + ); } } export async function suspendDeveloperProfile(userId: string): Promise { - const res = await apiFetch(`${API_BASE_URL}/api/admin/developers/profiles/${userId}/suspend`, { - method: "POST", - credentials: "include", - }); - if (!res.ok) throw new Error("Suspend failed"); + const res = await apiFetch( + `${API_BASE_URL}/api/admin/developers/profiles/${userId}/suspend`, + { + method: 'POST', + credentials: 'include', + } + ); + if (!res.ok) throw new Error('Suspend failed'); } -export async function reactivateDeveloperProfile(userId: string): Promise { - const res = await apiFetch(`${API_BASE_URL}/api/admin/developers/profiles/${userId}/reactivate`, { - method: "POST", - credentials: "include", - }); - if (!res.ok) throw new Error("Reactivate failed"); +export async function reactivateDeveloperProfile( + userId: string +): Promise { + const res = await apiFetch( + `${API_BASE_URL}/api/admin/developers/profiles/${userId}/reactivate`, + { + method: 'POST', + credentials: 'include', + } + ); + if (!res.ok) throw new Error('Reactivate failed'); } -export async function deleteAdminDeveloperAccount(userId: string): Promise { +export async function deleteAdminDeveloperAccount( + userId: string +): Promise { const res = await apiFetch( `${API_BASE_URL}/api/admin/developers/profiles/${encodeURIComponent(userId)}`, - { method: "DELETE", credentials: "include" }, + { method: 'DELETE', credentials: 'include' } ); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Delete failed"); + throw new Error((err as { error?: string }).error || 'Delete failed'); } -} \ No newline at end of file +} diff --git a/src/utils/fetch/chats.ts b/src/utils/fetch/chats.ts index 895691b1..5d709323 100644 --- a/src/utils/fetch/chats.ts +++ b/src/utils/fetch/chats.ts @@ -1,79 +1,98 @@ -import { apiFetch } from "../apiFetch.js"; +import { apiFetch } from '../apiFetch.js'; const API_BASE_URL = import.meta.env.VITE_SERVER_URL; export async function fetchChatMessages(sessionId: string) { const res = await apiFetch(`${API_BASE_URL}/api/chats/${sessionId}`, { - credentials: "include", + credentials: 'include', }); - if (!res.ok) throw new Error("Failed to fetch chat messages"); + if (!res.ok) throw new Error('Failed to fetch chat messages'); return res.json(); } export async function sendChatMessage(sessionId: string, message: string) { const res = await apiFetch(`${API_BASE_URL}/api/chats/${sessionId}`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }), }); - if (!res.ok) throw new Error("Failed to send message"); + if (!res.ok) throw new Error('Failed to send message'); return res.json(); } export async function deleteChatMessage(sessionId: string, messageId: number) { - const res = await apiFetch(`${API_BASE_URL}/api/chats/${sessionId}/${messageId}`, { - method: "DELETE", - credentials: "include", - }); - if (!res.ok) throw new Error("Failed to delete message"); + const res = await apiFetch( + `${API_BASE_URL}/api/chats/${sessionId}/${messageId}`, + { + method: 'DELETE', + credentials: 'include', + } + ); + if (!res.ok) throw new Error('Failed to delete message'); return res.json(); } -export async function reportChatMessage(sessionId: string, messageId: number, reason: string) { - const res = await apiFetch(`${API_BASE_URL}/api/chats/${sessionId}/${messageId}/report`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reason }), - }); - if (!res.ok) throw new Error("Failed to report message"); +export async function reportChatMessage( + sessionId: string, + messageId: number, + reason: string +) { + const res = await apiFetch( + `${API_BASE_URL}/api/chats/${sessionId}/${messageId}/report`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason }), + } + ); + if (!res.ok) throw new Error('Failed to report message'); return res.json(); } export async function fetchGlobalChatMessages() { const res = await apiFetch(`${API_BASE_URL}/api/chats/global/messages`, { - credentials: "include", + credentials: 'include', }); - if (!res.ok) throw new Error("Failed to fetch global chat messages"); + if (!res.ok) throw new Error('Failed to fetch global chat messages'); return res.json(); } -export async function reportGlobalChatMessage(messageId: number, reason: string) { - const res = await apiFetch(`${API_BASE_URL}/api/chats/global/${messageId}/report`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reason }), - }); - if (!res.ok) throw new Error("Failed to report message"); +export async function reportGlobalChatMessage( + messageId: number, + reason: string +) { + const res = await apiFetch( + `${API_BASE_URL}/api/chats/global/${messageId}/report`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason }), + } + ); + if (!res.ok) throw new Error('Failed to report message'); return res.json(); } export async function fetchAATCChatMessages() { const res = await apiFetch(`${API_BASE_URL}/api/chats/aatc/messages`, { - credentials: "include", + credentials: 'include', }); - if (!res.ok) throw new Error("Failed to fetch AATC chat messages"); + if (!res.ok) throw new Error('Failed to fetch AATC chat messages'); return res.json(); } export async function reportAATCChatMessage(messageId: number, reason: string) { - const res = await apiFetch(`${API_BASE_URL}/api/chats/aatc/${messageId}/report`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reason }), - }); - if (!res.ok) throw new Error("Failed to report message"); + const res = await apiFetch( + `${API_BASE_URL}/api/chats/aatc/${messageId}/report`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason }), + } + ); + if (!res.ok) throw new Error('Failed to report message'); return res.json(); -} \ No newline at end of file +} diff --git a/src/utils/fetch/data.ts b/src/utils/fetch/data.ts index 8a840ff1..af58e2cc 100644 --- a/src/utils/fetch/data.ts +++ b/src/utils/fetch/data.ts @@ -1,9 +1,9 @@ -import type { Airport, AirportFrequency } from "../../types/airports"; -import type { Aircraft } from "../../types/aircraft"; -import type { Airline } from "../../types/airlines"; -import type { TesterSettings } from "./testers"; -import type { Notification as AdminNotification } from "../fetch/admin"; -import { clientApiUrl } from "../clientApiBase"; +import type { Airport, AirportFrequency } from '../../types/airports'; +import type { Aircraft } from '../../types/aircraft'; +import type { Airline } from '../../types/airlines'; +import type { TesterSettings } from './testers'; +import type { Notification as AdminNotification } from '../fetch/admin'; +import { clientApiUrl } from '../clientApiBase'; interface AvailableImage { filename: string; @@ -11,7 +11,7 @@ interface AvailableImage { extension: string; } -const publicDataFetchInit: RequestInit = { credentials: "omit" }; +const publicDataFetchInit: RequestInit = { credentials: 'omit' }; async function fetchData(endpoint: string): Promise { try { @@ -30,19 +30,19 @@ async function fetchData(endpoint: string): Promise { } export function fetchAirports(): Promise { - return fetchData("airports"); + return fetchData('airports'); } export function fetchAircrafts(): Promise { - return fetchData("aircrafts"); + return fetchData('aircrafts'); } export function fetchAirlines(): Promise { - return fetchData("airlines"); + return fetchData('airlines'); } export function fetchFrequencies(): Promise { - return fetchData("frequencies"); + return fetchData('frequencies'); } export function fetchRunways(icao: string): Promise { @@ -58,11 +58,11 @@ export function fetchStars(icao: string): Promise { } export function fetchBackgrounds(): Promise { - return fetchData("backgrounds"); + return fetchData('backgrounds'); } export function fetchStatistics(): Promise { - return fetchData("statistics"); + return fetchData('statistics'); } export async function fetchLeaderboard(): Promise< @@ -80,7 +80,7 @@ export async function fetchLeaderboard(): Promise< `${import.meta.env.VITE_SERVER_URL}/api/data/leaderboard`, publicDataFetchInit ); - if (!response.ok) throw new Error("Failed to fetch leaderboard"); + if (!response.ok) throw new Error('Failed to fetch leaderboard'); return response.json(); } @@ -96,14 +96,14 @@ export async function getTesterSettings(): Promise { const settings: TesterSettings = await response.json(); return settings; } catch (error) { - console.error("Error fetching tester settings:", error); + console.error('Error fetching tester settings:', error); return { tester_gate_enabled: true }; } } export async function fetchActiveNotifications(): Promise { const response = await fetch( - clientApiUrl("/api/data/notifications/active"), + clientApiUrl('/api/data/notifications/active'), publicDataFetchInit ); if (!response.ok) { @@ -120,7 +120,7 @@ export async function fetchUserRanks( publicDataFetchInit ); if (!response.ok) { - throw new Error("Failed to fetch user ranks"); + throw new Error('Failed to fetch user ranks'); } return response.json(); } @@ -135,12 +135,12 @@ export async function fetchRoute( route: string; sid?: string; star?: string; - flParity?: "ODD" | "EVEN"; + flParity?: 'ODD' | 'EVEN'; success: boolean; }> { try { const params = new URLSearchParams({ from, to }); - if (runway) params.set("runway", runway); + if (runway) params.set('runway', runway); const response = await fetch( `${import.meta.env.VITE_SERVER_URL}/api/data/findRoute?${params}`, publicDataFetchInit @@ -152,6 +152,6 @@ export async function fetchRoute( return { ...data, success: true }; } catch (error) { console.error(`Error fetching route from ${from} to ${to}:`, error); - return { path: [], distance: 0, route: "", success: false }; + return { path: [], distance: 0, route: '', success: false }; } -} \ No newline at end of file +} diff --git a/src/utils/fetch/developer.ts b/src/utils/fetch/developer.ts index b2f40739..c194e49e 100644 --- a/src/utils/fetch/developer.ts +++ b/src/utils/fetch/developer.ts @@ -1,5 +1,5 @@ -import { apiFetch } from "../apiFetch.js"; -import type { DeveloperApiPublicSpec } from "../../types/developerApiSpec"; +import { apiFetch } from '../apiFetch.js'; +import type { DeveloperApiPublicSpec } from '../../types/developerApiSpec'; const API_BASE_URL = import.meta.env.VITE_SERVER_URL; @@ -34,50 +34,60 @@ export interface DeveloperApplicationState { export async function fetchDeveloperApiDocs(): Promise { const tryLive = async () => { const res = await apiFetch(`${API_BASE_URL}/api/developer/docs`, { - credentials: "include", + credentials: 'include', }); - if (!res.ok) throw new Error("docs request failed"); + if (!res.ok) throw new Error('docs request failed'); return res.json() as Promise; }; try { return await tryLive(); } catch { - const res = await fetch("/developer-api-docs.json", { credentials: "same-origin" }); - if (!res.ok) throw new Error("Failed to load bundled developer-api-docs.json"); + const res = await fetch('/developer-api-docs.json', { + credentials: 'same-origin', + }); + if (!res.ok) + throw new Error('Failed to load bundled developer-api-docs.json'); return res.json() as Promise; } } -export async function fetchDeveloperCatalog(): Promise { +export async function fetchDeveloperCatalog(): Promise< + DeveloperScopeCatalogEntry[] +> { const res = await apiFetch(`${API_BASE_URL}/api/developer/catalog`, { - credentials: "include", + credentials: 'include', }); - if (!res.ok) throw new Error("Failed to load scope catalog"); + if (!res.ok) throw new Error('Failed to load scope catalog'); const data = await res.json(); return data.scopes as DeveloperScopeCatalogEntry[]; } export async function fetchDeveloperApplication(): Promise { const res = await apiFetch(`${API_BASE_URL}/api/developer/application`, { - credentials: "include", + credentials: 'include', }); - if (!res.ok) throw new Error("Failed to load application"); + if (!res.ok) throw new Error('Failed to load application'); return res.json(); } export async function patchDeveloperNotificationEmail( - email: string | null, + email: string | null ): Promise<{ notificationEmail: string | null }> { - const res = await apiFetch(`${API_BASE_URL}/api/developer/profile/notification-email`, { - method: "PATCH", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - }); + const res = await apiFetch( + `${API_BASE_URL}/api/developer/profile/notification-email`, + { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + } + ); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Failed to save email"); + throw new Error( + (err as { error?: string }).error || 'Failed to save email' + ); } return res.json() as Promise<{ notificationEmail: string | null }>; } @@ -88,9 +98,9 @@ export async function submitDeveloperApplication(input: { requestedScopes: string[]; }): Promise { const res = await apiFetch(`${API_BASE_URL}/api/developer/application`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ who: input.who, why: input.why, @@ -99,7 +109,7 @@ export async function submitDeveloperApplication(input: { }); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Failed to submit"); + throw new Error((err as { error?: string }).error || 'Failed to submit'); } } @@ -108,19 +118,22 @@ export async function submitDeveloperScopeExpansionRequest(input: { why: string; additionalScopes: string[]; }): Promise { - const res = await apiFetch(`${API_BASE_URL}/api/developer/application/scope-expansion`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - who: input.who, - why: input.why, - additionalScopes: input.additionalScopes, - }), - }); + const res = await apiFetch( + `${API_BASE_URL}/api/developer/application/scope-expansion`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + who: input.who, + why: input.why, + additionalScopes: input.additionalScopes, + }), + } + ); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Failed to submit"); + throw new Error((err as { error?: string }).error || 'Failed to submit'); } } @@ -146,16 +159,16 @@ export type DeveloperKeysPayload = { export async function fetchDeveloperKeys(): Promise { const res = await apiFetch(`${API_BASE_URL}/api/developer/keys`, { - credentials: "include", + credentials: 'include', }); - if (!res.ok) throw new Error("Failed to list keys"); + if (!res.ok) throw new Error('Failed to list keys'); const data = (await res.json()) as { keys?: DeveloperKeyRow[]; defaultRateLimitPerMinute?: number; }; const raw = data.defaultRateLimitPerMinute; const defaultRateLimitPerMinute = - typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? raw : 120; + typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : 120; return { keys: Array.isArray(data.keys) ? data.keys : [], defaultRateLimitPerMinute, @@ -164,7 +177,7 @@ export async function fetchDeveloperKeys(): Promise { export type CreateDeveloperKeyResult = | { - kind: "active"; + kind: 'active'; id: string; name: string; prefix: string; @@ -173,7 +186,7 @@ export type CreateDeveloperKeyResult = createdAt: string; } | { - kind: "pending"; + kind: 'pending'; id: string; name: string; prefix: string; @@ -188,30 +201,32 @@ export async function createDeveloperKey(input: { scopes: string[]; }): Promise { const res = await apiFetch(`${API_BASE_URL}/api/developer/keys`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Failed to create key"); + throw new Error( + (err as { error?: string }).error || 'Failed to create key' + ); } const data = (await res.json()) as Record; - if (res.status === 202 || data.status === "pending") { + if (res.status === 202 || data.status === 'pending') { return { - kind: "pending", + kind: 'pending', id: String(data.id), name: String(data.name), prefix: String(data.prefix), - status: String(data.status ?? "pending"), + status: String(data.status ?? 'pending'), requestedScopes: (data.requestedScopes as string[]) ?? [], - message: String(data.message ?? "Pending admin approval"), + message: String(data.message ?? 'Pending admin approval'), createdAt: String(data.createdAt), }; } return { - kind: "active", + kind: 'active', id: String(data.id), name: String(data.name), prefix: String(data.prefix), @@ -223,34 +238,39 @@ export async function createDeveloperKey(input: { export async function dismissDeveloperAdminNotice(): Promise { const res = await apiFetch(`${API_BASE_URL}/api/developer/notice/dismiss`, { - method: "POST", - credentials: "include", + method: 'POST', + credentials: 'include', }); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Failed to dismiss"); + throw new Error((err as { error?: string }).error || 'Failed to dismiss'); } } export async function deleteDeveloperKey(id: string): Promise { const res = await apiFetch(`${API_BASE_URL}/api/developer/keys/${id}`, { - method: "DELETE", - credentials: "include", + method: 'DELETE', + credentials: 'include', }); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Failed to delete key"); + throw new Error( + (err as { error?: string }).error || 'Failed to delete key' + ); } } export async function revokeDeveloperKey(id: string): Promise { - const res = await apiFetch(`${API_BASE_URL}/api/developer/keys/${id}/revoke`, { - method: "POST", - credentials: "include", - }); + const res = await apiFetch( + `${API_BASE_URL}/api/developer/keys/${id}/revoke`, + { + method: 'POST', + credentials: 'include', + } + ); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Failed to revoke"); + throw new Error((err as { error?: string }).error || 'Failed to revoke'); } } @@ -260,13 +280,18 @@ export async function rotateDeveloperKey(id: string): Promise<{ prefix: string; scopes: string[]; }> { - const res = await apiFetch(`${API_BASE_URL}/api/developer/keys/${id}/rotate`, { - method: "POST", - credentials: "include", - }); + const res = await apiFetch( + `${API_BASE_URL}/api/developer/keys/${id}/rotate`, + { + method: 'POST', + credentials: 'include', + } + ); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error || "Failed to rotate key"); + throw new Error( + (err as { error?: string }).error || 'Failed to rotate key' + ); } return res.json(); } @@ -274,7 +299,7 @@ export async function rotateDeveloperKey(id: string): Promise<{ export interface DeveloperDashboardSummary { days?: number; hours?: number; - granularity?: "day" | "hour"; + granularity?: 'day' | 'hour'; daily: { date: string; count: number }[]; byScope: { scope_id: string; count: number }[]; recent: { @@ -294,15 +319,18 @@ export async function fetchDeveloperDashboardSummary(opts?: { days?: number; hours?: number; }): Promise { - let q = ""; + let q = ''; if (opts?.hours != null) { q = `?hours=${opts.hours}`; } else if (opts?.days != null) { q = `?days=${opts.days}`; } - const res = await apiFetch(`${API_BASE_URL}/api/developer/dashboard/summary${q}`, { - credentials: "include", - }); - if (!res.ok) throw new Error("Failed to load dashboard"); + const res = await apiFetch( + `${API_BASE_URL}/api/developer/dashboard/summary${q}`, + { + credentials: 'include', + } + ); + if (!res.ok) throw new Error('Failed to load dashboard'); return res.json(); -} \ No newline at end of file +} diff --git a/src/utils/fetch/flights.ts b/src/utils/fetch/flights.ts index 25b18afe..e1c58436 100644 --- a/src/utils/fetch/flights.ts +++ b/src/utils/fetch/flights.ts @@ -78,9 +78,12 @@ export interface MyFlightLogsResponse { export async function fetchMyFlightLogs( flightId: string ): Promise { - const res = await apiFetch(`${API_BASE_URL}/api/flights/me/${flightId}/logs`, { - credentials: 'include', - }); + const res = await apiFetch( + `${API_BASE_URL}/api/flights/me/${flightId}/logs`, + { + credentials: 'include', + } + ); if (!res.ok) throw new Error('Failed to fetch flight logs'); return res.json(); } @@ -127,12 +130,15 @@ export async function updateFlightNotes( flightId: string, notes: string ): Promise { - const res = await apiFetch(`${API_BASE_URL}/api/flights/me/${flightId}/notes`, { - method: 'PATCH', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ notes }), - }); + const res = await apiFetch( + `${API_BASE_URL}/api/flights/me/${flightId}/notes`, + { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ notes }), + } + ); if (!res.ok) throw new Error('Failed to save notes'); } @@ -147,11 +153,14 @@ export async function uploadSnapImage( ): Promise<{ url: string; cephie_id: string; snap_images: SnapImage[] }> { const formData = new FormData(); formData.append('image', file); - const res = await apiFetch(`${API_BASE_URL}/api/flights/me/${flightId}/snap`, { - method: 'POST', - credentials: 'include', - body: formData, - }); + const res = await apiFetch( + `${API_BASE_URL}/api/flights/me/${flightId}/snap`, + { + method: 'POST', + credentials: 'include', + body: formData, + } + ); if (!res.ok) throw new Error('Failed to upload snap'); return res.json(); } @@ -173,10 +182,13 @@ export async function deleteSnapImage( export async function toggleFeaturedOnProfile( flightId: string ): Promise<{ featured: boolean }> { - const res = await apiFetch(`${API_BASE_URL}/api/flights/me/${flightId}/feature`, { - method: 'PATCH', - credentials: 'include', - }); + const res = await apiFetch( + `${API_BASE_URL}/api/flights/me/${flightId}/feature`, + { + method: 'PATCH', + credentials: 'include', + } + ); if (res.status === 409) throw new Error('CAP_REACHED'); if (!res.ok) throw new Error('Failed to toggle featured status'); return res.json(); diff --git a/src/utils/fetch/ratings.ts b/src/utils/fetch/ratings.ts index 4784788c..c7b3e77a 100644 --- a/src/utils/fetch/ratings.ts +++ b/src/utils/fetch/ratings.ts @@ -1,13 +1,20 @@ import { apiFetch } from '../apiFetch.js'; -export async function submitControllerRating(controllerId: string, rating: number, flightId?: string) { - const response = await apiFetch(`${import.meta.env.VITE_SERVER_URL}/api/ratings`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ controllerId, rating, flightId }), - }); +export async function submitControllerRating( + controllerId: string, + rating: number, + flightId?: string +) { + const response = await apiFetch( + `${import.meta.env.VITE_SERVER_URL}/api/ratings`, + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ controllerId, rating, flightId }), + } + ); if (!response.ok) { const error = await response.json(); diff --git a/src/utils/fetch/sessions.ts b/src/utils/fetch/sessions.ts index 1305decc..3861301f 100644 --- a/src/utils/fetch/sessions.ts +++ b/src/utils/fetch/sessions.ts @@ -1,32 +1,35 @@ -import { apiFetch } from "../apiFetch.js"; -import type { SessionInfo } from "../../types/session"; +import { apiFetch } from '../apiFetch.js'; +import type { SessionInfo } from '../../types/session'; const API_BASE_URL = import.meta.env.VITE_SERVER_URL; -export async function fetchSession(sessionId: string, accessId: string): Promise { +export async function fetchSession( + sessionId: string, + accessId: string +): Promise { const url = new URL(`${API_BASE_URL}/api/sessions/${sessionId}`); - url.searchParams.append("accessId", accessId); + url.searchParams.append('accessId', accessId); const res = await apiFetch(url.toString(), { - credentials: "include", + credentials: 'include', }); - if (!res.ok) throw new Error("Failed to fetch session"); + if (!res.ok) throw new Error('Failed to fetch session'); return res.json(); } export async function fetchMySessions(): Promise { const res = await apiFetch(`${API_BASE_URL}/api/sessions/mine`, { - credentials: "include", + credentials: 'include', }); - if (!res.ok) throw new Error("Failed to fetch user sessions"); + if (!res.ok) throw new Error('Failed to fetch user sessions'); return res.json(); } export async function fetchAllSessions(): Promise { const res = await apiFetch(`${API_BASE_URL}/api/sessions/`, { - credentials: "include", + credentials: 'include', }); - if (!res.ok) throw new Error("Failed to fetch sessions"); + if (!res.ok) throw new Error('Failed to fetch sessions'); return res.json(); } @@ -39,14 +42,14 @@ export async function createSession(data: { isTutorial?: boolean; }): Promise { const res = await apiFetch(`${API_BASE_URL}/api/sessions/create`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!res.ok) { const body = await res.json().catch(() => ({})); - throw new Error(body.message || body.error || "Failed to create session"); + throw new Error(body.message || body.error || 'Failed to create session'); } return res.json(); } @@ -54,45 +57,45 @@ export async function createSession(data: { export async function updateSession( sessionId: string, accessId: string, - updates: Partial, + updates: Partial ): Promise { const url = new URL(`${API_BASE_URL}/api/sessions/${sessionId}`); - url.searchParams.append("accessId", accessId); + url.searchParams.append('accessId', accessId); const res = await apiFetch(url.toString(), { - method: "PUT", - credentials: "include", - headers: { "Content-Type": "application/json" }, + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }); - if (!res.ok) throw new Error("Failed to update session"); + if (!res.ok) throw new Error('Failed to update session'); return res.json(); } export async function updateSessionName( sessionId: string, - name: string, + name: string ): Promise<{ customName: string }> { const res = await apiFetch(`${API_BASE_URL}/api/sessions/update-name`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId, name }), }); - if (!res.ok) throw new Error("Failed to update session name"); + if (!res.ok) throw new Error('Failed to update session name'); return res.json(); } export async function deleteSession( - sessionId: string, + sessionId: string ): Promise<{ message: string; sessionId: string }> { const res = await apiFetch(`${API_BASE_URL}/api/sessions/delete`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId }), }); - if (!res.ok) throw new Error("Failed to delete session"); + if (!res.ok) throw new Error('Failed to delete session'); return res.json(); } @@ -102,18 +105,21 @@ export async function deleteOldestSession(): Promise<{ airportIcao: string; createdAt: string; }> { - const response = await apiFetch(`${API_BASE_URL}/api/sessions/delete-oldest`, { - method: "POST", - credentials: "include", - headers: { - "Content-Type": "application/json", - }, - }); + const response = await apiFetch( + `${API_BASE_URL}/api/sessions/delete-oldest`, + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + } + ); if (!response.ok) { const errorData = await response.json(); - throw new Error(errorData.message || "Failed to delete oldest session"); + throw new Error(errorData.message || 'Failed to delete oldest session'); } return await response.json(); -} \ No newline at end of file +} diff --git a/src/utils/fetch/testers.ts b/src/utils/fetch/testers.ts index b2af321f..badc3877 100644 --- a/src/utils/fetch/testers.ts +++ b/src/utils/fetch/testers.ts @@ -28,14 +28,17 @@ export interface TesterSettings { } async function makeTesterRequest(endpoint: string, options?: RequestInit) { - const response = await apiFetch(`${API_BASE_URL}/api/admin/testers${endpoint}`, { - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, - ...options, - }); + const response = await apiFetch( + `${API_BASE_URL}/api/admin/testers${endpoint}`, + { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + ...options, + } + ); if (!response.ok) { if (response.status === 403) { diff --git a/src/utils/playSound.ts b/src/utils/playSound.ts index 30c1eaa4..5c3b1f5e 100644 --- a/src/utils/playSound.ts +++ b/src/utils/playSound.ts @@ -21,11 +21,13 @@ export function playAudioWithGain( return; } - const audioContext = new (window.AudioContext || + const audioContext = new ( + window.AudioContext || ( window as Window & typeof globalThis & { webkitAudioContext?: typeof AudioContext } - ).webkitAudioContext!)(); + ).webkitAudioContext! + )(); const source = audioContext.createMediaElementSource(audioElement); const gainNode = audioContext.createGain(); diff --git a/src/utils/roles.ts b/src/utils/roles.ts index f2b79bb8..3484d4bc 100644 --- a/src/utils/roles.ts +++ b/src/utils/roles.ts @@ -85,12 +85,14 @@ export const AVAILABLE_PERMISSIONS = [ { key: 'pfatc_sector', label: 'PFATC Sector Controller', - description: 'Edit flights across all PFATC network sessions during events and create PFATC sessions in event mode', + description: + 'Edit flights across all PFATC network sessions during events and create PFATC sessions in event mode', }, { key: 'aatc_sector', label: 'AATC Sector Controller', - description: 'Edit flights across all Advanced ATC sessions during events and create AATC sessions in event mode', + description: + 'Edit flights across all Advanced ATC sessions during events and create AATC sessions in event mode', }, ]; diff --git a/src/utils/sessionKind.ts b/src/utils/sessionKind.ts index d68701d6..323dc28d 100644 --- a/src/utils/sessionKind.ts +++ b/src/utils/sessionKind.ts @@ -3,4 +3,4 @@ export function hasAdvancedNetworkFeatures(session: { isAdvancedATC?: boolean; }): boolean { return Boolean(session.isPFATC) || Boolean(session.isAdvancedATC); -} \ No newline at end of file +} diff --git a/src/utils/tutorialTracking.ts b/src/utils/tutorialTracking.ts index 26131cf1..316f7f1e 100644 --- a/src/utils/tutorialTracking.ts +++ b/src/utils/tutorialTracking.ts @@ -11,17 +11,36 @@ export function trackTutorialEvent(section: string, data: CallBackProps) { if (type === 'step:after') { if (action === 'next') { - posthog.capture('tutorial_step_viewed', { section, step_index: index, step_title: stepTitle }); + posthog.capture('tutorial_step_viewed', { + section, + step_index: index, + step_title: stepTitle, + }); } else if (action === 'prev') { - posthog.capture('tutorial_step_back', { section, step_index: index, step_title: stepTitle }); + posthog.capture('tutorial_step_back', { + section, + step_index: index, + step_title: stepTitle, + }); } else if (action === 'close') { - posthog.capture('tutorial_abandoned', { section, step_index: index, step_title: stepTitle }); + posthog.capture('tutorial_abandoned', { + section, + step_index: index, + step_title: stepTitle, + }); } } if (status === 'finished') { - posthog.capture('tutorial_section_completed', { section, total_steps: index + 1 }); + posthog.capture('tutorial_section_completed', { + section, + total_steps: index + 1, + }); } else if (status === 'skipped') { - posthog.capture('tutorial_skipped', { section, at_step: index, step_title: stepTitle }); + posthog.capture('tutorial_skipped', { + section, + at_step: index, + step_title: stepTitle, + }); } } diff --git a/tests/astro/submitSeo.test.ts b/tests/astro/submitSeo.test.ts index cfbb39c1..f241badd 100644 --- a/tests/astro/submitSeo.test.ts +++ b/tests/astro/submitSeo.test.ts @@ -38,4 +38,4 @@ describe('buildSubmitSessionSeo', () => { const image = resolveSubmitOgImage(siteOrigin, 'abc12345'); expect(image).toBe(`${siteOrigin}/api/og/submit/abc12345`); }); -}); \ No newline at end of file +}); diff --git a/tests/server/db/audit.test.ts b/tests/server/db/audit.test.ts index afd32b76..02223ac1 100644 --- a/tests/server/db/audit.test.ts +++ b/tests/server/db/audit.test.ts @@ -30,7 +30,10 @@ describe('logAdminAction', () => { }); it('returns new audit log id', async () => { - mocks.executeTakeFirst.mockResolvedValue({ id: 99, created_at: new Date() }); + mocks.executeTakeFirst.mockResolvedValue({ + id: 99, + created_at: new Date(), + }); const id = await logAdminAction({ adminId: '1', diff --git a/tests/server/db/ban.test.ts b/tests/server/db/ban.test.ts index 54d0060b..1e2f821c 100644 --- a/tests/server/db/ban.test.ts +++ b/tests/server/db/ban.test.ts @@ -24,10 +24,10 @@ vi.mock('../../../server/db/connection.js', () => ({ // dual-mode select: supports .execute() directly (count query) // or .orderBy().limit().offset().execute() (list query) const inner = { - where: vi.fn(), + where: vi.fn(), orderBy: vi.fn(), - limit: vi.fn(), - offset: vi.fn(), + limit: vi.fn(), + offset: vi.fn(), execute: mocks.countExecute, }; inner.where.mockReturnValue(inner); @@ -36,14 +36,14 @@ vi.mock('../../../server/db/connection.js', () => ({ inner.offset.mockReturnValue({ execute: mocks.listExecute }); const chain = { - leftJoin: vi.fn(), - selectAll: vi.fn(), - where: vi.fn(), - orderBy: vi.fn(), - limit: vi.fn(), - offset: vi.fn(), + leftJoin: vi.fn(), + selectAll: vi.fn(), + where: vi.fn(), + orderBy: vi.fn(), + limit: vi.fn(), + offset: vi.fn(), executeTakeFirst: mocks.findExecute, - select: vi.fn().mockReturnValue(inner), + select: vi.fn().mockReturnValue(inner), }; chain.leftJoin.mockReturnValue(chain); chain.selectAll.mockReturnValue(chain); @@ -117,7 +117,11 @@ describe('banUser', () => { reason: 'spam', bannedBy: 'admin', }); - expect(redisConnection.setex).toHaveBeenCalledWith('ban:u42', expect.any(Number), '1'); + expect(redisConnection.setex).toHaveBeenCalledWith( + 'ban:u42', + expect.any(Number), + '1' + ); }); it('caches ip ban in redis when ip is provided', async () => { @@ -129,7 +133,11 @@ describe('banUser', () => { reason: 'spam', bannedBy: 'admin', }); - expect(redisConnection.setex).toHaveBeenCalledWith('ban:ip:9.9.9.9', expect.any(Number), '1'); + expect(redisConnection.setex).toHaveBeenCalledWith( + 'ban:ip:9.9.9.9', + expect.any(Number), + '1' + ); }); it('treats empty string expiresAt as undefined', async () => { @@ -196,7 +204,12 @@ describe('isIpBanned', () => { }); it('returns the ban record when ip is banned', async () => { - const ban = { id: 2, ip_address: '1.2.3.4', active: true, banned_at: new Date() }; + const ban = { + id: 2, + ip_address: '1.2.3.4', + active: true, + banned_at: new Date(), + }; mocks.findExecute.mockResolvedValue(ban); const result = await isIpBanned('1.2.3.4'); expect(result).toEqual(ban); diff --git a/tests/server/db/feedback.test.ts b/tests/server/db/feedback.test.ts index 1daa804f..cb5e0a2b 100644 --- a/tests/server/db/feedback.test.ts +++ b/tests/server/db/feedback.test.ts @@ -28,7 +28,11 @@ vi.mock('../../../server/db/connection.js', () => ({ redisConnection: {}, })); -import { addFeedback, deleteFeedback, getAllFeedback } from '../../../server/db/feedback.js'; +import { + addFeedback, + deleteFeedback, + getAllFeedback, +} from '../../../server/db/feedback.js'; describe('getAllFeedback', () => { beforeEach(() => { diff --git a/tests/server/db/leaderboard.test.ts b/tests/server/db/leaderboard.test.ts index 9be5c22d..0bd53703 100644 --- a/tests/server/db/leaderboard.test.ts +++ b/tests/server/db/leaderboard.test.ts @@ -22,7 +22,10 @@ vi.mock('../../../server/db/users.js', () => ({ getUserById: vi.fn(), })); -import { getUserRank, updateLeaderboard } from '../../../server/db/leaderboard.js'; +import { + getUserRank, + updateLeaderboard, +} from '../../../server/db/leaderboard.js'; describe('updateLeaderboard', () => { beforeEach(() => { diff --git a/tests/server/db/ratings.test.ts b/tests/server/db/ratings.test.ts index d6800007..ba7ff52f 100644 --- a/tests/server/db/ratings.test.ts +++ b/tests/server/db/ratings.test.ts @@ -21,7 +21,10 @@ vi.mock('../../../server/db/connection.js', () => ({ redisConnection: {}, })); -import { addControllerRating, getControllerRatingStats } from '../../../server/db/ratings.js'; +import { + addControllerRating, + getControllerRatingStats, +} from '../../../server/db/ratings.js'; describe('addControllerRating', () => { beforeEach(() => { diff --git a/tests/server/db/roles.test.ts b/tests/server/db/roles.test.ts index 45e9b2f8..a71c665e 100644 --- a/tests/server/db/roles.test.ts +++ b/tests/server/db/roles.test.ts @@ -22,13 +22,17 @@ vi.mock('../../../server/db/connection.js', () => ({ }), insertInto: vi.fn(() => ({ values: vi.fn(() => ({ - returningAll: vi.fn(() => ({ executeTakeFirst: mocks.executeTakeFirst })), + returningAll: vi.fn(() => ({ + executeTakeFirst: mocks.executeTakeFirst, + })), })), })), updateTable: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn(() => ({ - returningAll: vi.fn(() => ({ executeTakeFirst: mocks.executeTakeFirst })), + returningAll: vi.fn(() => ({ + executeTakeFirst: mocks.executeTakeFirst, + })), })), })), })), @@ -39,7 +43,11 @@ vi.mock('../../../server/db/connection.js', () => ({ redisConnection: {}, })); -import { createRole, getAllRoles, getRoleById } from '../../../server/db/roles.js'; +import { + createRole, + getAllRoles, + getRoleById, +} from '../../../server/db/roles.js'; describe('getRoleById', () => { beforeEach(() => { diff --git a/tests/server/db/sessions.test.ts b/tests/server/db/sessions.test.ts index cb33ff81..453e5d4b 100644 --- a/tests/server/db/sessions.test.ts +++ b/tests/server/db/sessions.test.ts @@ -21,7 +21,10 @@ vi.mock('../../../server/db/connection.js', () => ({ redisConnection: {}, })); -import { getSessionById, getSessionsByUser } from '../../../server/db/sessions.js'; +import { + getSessionById, + getSessionsByUser, +} from '../../../server/db/sessions.js'; describe('getSessionById', () => { beforeEach(() => { diff --git a/tests/server/db/statistics.test.ts b/tests/server/db/statistics.test.ts index bcd4730f..92e01222 100644 --- a/tests/server/db/statistics.test.ts +++ b/tests/server/db/statistics.test.ts @@ -1,11 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ execute: vi.fn(), executeTakeFirst: vi.fn(), })); -vi.mock("../../../server/db/connection.js", () => ({ +vi.mock('../../../server/db/connection.js', () => ({ mainDb: { insertInto: vi.fn(() => ({ values: vi.fn(() => ({ @@ -29,65 +29,65 @@ import { recordNewFlight, recordNewSession, recordNewUser, -} from "../../../server/db/statistics.js"; +} from '../../../server/db/statistics.js'; -describe("recordLogin", () => { +describe('recordLogin', () => { beforeEach(() => { mocks.execute.mockClear(); mocks.execute.mockResolvedValue(undefined); }); - it("runs insert upsert without throwing", async () => { + it('runs insert upsert without throwing', async () => { await recordLogin(); expect(mocks.execute).toHaveBeenCalled(); }); }); -describe("recordNewSession", () => { +describe('recordNewSession', () => { beforeEach(() => { mocks.execute.mockClear(); mocks.execute.mockResolvedValue(undefined); }); - it("runs insert upsert without throwing", async () => { + it('runs insert upsert without throwing', async () => { await recordNewSession(); expect(mocks.execute).toHaveBeenCalled(); }); }); -describe("recordNewFlight", () => { +describe('recordNewFlight', () => { beforeEach(() => { mocks.execute.mockClear(); mocks.execute.mockResolvedValue(undefined); }); - it("runs insert upsert without throwing", async () => { + it('runs insert upsert without throwing', async () => { await recordNewFlight(); expect(mocks.execute).toHaveBeenCalled(); }); }); -describe("recordNewUser", () => { +describe('recordNewUser', () => { beforeEach(() => { mocks.execute.mockClear(); mocks.execute.mockResolvedValue(undefined); }); - it("runs insert upsert without throwing", async () => { + it('runs insert upsert without throwing', async () => { await recordNewUser(); expect(mocks.execute).toHaveBeenCalled(); }); }); -describe("cleanupOldStatistics", () => { +describe('cleanupOldStatistics', () => { beforeEach(() => { mocks.executeTakeFirst.mockClear(); mocks.executeTakeFirst.mockResolvedValue({ numDeletedRows: 0n }); }); - it("can delete old rows when throttle allows", async () => { + it('can delete old rows when throttle allows', async () => { await cleanupOldStatistics(); await cleanupOldStatistics(); expect(mocks.executeTakeFirst).toHaveBeenCalledTimes(1); }); -}); \ No newline at end of file +}); diff --git a/tests/server/db/testers.test.ts b/tests/server/db/testers.test.ts index 52fc3838..4d69a6c3 100644 --- a/tests/server/db/testers.test.ts +++ b/tests/server/db/testers.test.ts @@ -18,7 +18,9 @@ vi.mock('../../../server/db/connection.js', () => ({ })), deleteFrom: vi.fn(() => ({ where: vi.fn(() => ({ - returningAll: vi.fn(() => ({ executeTakeFirst: mocks.executeTakeFirst })), + returningAll: vi.fn(() => ({ + executeTakeFirst: mocks.executeTakeFirst, + })), })), })), selectFrom: vi.fn(() => { diff --git a/tests/server/og/renderSubmitOgPng.test.ts b/tests/server/og/renderSubmitOgPng.test.ts index c77661a0..d7efa85a 100644 --- a/tests/server/og/renderSubmitOgPng.test.ts +++ b/tests/server/og/renderSubmitOgPng.test.ts @@ -30,4 +30,4 @@ describe('buildSubmitOgCardProps', () => { expect(props.atisSnippet?.startsWith('INFO B:')).toBe(true); expect(props.atisSnippet).toContain('RWY 29 IN USE'); }); -}); \ No newline at end of file +}); diff --git a/tests/server/routes/metar.route.test.ts b/tests/server/routes/metar.route.test.ts index f5d82bf2..14b5d32a 100644 --- a/tests/server/routes/metar.route.test.ts +++ b/tests/server/routes/metar.route.test.ts @@ -1,22 +1,24 @@ -import express from "express"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import express from 'express'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { appRequest } from "../helpers/appRequest.js"; +import { appRequest } from '../helpers/appRequest.js'; const { redisStore } = vi.hoisted(() => ({ redisStore: new Map(), })); -vi.mock("../../../server/db/connection.js", () => ({ +vi.mock('../../../server/db/connection.js', () => ({ redisConnection: { get: vi.fn((k: string) => Promise.resolve(redisStore.get(k) ?? null)), set: vi.fn((k: string, v: string, ..._rest: unknown[]) => { redisStore.set(k, v); - return Promise.resolve("OK"); + return Promise.resolve('OK'); }), keys: vi.fn((pattern: string) => { - const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern; - return Promise.resolve([...redisStore.keys()].filter((key) => key.startsWith(prefix))); + const prefix = pattern.endsWith('*') ? pattern.slice(0, -1) : pattern; + return Promise.resolve( + [...redisStore.keys()].filter((key) => key.startsWith(prefix)) + ); }), del: vi.fn((...keys: string[]) => { let n = 0; @@ -28,12 +30,12 @@ vi.mock("../../../server/db/connection.js", () => ({ }, })); -import metarRouter from "../../../server/routes/metar.js"; -import { clearMetarCacheForTests } from "../../../server/utils/metarAviationWeather.js"; +import metarRouter from '../../../server/routes/metar.js'; +import { clearMetarCacheForTests } from '../../../server/utils/metarAviationWeather.js'; -describe("GET /api/metar/:icao", () => { +describe('GET /api/metar/:icao', () => { const app = express(); - app.use("/", metarRouter); + app.use('/', metarRouter); const originalFetch = globalThis.fetch; @@ -43,7 +45,7 @@ describe("GET /api/metar/:icao", () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, - text: async () => JSON.stringify([{ raw: "METAR EGLL" }]), + text: async () => JSON.stringify([{ raw: 'METAR EGLL' }]), } as Response); }); @@ -53,17 +55,17 @@ describe("GET /api/metar/:icao", () => { redisStore.clear(); }); - it("returns first METAR object when upstream returns json array", async () => { - const res = await appRequest(app, "GET", "/EGLL"); + it('returns first METAR object when upstream returns json array', async () => { + const res = await appRequest(app, 'GET', '/EGLL'); expect(res.status).toBe(200); - expect((res.body as { raw: string }).raw).toContain("EGLL"); + expect((res.body as { raw: string }).raw).toContain('EGLL'); }); - it("uses Redis fresh cache and does not call upstream twice within the fresh window", async () => { - await appRequest(app, "GET", "/EGLL"); - await appRequest(app, "GET", "/EGLL"); + it('uses Redis fresh cache and does not call upstream twice within the fresh window', async () => { + await appRequest(app, 'GET', '/EGLL'); + await appRequest(app, 'GET', '/EGLL'); expect(vi.mocked(globalThis.fetch).mock.calls.length).toBe(1); }); -}); \ No newline at end of file +}); diff --git a/tests/server/routes/sessions.route.test.ts b/tests/server/routes/sessions.route.test.ts index 4748f3f6..402d9aa0 100644 --- a/tests/server/routes/sessions.route.test.ts +++ b/tests/server/routes/sessions.route.test.ts @@ -1,46 +1,46 @@ -import express from "express"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import express from 'express'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { appRequest } from "../helpers/appRequest.js"; +import { appRequest } from '../helpers/appRequest.js'; -vi.mock("../../../server/db/connection.js", () => ({ +vi.mock('../../../server/db/connection.js', () => ({ mainDb: {}, redisConnection: {}, })); -vi.mock("../../../server/db/sessions.js", () => ({ +vi.mock('../../../server/db/sessions.js', () => ({ getAllSessions: vi.fn(), })); -import { getAllSessions } from "../../../server/db/sessions.js"; -import sessionsRouter from "../../../server/routes/sessions.js"; +import { getAllSessions } from '../../../server/db/sessions.js'; +import sessionsRouter from '../../../server/routes/sessions.js'; -describe("GET /api/sessions", () => { +describe('GET /api/sessions', () => { const app = express(); - app.use("/", sessionsRouter); + app.use('/', sessionsRouter); beforeEach(() => { vi.mocked(getAllSessions).mockReset(); }); - it("returns mapped session list", async () => { + it('returns mapped session list', async () => { vi.mocked(getAllSessions).mockResolvedValue([ { - session_id: "Ab12Cd34", - airport_icao: "EGLL", + session_id: 'Ab12Cd34', + airport_icao: 'EGLL', created_at: new Date(), - created_by: "u1", + created_by: 'u1', is_pfatc: false, is_advanced_atc: false, - active_runway: "09L", + active_runway: '09L', }, ] as never); - const res = await appRequest(app, "GET", "/"); + const res = await appRequest(app, 'GET', '/'); expect(res.status).toBe(200); const list = res.body as { sessionId: string; airportIcao: string }[]; - expect(list[0].sessionId).toBe("Ab12Cd34"); - expect(list[0].airportIcao).toBe("EGLL"); + expect(list[0].sessionId).toBe('Ab12Cd34'); + expect(list[0].airportIcao).toBe('EGLL'); }); -}); \ No newline at end of file +}); diff --git a/tests/server/utils/metarAviationWeather.test.ts b/tests/server/utils/metarAviationWeather.test.ts index fefc13f6..df58b818 100644 --- a/tests/server/utils/metarAviationWeather.test.ts +++ b/tests/server/utils/metarAviationWeather.test.ts @@ -1,19 +1,21 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { redisStore } = vi.hoisted(() => ({ redisStore: new Map(), })); -vi.mock("../../../server/db/connection.js", () => ({ +vi.mock('../../../server/db/connection.js', () => ({ redisConnection: { get: vi.fn((k: string) => Promise.resolve(redisStore.get(k) ?? null)), set: vi.fn((k: string, v: string, ..._rest: unknown[]) => { redisStore.set(k, v); - return Promise.resolve("OK"); + return Promise.resolve('OK'); }), keys: vi.fn((pattern: string) => { - const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern; - return Promise.resolve(Array.from(redisStore.keys()).filter((key) => key.startsWith(prefix))); + const prefix = pattern.endsWith('*') ? pattern.slice(0, -1) : pattern; + return Promise.resolve( + Array.from(redisStore.keys()).filter((key) => key.startsWith(prefix)) + ); }), del: vi.fn((...keys: string[]) => { let n = 0; @@ -28,9 +30,9 @@ vi.mock("../../../server/db/connection.js", () => ({ import { clearMetarCacheForTests, resolveAviationMetar, -} from "../../../server/utils/metarAviationWeather.js"; +} from '../../../server/utils/metarAviationWeather.js'; -describe("resolveAviationMetar", () => { +describe('resolveAviationMetar', () => { const originalFetch = globalThis.fetch; beforeEach(async () => { @@ -45,20 +47,22 @@ describe("resolveAviationMetar", () => { vi.useRealTimers(); }); - it("returns stale cache from Redis when upstream errors after the fresh window", async () => { + it('returns stale cache from Redis when upstream errors after the fresh window', async () => { vi.useFakeTimers(); - const t0 = new Date("2025-06-01T12:00:00.000Z").getTime(); + const t0 = new Date('2025-06-01T12:00:00.000Z').getTime(); vi.setSystemTime(t0); const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true, status: 200, text: async () => - JSON.stringify([{ rawOb: "METAR EGLL 121200Z 27010KT", wdir: 270, wspd: 10 }]), + JSON.stringify([ + { rawOb: 'METAR EGLL 121200Z 27010KT', wdir: 270, wspd: 10 }, + ]), } as Response); globalThis.fetch = fetchMock; - const r1 = await resolveAviationMetar("EGLL"); + const r1 = await resolveAviationMetar('EGLL'); expect(r1.ok).toBe(true); if (r1.ok) { expect(r1.stale).toBe(false); @@ -66,9 +70,9 @@ describe("resolveAviationMetar", () => { } vi.setSystemTime(t0 + 4 * 60 * 1000); - fetchMock.mockRejectedValue(new Error("network")); + fetchMock.mockRejectedValue(new Error('network')); - const pending = resolveAviationMetar("EGLL"); + const pending = resolveAviationMetar('EGLL'); await vi.advanceTimersByTimeAsync(20_000); const r2 = await pending; @@ -76,8 +80,8 @@ describe("resolveAviationMetar", () => { if (r2.ok) { expect(r2.stale).toBe(true); expect(r2.cacheHit).toBe(true); - expect(String(r2.body.rawOb)).toContain("EGLL"); + expect(String(r2.body.rawOb)).toContain('EGLL'); } expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2); }); -}); \ No newline at end of file +}); diff --git a/tests/server/utils/publicSessionAtis.test.ts b/tests/server/utils/publicSessionAtis.test.ts index d4f0b0ff..acc3aca4 100644 --- a/tests/server/utils/publicSessionAtis.test.ts +++ b/tests/server/utils/publicSessionAtis.test.ts @@ -37,4 +37,4 @@ describe('parsePublicSessionAtis', () => { text: 'hello world', }); }); -}); \ No newline at end of file +}); diff --git a/tests/server/utils/sessionNetworkFlags.test.ts b/tests/server/utils/sessionNetworkFlags.test.ts index 8c6e3476..856529f3 100644 --- a/tests/server/utils/sessionNetworkFlags.test.ts +++ b/tests/server/utils/sessionNetworkFlags.test.ts @@ -1,34 +1,36 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest'; import { assertExclusiveSessionNetworkFlags, ExclusiveSessionNetworkFlagsError, isPostgresCheckViolation, -} from "../../../server/utils/sessionNetworkFlags.js"; +} from '../../../server/utils/sessionNetworkFlags.js'; -describe("sessionNetworkFlags", () => { - it("allows both false", () => { - expect(() => assertExclusiveSessionNetworkFlags(false, false)).not.toThrow(); +describe('sessionNetworkFlags', () => { + it('allows both false', () => { + expect(() => + assertExclusiveSessionNetworkFlags(false, false) + ).not.toThrow(); }); - it("allows only PFATC", () => { + it('allows only PFATC', () => { expect(() => assertExclusiveSessionNetworkFlags(true, false)).not.toThrow(); }); - it("allows only Advanced ATC", () => { + it('allows only Advanced ATC', () => { expect(() => assertExclusiveSessionNetworkFlags(false, true)).not.toThrow(); }); - it("rejects both true", () => { + it('rejects both true', () => { expect(() => assertExclusiveSessionNetworkFlags(true, true)).toThrow( - ExclusiveSessionNetworkFlagsError, + ExclusiveSessionNetworkFlagsError ); }); - it("detects Postgres check_violation", () => { - expect(isPostgresCheckViolation({ code: "23514" })).toBe(true); - expect(isPostgresCheckViolation({ cause: { code: "23514" } })).toBe(true); - expect(isPostgresCheckViolation({ code: "23505" })).toBe(false); - expect(isPostgresCheckViolation(new Error("no"))).toBe(false); + it('detects Postgres check_violation', () => { + expect(isPostgresCheckViolation({ code: '23514' })).toBe(true); + expect(isPostgresCheckViolation({ cause: { code: '23514' } })).toBe(true); + expect(isPostgresCheckViolation({ code: '23505' })).toBe(false); + expect(isPostgresCheckViolation(new Error('no'))).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/tests/server/utils/validation.test.ts b/tests/server/utils/validation.test.ts index d9103ccd..8270e332 100644 --- a/tests/server/utils/validation.test.ts +++ b/tests/server/utils/validation.test.ts @@ -16,10 +16,18 @@ describe('validateSessionId', () => { }); it('rejects wrong length or characters', () => { - expect(() => validateSessionId(undefined)).toThrow('Session ID is required'); - expect(() => validateSessionId('short')).toThrow('Invalid session ID format'); - expect(() => validateSessionId('toolonggg')).toThrow('Invalid session ID format'); - expect(() => validateSessionId('Ab12Cd3!')).toThrow('Invalid session ID format'); + expect(() => validateSessionId(undefined)).toThrow( + 'Session ID is required' + ); + expect(() => validateSessionId('short')).toThrow( + 'Invalid session ID format' + ); + expect(() => validateSessionId('toolonggg')).toThrow( + 'Invalid session ID format' + ); + expect(() => validateSessionId('Ab12Cd3!')).toThrow( + 'Invalid session ID format' + ); }); }); @@ -31,7 +39,9 @@ describe('validateAccessId', () => { it('rejects invalid access ids', () => { expect(() => validateAccessId(undefined)).toThrow('Access ID is required'); - expect(() => validateAccessId('gg'.repeat(32))).toThrow('Invalid access ID format'); + expect(() => validateAccessId('gg'.repeat(32))).toThrow( + 'Invalid access ID format' + ); }); }); @@ -42,7 +52,9 @@ describe('validateFlightId', () => { it('rejects empty or invalid characters', () => { expect(() => validateFlightId('')).toThrow('Flight ID cannot be empty'); - expect(() => validateFlightId('bad id')).toThrow('Invalid flight ID format'); + expect(() => validateFlightId('bad id')).toThrow( + 'Invalid flight ID format' + ); }); }); diff --git a/tests/setup/vitestSetup.ts b/tests/setup/vitestSetup.ts index d7e06879..7cfde8ea 100644 --- a/tests/setup/vitestSetup.ts +++ b/tests/setup/vitestSetup.ts @@ -7,7 +7,8 @@ process.env.JWT_SECRET = process.env.DB_ENCRYPTION_KEY = process.env.DB_ENCRYPTION_KEY || '0'.repeat(128); process.env.ADMIN_IDS = process.env.ADMIN_IDS ?? ''; -process.env.IP_HASH_SECRET = process.env.IP_HASH_SECRET || 'vitest-ip-hash-secret-for-testing-only'; +process.env.IP_HASH_SECRET = + process.env.IP_HASH_SECRET || 'vitest-ip-hash-secret-for-testing-only'; vi.mock('../../server/websockets/sessionUsersWebsocket.js', () => ({ getActiveUsersForSession: vi.fn().mockResolvedValue([]),