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/.github/workflows/CI.yml b/.github/workflows/CI.yml
index f2f410d7..db9f7e66 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 vp fmt --check
+
- name: Run lint
run: npm run lint
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 87f9dcfb..aebad874 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",
@@ -130,6 +123,13 @@
"node": true,
"es2020": true
}
+ },
+ {
+ "files": ["tests/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx}"],
+ "env": {
+ "node": true,
+ "es2020": true
+ }
}
]
-}
\ No newline at end of file
+}
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/.prettierrc b/.prettierrc
deleted file mode 100644
index 94b0c02b..00000000
--- a/.prettierrc
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "singleQuote": false,
- "tabWidth": 2,
- "trailingComma": "es5"
-}
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 00000000..99e2f7dd
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["oxc.oxc-vscode"]
+}
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/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"
+ }
}
}
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)}
- >
-
-
- {isSelected && (
-
-
-
- )}
-
+ {cephieSnapImages.map((img) => {
+ const isSelected = selectedImage === img.url;
+ return (
+
handleSelectImage(img.url)}
+ >
+
+
+ {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({
@@ -116,7 +116,7 @@ export default function AdminDeveloperApplicationReviewModal({
@@ -125,7 +125,7 @@ export default function AdminDeveloperApplicationReviewModal({
void submitApprove()}
className="!bg-emerald-600 hover:!bg-emerald-500"
@@ -143,7 +143,7 @@ export default function AdminDeveloperApplicationReviewModal({
@@ -158,7 +158,7 @@ export default function AdminDeveloperApplicationReviewModal({
Why
- {application.whyText || "—"}
+ {application.whyText || '—'}
@@ -256,4 +256,4 @@ export default function AdminDeveloperApplicationReviewModal({
)}
);
-}
\ No newline at end of file
+}
diff --git a/src/components/admin/AdminDeveloperEditModal.tsx b/src/components/admin/AdminDeveloperEditModal.tsx
index 0cf774e5..6d347788 100644
--- a/src/components/admin/AdminDeveloperEditModal.tsx
+++ b/src/components/admin/AdminDeveloperEditModal.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from 'react';
import {
MdSave,
MdVpnKey,
@@ -7,19 +7,19 @@ import {
MdContentCopy,
MdRefresh,
MdDelete,
-} from "react-icons/md";
-import AdminModal from "./AdminModal";
-import AdminTable from "./AdminTable";
-import DeveloperDiscordAvatar from "./DeveloperDiscordAvatar";
+} from 'react-icons/md';
+import AdminModal from './AdminModal';
+import AdminTable from './AdminTable';
+import DeveloperDiscordAvatar from './DeveloperDiscordAvatar';
import {
statusBadgeClass,
adminDownsizeButtonSize,
ADMIN_TABLE_HEAD,
ADMIN_TH,
ADMIN_TD,
-} from "./adminConstants";
-import Button from "../common/Button";
-import ScopeTagSelector from "../developers/ScopeTagSelector";
+} from './adminConstants';
+import Button from '../common/Button';
+import ScopeTagSelector from '../developers/ScopeTagSelector';
import {
approveAdminDeveloperKey,
fetchAdminDeveloperCatalog,
@@ -31,9 +31,9 @@ import {
type AdminDeveloperKeyRow,
type AdminDeveloperSummary,
type AdminScopeCatalogEntry,
-} from "../../utils/fetch/adminDevelopers";
+} from '../../utils/fetch/adminDevelopers';
-type Tab = "ceiling" | "keys";
+type Tab = 'ceiling' | 'keys';
type Props = {
developer: AdminDeveloperSummary;
@@ -56,7 +56,7 @@ export default function AdminDeveloperEditModal({
onDeleteDeveloper,
deleteDeveloperBusy,
}: Props) {
- const [tab, setTab] = useState("ceiling");
+ const [tab, setTab] = useState('ceiling');
const [catalog, setCatalog] = useState([]);
const [keys, setKeys] = useState([]);
const [keysLoading, setKeysLoading] = useState(true);
@@ -73,12 +73,12 @@ export default function AdminDeveloperEditModal({
null
);
const [approveScopes, setApproveScopes] = useState>(new Set());
- const [approveRpm, setApproveRpm] = useState("");
- const [approveNote, setApproveNote] = useState("");
+ const [approveRpm, setApproveRpm] = useState('');
+ const [approveNote, setApproveNote] = useState('');
const [editKey, setEditKey] = useState(null);
const [editScopes, setEditScopes] = useState>(new Set());
- const [editRpm, setEditRpm] = useState("");
+ const [editRpm, setEditRpm] = useState('');
useEffect(() => {
setCeiling(new Set(developer.approvedScopes));
@@ -126,7 +126,7 @@ export default function AdminDeveloperEditModal({
setTimeout(() => setCeilingSaved(false), 2000);
await onReload();
} catch (e) {
- alert(e instanceof Error ? e.message : "Save failed");
+ alert(e instanceof Error ? e.message : 'Save failed');
} finally {
setCeilingBusy(false);
}
@@ -135,8 +135,8 @@ export default function AdminDeveloperEditModal({
const openApprove = (k: AdminDeveloperKeyRow) => {
setApproveKey(k);
setApproveScopes(new Set(k.requestedScopes));
- setApproveRpm("");
- setApproveNote("");
+ setApproveRpm('');
+ setApproveNote('');
};
const submitApprove = async () => {
@@ -144,7 +144,7 @@ export default function AdminDeveloperEditModal({
setRowBusy(approveKey.id);
try {
const rpm =
- approveRpm.trim() === ""
+ approveRpm.trim() === ''
? null
: Math.max(0, parseInt(approveRpm, 10) || 0);
const res = await approveAdminDeveloperKey(
@@ -161,21 +161,21 @@ export default function AdminDeveloperEditModal({
await reloadKeys();
await onReload();
} catch (e) {
- alert(e instanceof Error ? e.message : "Approve failed");
+ alert(e instanceof Error ? e.message : 'Approve failed');
} finally {
setRowBusy(null);
}
};
const submitRejectKey = async (k: AdminDeveloperKeyRow) => {
- if (!confirm("Reject this key request?")) return;
+ if (!confirm('Reject this key request?')) return;
setRowBusy(k.id);
try {
await rejectAdminDeveloperKey(developer.userId, k.id);
await reloadKeys();
await onReload();
} catch (e) {
- alert(e instanceof Error ? e.message : "Reject failed");
+ alert(e instanceof Error ? e.message : 'Reject failed');
} finally {
setRowBusy(null);
}
@@ -185,7 +185,7 @@ export default function AdminDeveloperEditModal({
setEditKey(k);
setEditScopes(new Set(k.scopes));
setEditRpm(
- k.rateLimitPerMinute != null ? String(k.rateLimitPerMinute) : ""
+ k.rateLimitPerMinute != null ? String(k.rateLimitPerMinute) : ''
);
};
@@ -194,7 +194,7 @@ export default function AdminDeveloperEditModal({
setRowBusy(editKey.id);
try {
const rpm =
- editRpm.trim() === "" ? null : Math.max(0, parseInt(editRpm, 10) || 0);
+ editRpm.trim() === '' ? null : Math.max(0, parseInt(editRpm, 10) || 0);
await patchAdminDeveloperKey(developer.userId, editKey.id, {
scopes: [...editScopes],
rateLimitPerMinute: rpm,
@@ -203,7 +203,7 @@ export default function AdminDeveloperEditModal({
await reloadKeys();
await onReload();
} catch (e) {
- alert(e instanceof Error ? e.message : "Save failed");
+ alert(e instanceof Error ? e.message : 'Save failed');
} finally {
setRowBusy(null);
}
@@ -217,7 +217,7 @@ export default function AdminDeveloperEditModal({
await reloadKeys();
await onReload();
} catch (e) {
- alert(e instanceof Error ? e.message : "Revoke failed");
+ alert(e instanceof Error ? e.message : 'Revoke failed');
} finally {
setRowBusy(null);
}
@@ -230,7 +230,7 @@ export default function AdminDeveloperEditModal({
setTimeout(() => setRevealedCopied(false), 2000);
};
- const activeIndex = tab === "ceiling" ? 0 : 1;
+ const activeIndex = tab === 'ceiling' ? 0 : 1;
const approveKeyFromCatalog = useMemo(
() =>
@@ -296,7 +296,7 @@ export default function AdminDeveloperEditModal({
- {developer.status === "active" && onProfileSuspend && (
+ {developer.status === 'active' && onProfileSuspend && (
)}
- {developer.status !== "active" && onProfileReactivate && (
+ {developer.status !== 'active' && onProfileReactivate && (
setTab("ceiling")}
- className={`relative z-10 flex flex-1 items-center justify-center gap-1.5 rounded-full px-3 py-2 text-xs font-semibold transition-colors ${tab === "ceiling" ? "text-white" : "text-zinc-400 hover:text-zinc-200"}`}
+ onClick={() => setTab('ceiling')}
+ className={`relative z-10 flex flex-1 items-center justify-center gap-1.5 rounded-full px-3 py-2 text-xs font-semibold transition-colors ${tab === 'ceiling' ? 'text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
>
Scope ceiling
setTab("keys")}
- className={`relative z-10 flex flex-1 items-center justify-center gap-1.5 rounded-full px-3 py-2 text-xs font-semibold transition-colors ${tab === "keys" ? "text-white" : "text-zinc-400 hover:text-zinc-200"}`}
+ onClick={() => setTab('keys')}
+ className={`relative z-10 flex flex-1 items-center justify-center gap-1.5 rounded-full px-3 py-2 text-xs font-semibold transition-colors ${tab === 'keys' ? 'text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
>
Keys
@@ -360,7 +360,7 @@ export default function AdminDeveloperEditModal({
- {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' ? (
openApprove(k)}
className="!bg-emerald-800/80 hover:!bg-emerald-700"
@@ -460,19 +460,19 @@ export default function AdminDeveloperEditModal({
void submitRejectKey(k)}
>
Reject
- ) : k.status === "active" ? (
+ ) : k.status === 'active' ? (
openEdit(k)}
>
@@ -481,7 +481,7 @@ export default function AdminDeveloperEditModal({
void doRevoke(k)}
>
@@ -510,7 +510,7 @@ export default function AdminDeveloperEditModal({
setApproveKey(null)}
>
Cancel
@@ -518,7 +518,7 @@ export default function AdminDeveloperEditModal({
void submitApprove()}
className="!bg-emerald-600 hover:!bg-emerald-500"
@@ -578,7 +578,7 @@ export default function AdminDeveloperEditModal({
void copyRevealed()}
className="flex-1 !bg-emerald-700 hover:!bg-emerald-600"
>
@@ -587,12 +587,12 @@ export default function AdminDeveloperEditModal({
) : (
)}
- {revealedCopied ? "Copied" : "Copy secret"}
+ {revealedCopied ? 'Copied' : 'Copy secret'}
setRevealedSecret(null)}
>
Done
@@ -609,14 +609,14 @@ export default function AdminDeveloperEditModal({
setEditKey(null)}
- title={editKey ? `Edit key — ${editKey.name}` : "Edit key"}
+ title={editKey ? `Edit key — ${editKey.name}` : 'Edit key'}
size="md"
footer={
<>
setEditKey(null)}
>
Cancel
@@ -624,7 +624,7 @@ export default function AdminDeveloperEditModal({
void saveEdit()}
>
@@ -659,4 +659,4 @@ export default function AdminDeveloperEditModal({
>
);
-}
\ No newline at end of file
+}
diff --git a/src/components/admin/AdminDeveloperManagePanel.tsx b/src/components/admin/AdminDeveloperManagePanel.tsx
index 4f8701a2..47d79703 100644
--- a/src/components/admin/AdminDeveloperManagePanel.tsx
+++ b/src/components/admin/AdminDeveloperManagePanel.tsx
@@ -1,8 +1,8 @@
-import { useEffect, useMemo, useState } from "react";
-import { MdVpnKey, MdSave, MdShield } from "react-icons/md";
-import AdminModal from "./AdminModal";
-import AdminTable from "./AdminTable";
-import AdminSectionTitle from "./AdminSectionTitle";
+import { useEffect, useMemo, useState } from 'react';
+import { MdVpnKey, MdSave, MdShield } from 'react-icons/md';
+import AdminModal from './AdminModal';
+import AdminTable from './AdminTable';
+import AdminSectionTitle from './AdminSectionTitle';
import {
adminDownsizeButtonSize,
adminSectionClass,
@@ -10,8 +10,8 @@ import {
ADMIN_TH,
ADMIN_TD,
statusBadgeClass,
-} from "./adminConstants";
-import Button from "../common/Button";
+} from './adminConstants';
+import Button from '../common/Button';
import {
approveAdminDeveloperKey,
fetchAdminDeveloperCatalog,
@@ -23,7 +23,7 @@ import {
type AdminDeveloperKeyRow,
type AdminDeveloperSummary,
type AdminScopeCatalogEntry,
-} from "../../utils/fetch/adminDevelopers";
+} from '../../utils/fetch/adminDevelopers';
type Props = {
developer: AdminDeveloperSummary;
@@ -52,12 +52,12 @@ export default function AdminDeveloperManagePanel({
null
);
const [approveScopes, setApproveScopes] = useState>(new Set());
- const [approveRpm, setApproveRpm] = useState("");
- const [approveNote, setApproveNote] = useState("");
+ const [approveRpm, setApproveRpm] = useState('');
+ const [approveNote, setApproveNote] = useState('');
const [revealedSecret, setRevealedSecret] = useState(null);
const [editKey, setEditKey] = useState(null);
const [editScopes, setEditScopes] = useState>(new Set());
- const [editRpm, setEditRpm] = useState("");
+ const [editRpm, setEditRpm] = useState('');
useEffect(() => {
setCeiling(new Set(developer.approvedScopes));
@@ -101,7 +101,7 @@ export default function AdminDeveloperManagePanel({
const scopeGroups = useMemo(() => {
const m = new Map();
for (const c of catalogSorted) {
- const prefix = c.id.split(".")[0] ?? "other";
+ const prefix = c.id.split('.')[0] ?? 'other';
const arr = m.get(prefix) ?? [];
arr.push(c);
m.set(prefix, arr);
@@ -132,8 +132,8 @@ export default function AdminDeveloperManagePanel({
const openApprove = (k: AdminDeveloperKeyRow) => {
setApproveKey(k);
setApproveScopes(new Set(k.requestedScopes));
- setApproveRpm("");
- setApproveNote("");
+ setApproveRpm('');
+ setApproveNote('');
setRevealedSecret(null);
};
@@ -142,7 +142,7 @@ export default function AdminDeveloperManagePanel({
setRowBusy(approveKey.id);
try {
const rpm =
- approveRpm.trim() === ""
+ approveRpm.trim() === ''
? null
: Math.max(0, parseInt(approveRpm, 10) || 0);
const res = await approveAdminDeveloperKey(
@@ -160,14 +160,14 @@ export default function AdminDeveloperManagePanel({
setKeys(kr.keys);
await onReload();
} catch (e) {
- alert(e instanceof Error ? e.message : "Approve failed");
+ alert(e instanceof Error ? e.message : 'Approve failed');
} finally {
setRowBusy(null);
}
};
const submitRejectKey = async (k: AdminDeveloperKeyRow) => {
- if (!confirm("Reject this key request?")) return;
+ if (!confirm('Reject this key request?')) return;
setRowBusy(k.id);
try {
await rejectAdminDeveloperKey(developer.userId, k.id);
@@ -175,7 +175,7 @@ export default function AdminDeveloperManagePanel({
setKeys(kr.keys);
await onReload();
} catch (e) {
- alert(e instanceof Error ? e.message : "Reject failed");
+ alert(e instanceof Error ? e.message : 'Reject failed');
} finally {
setRowBusy(null);
}
@@ -185,7 +185,7 @@ export default function AdminDeveloperManagePanel({
setEditKey(k);
setEditScopes(new Set(k.scopes));
setEditRpm(
- k.rateLimitPerMinute != null ? String(k.rateLimitPerMinute) : ""
+ k.rateLimitPerMinute != null ? String(k.rateLimitPerMinute) : ''
);
};
@@ -194,7 +194,7 @@ export default function AdminDeveloperManagePanel({
setRowBusy(editKey.id);
try {
const rpm =
- editRpm.trim() === "" ? null : Math.max(0, parseInt(editRpm, 10) || 0);
+ editRpm.trim() === '' ? null : Math.max(0, parseInt(editRpm, 10) || 0);
await patchAdminDeveloperKey(developer.userId, editKey.id, {
scopes: [...editScopes],
rateLimitPerMinute: rpm,
@@ -204,7 +204,7 @@ export default function AdminDeveloperManagePanel({
setKeys(kr.keys);
await onReload();
} catch (e) {
- alert(e instanceof Error ? e.message : "Save failed");
+ alert(e instanceof Error ? e.message : 'Save failed');
} finally {
setRowBusy(null);
}
@@ -219,7 +219,7 @@ export default function AdminDeveloperManagePanel({
setKeys(kr.keys);
await onReload();
} catch (e) {
- alert(e instanceof Error ? e.message : "Revoke failed");
+ alert(e instanceof Error ? e.message : 'Revoke failed');
} finally {
setRowBusy(null);
}
@@ -227,7 +227,7 @@ export default function AdminDeveloperManagePanel({
return (
-
+
@@ -236,18 +236,18 @@ export default function AdminDeveloperManagePanel({
Scope ceiling & keys
- {developer.username} · {developer.keysActive} usable ·{" "}
+ {developer.username} · {developer.keysActive} usable ·{' '}
{developer.keysPending} pending · {developer.keysTotal} total
{(onProfileSuspend || onProfileReactivate) && (
- {developer.status === "active" && onProfileSuspend && (
+ {developer.status === 'active' && onProfileSuspend && (
onProfileSuspend()}
className="!border-amber-800/60 !text-amber-200"
@@ -255,11 +255,11 @@ export default function AdminDeveloperManagePanel({
Suspend
)}
- {developer.status !== "active" && onProfileReactivate && (
+ {developer.status !== 'active' && onProfileReactivate && (
onProfileReactivate()}
className="!border-emerald-800/50 !text-emerald-200"
@@ -310,7 +310,7 @@ export default function AdminDeveloperManagePanel({
void saveCeiling()}
className="mt-3 inline-flex items-center gap-2"
@@ -359,7 +359,7 @@ export default function AdminDeveloperManagePanel({
{k.status}
@@ -367,26 +367,26 @@ export default function AdminDeveloperManagePanel({
- {(k.status === "pending" ? k.requestedScopes : k.scopes)
+ {(k.status === 'pending' ? k.requestedScopes : k.scopes)
.slice(0, 4)
- .join(", ")}
- {(k.status === "pending" ? k.requestedScopes : k.scopes)
+ .join(', ')}
+ {(k.status === 'pending' ? k.requestedScopes : k.scopes)
.length > 4
- ? "…"
- : ""}
+ ? '…'
+ : ''}
- {k.rateLimitPerMinute ?? "—"}
+ {k.rateLimitPerMinute ?? '—'}
{k.revokedAt ? (
Revoked
- ) : k.status === "pending" ? (
+ ) : k.status === 'pending' ? (
openApprove(k)}
className="!bg-emerald-800/80 hover:!bg-emerald-700"
@@ -396,19 +396,19 @@ export default function AdminDeveloperManagePanel({
void submitRejectKey(k)}
>
Reject
- ) : k.status === "active" ? (
+ ) : k.status === 'active' ? (
openEdit(k)}
>
@@ -417,7 +417,7 @@ export default function AdminDeveloperManagePanel({
void doRevoke(k)}
>
@@ -444,7 +444,7 @@ export default function AdminDeveloperManagePanel({
setApproveKey(null)}
>
Cancel
@@ -452,7 +452,7 @@ export default function AdminDeveloperManagePanel({
void submitApprove()}
className="!bg-emerald-600 hover:!bg-emerald-500"
@@ -522,7 +522,7 @@ export default function AdminDeveloperManagePanel({
setRevealedSecret(null)}
className="w-full"
>
@@ -545,7 +545,7 @@ export default function AdminDeveloperManagePanel({
setEditKey(null)}
>
Cancel
@@ -553,7 +553,7 @@ export default function AdminDeveloperManagePanel({
void saveEdit()}
>
@@ -604,4 +604,4 @@ export default function AdminDeveloperManagePanel({
);
-}
\ 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 (
{!iconOnly ? label : null}
);
-}
\ 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() {
setShowAddModal(true)}
className="shrink-0"
>
@@ -317,7 +317,7 @@ export default function UpdateModalsSection() {
{modal.is_active ? (
<>
@@ -325,21 +325,21 @@ export default function UpdateModalsSection() {
Active
>
) : (
- "Draft"
+ 'Draft'
)}
{modal.published_at
? new Date(modal.published_at).toLocaleDateString()
- : "Not published"}
+ : 'Not published'}
{modal.is_active ? (
handleUnpublish(modal.id)}
>
@@ -347,7 +347,7 @@ export default function UpdateModalsSection() {
) : (
handlePublish(modal.id)}
>
@@ -355,14 +355,14 @@ export default function UpdateModalsSection() {
)}
openEditModal(modal)}
>
handleDelete(modal.id)}
>
@@ -378,24 +378,24 @@ export default function UpdateModalsSection() {
Cancel
- {editingModal ? "Update" : "Create"} Modal
+ {editingModal ? 'Update' : 'Create'} Modal
>
}
@@ -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'}
-
onClose()} className="p-1 rounded-full hover:bg-gray-700">
+ onClose()}
+ className="p-1 rounded-full hover:bg-gray-700"
+ >
@@ -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) => (
))
) : (
- {(activeTab === "aatc" ? aatcConnectedUsers : connectedGlobalChatUsers).map((globalUser) => (
+ {(activeTab === 'aatc'
+ ? aatcConnectedUsers
+ : connectedGlobalChatUsers
+ ).map((globalUser) => (
{
- 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 (
-
+
{checked && (
@@ -65,4 +68,4 @@ export default function Checkbox({
{label}
);
-}
\ No newline at end of file
+}
diff --git a/src/components/common/Dropdown.tsx b/src/components/common/Dropdown.tsx
index 53d22786..b9fa75cb 100644
--- a/src/components/common/Dropdown.tsx
+++ b/src/components/common/Dropdown.tsx
@@ -68,7 +68,11 @@ function Dropdown({
}: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [isMeasured, setIsMeasured] = useState(false);
- const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
+ const [dropdownPosition, setDropdownPosition] = useState({
+ top: 0,
+ left: 0,
+ width: 0,
+ });
const [panelAbove, setPanelAbove] = useState(false);
const dropdownRef = useRef
(null);
@@ -85,7 +89,7 @@ function Dropdown({
getDisplayValue
? getDisplayValue(val)
: options.find((o) => o.value === val)?.label || '',
- [getDisplayValue, options],
+ [getDisplayValue, options]
);
useEffect(() => {
@@ -104,28 +108,37 @@ function Dropdown({
if (!q) return options;
return options.filter(
(o) =>
- o.label.toLowerCase().includes(q) ||
- o.value.toLowerCase().includes(q),
+ o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)
);
}, [options, searchable, isOpen, inputValue]);
const getTriggerEl = useCallback(
() => (searchable ? triggerWrapperRef.current : buttonRef.current),
- [searchable],
+ [searchable]
);
const gap = searchable ? 0 : 4;
- const computePos = useCallback((el: HTMLElement, dd: HTMLElement | null) => {
- const rect = el.getBoundingClientRect();
- const vpHeight = window.visualViewport?.height ?? window.innerHeight;
- const spaceBelow = vpHeight - rect.bottom;
- const spaceAbove = rect.top;
- const ddHeight = dd ? dd.getBoundingClientRect().height : 0;
- const above = dd ? ddHeight > spaceBelow && spaceAbove > spaceBelow : false;
- const top = above ? rect.top - ddHeight - gap : rect.bottom + gap;
- return { top: Math.round(top), left: Math.floor(rect.left), width: Math.ceil(rect.width), above };
- }, [gap]);
+ const computePos = useCallback(
+ (el: HTMLElement, dd: HTMLElement | null) => {
+ const rect = el.getBoundingClientRect();
+ const vpHeight = window.visualViewport?.height ?? window.innerHeight;
+ const spaceBelow = vpHeight - rect.bottom;
+ const spaceAbove = rect.top;
+ const ddHeight = dd ? dd.getBoundingClientRect().height : 0;
+ const above = dd
+ ? ddHeight > spaceBelow && spaceAbove > spaceBelow
+ : false;
+ const top = above ? rect.top - ddHeight - gap : rect.bottom + gap;
+ return {
+ top: Math.round(top),
+ left: Math.floor(rect.left),
+ width: Math.ceil(rect.width),
+ above,
+ };
+ },
+ [gap]
+ );
const updatePosition = useCallback(() => {
const el = getTriggerEl();
@@ -135,7 +148,7 @@ function Dropdown({
setDropdownPosition((prev) =>
prev.left === pos.left && prev.width === pos.width && prev.top === pos.top
? prev
- : pos,
+ : pos
);
}, [getTriggerEl, computePos]);
@@ -143,8 +156,12 @@ function Dropdown({
if (disabled) return;
const next = !isOpen;
setIsOpen(next);
- if (next) { setIsMeasured(false); if (usePortal) updatePosition(); }
- else { setIsMeasured(false); }
+ if (next) {
+ setIsMeasured(false);
+ if (usePortal) updatePosition();
+ } else {
+ setIsMeasured(false);
+ }
};
const handleInputFocus = () => {
@@ -202,7 +219,15 @@ function Dropdown({
return;
}
measure(el, dd);
- }, [usePortal, isOpen, isMeasured, options.length, maxHeight, getTriggerEl, computePos]);
+ }, [
+ usePortal,
+ isOpen,
+ isMeasured,
+ options.length,
+ maxHeight,
+ getTriggerEl,
+ computePos,
+ ]);
// Close on any scroll outside the panel — applies to portal AND absolute dropdowns.
// A 150 ms timeout lets the browser finish its automatic scroll-to-focused-input
@@ -250,19 +275,40 @@ function Dropdown({
setPanelAbove(pos.above);
});
return () => cancelAnimationFrame(raf);
- }, [usePortal, isOpen, isMeasured, options.length, maxHeight, getTriggerEl, computePos]);
+ }, [
+ usePortal,
+ isOpen,
+ isMeasured,
+ options.length,
+ maxHeight,
+ getTriggerEl,
+ computePos,
+ ]);
// Scroll selected item into view in the portal panel
useLayoutEffect(() => {
if (!usePortal || !isOpen || !isMeasured) return;
const panel = dropdownRef.current;
if (!panel || panel.scrollHeight <= panel.clientHeight + 1) return;
- const selectedEl = panel.querySelector('[data-dropdown-selected="true"]');
+ const selectedEl = panel.querySelector(
+ '[data-dropdown-selected="true"]'
+ );
if (!selectedEl) return;
const panelRect = panel.getBoundingClientRect();
const itemRect = selectedEl.getBoundingClientRect();
- const delta = (itemRect.top + itemRect.height / 2) - (panelRect.top + panel.clientHeight / 2);
- panel.scrollTop = Math.round(Math.max(0, Math.min(panel.scrollTop + delta, panel.scrollHeight - panel.clientHeight)));
+ const delta =
+ itemRect.top +
+ itemRect.height / 2 -
+ (panelRect.top + panel.clientHeight / 2);
+ panel.scrollTop = Math.round(
+ Math.max(
+ 0,
+ Math.min(
+ panel.scrollTop + delta,
+ panel.scrollHeight - panel.clientHeight
+ )
+ )
+ );
}, [usePortal, isOpen, isMeasured, value, options.length, maxHeight]);
// Non-searchable: close on outside click
@@ -270,8 +316,10 @@ function Dropdown({
if (searchable) return;
const handleClickOutside = (e: MouseEvent) => {
if (
- dropdownRef.current && !dropdownRef.current.contains(e.target as Node) &&
- buttonRef.current && !buttonRef.current.contains(e.target as Node)
+ dropdownRef.current &&
+ !dropdownRef.current.contains(e.target as Node) &&
+ buttonRef.current &&
+ !buttonRef.current.contains(e.target as Node)
) {
setIsOpen(false);
setIsMeasured(false);
@@ -301,7 +349,9 @@ function Dropdown({
)}
{visibleOptions.length === 0 ? (
- No options found
+
+ No options found
+
) : (
visibleOptions.map((option) => {
const isSelected = option.selected || option.value === value;
@@ -345,7 +395,17 @@ function Dropdown({
visibility: isMeasured ? 'visible' : 'hidden',
}}
>
- {panelAbove ? <>{optionsList}{divider}> : <>{divider}{optionsList}>}
+ {panelAbove ? (
+ <>
+ {optionsList}
+ {divider}
+ >
+ ) : (
+ <>
+ {divider}
+ {optionsList}
+ >
+ )}
);
@@ -431,7 +491,9 @@ function Dropdown({
)}
{visibleOptions.length === 0 ? (
- No options found
+
+ No options found
+
) : (
visibleOptions.map((option) => {
const isSelected = option.selected || option.value === value;
@@ -469,7 +531,11 @@ function Dropdown({
{displayValue}
diff --git a/src/components/developers/DeveloperAccessRequestForm.tsx b/src/components/developers/DeveloperAccessRequestForm.tsx
index 0e956afa..597c6976 100644
--- a/src/components/developers/DeveloperAccessRequestForm.tsx
+++ b/src/components/developers/DeveloperAccessRequestForm.tsx
@@ -1,17 +1,17 @@
-import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
-import { AlertCircle, X } from "lucide-react";
-import ScopeTagSelector from "./ScopeTagSelector";
-import type { ScopeCatalogEntry } from "./ScopeTagSelector";
+import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
+import { AlertCircle, X } from 'lucide-react';
+import ScopeTagSelector from './ScopeTagSelector';
+import type { ScopeCatalogEntry } from './ScopeTagSelector';
const shellClass =
- "rounded-3xl border border-zinc-700/80 bg-linear-to-br from-zinc-900/95 via-zinc-900/90 to-sky-950/25 p-6 sm:p-8 shadow-xl ring-1 ring-zinc-700/45";
+ 'rounded-3xl border border-zinc-700/80 bg-linear-to-br from-zinc-900/95 via-zinc-900/90 to-sky-950/25 p-6 sm:p-8 shadow-xl ring-1 ring-zinc-700/45';
const inputClass =
- "w-full rounded-2xl border border-zinc-700 bg-zinc-950/80 px-3.5 py-2.5 text-sm text-zinc-100 placeholder:text-zinc-500 ring-1 ring-zinc-800/40 focus:outline-none focus:ring-2 focus:ring-sky-500/25 focus:border-zinc-600 resize-none";
+ 'w-full rounded-2xl border border-zinc-700 bg-zinc-950/80 px-3.5 py-2.5 text-sm text-zinc-100 placeholder:text-zinc-500 ring-1 ring-zinc-800/40 focus:outline-none focus:ring-2 focus:ring-sky-500/25 focus:border-zinc-600 resize-none';
-const labelClass = "block text-sm font-medium text-zinc-400 mb-1.5";
+const labelClass = 'block text-sm font-medium text-zinc-400 mb-1.5';
-export type DeveloperAccessRequestMode = "initial" | "expansion";
+export type DeveloperAccessRequestMode = 'initial' | 'expansion';
type Props = {
mode: DeveloperAccessRequestMode;
@@ -44,23 +44,25 @@ export default function DeveloperAccessRequestForm({
whyMinLen = 10,
onDismiss,
}: Props) {
- const isInitial = mode === "initial";
- const title = isInitial ? "Let’s get you API access" : "Request more API access";
+ const isInitial = mode === 'initial';
+ const title = isInitial
+ ? 'Let’s get you API access'
+ : 'Request more API access';
const subtitle = isInitial
- ? "A quick intro helps us approve you faster. Choose the scopes you need right now — you can always request more later from this same page once you’re approved."
- : "Share a bit of context and pick only the new scopes you need. Your existing access stays on while we review this.";
+ ? 'A quick intro helps us approve you faster. Choose the scopes you need right now — you can always request more later from this same page once you’re approved.'
+ : 'Share a bit of context and pick only the new scopes you need. Your existing access stays on while we review this.';
- const whoLabel = isInitial ? "About you" : "Still you?";
+ const whoLabel = isInitial ? 'About you' : 'Still you?';
const whoPlaceholder = isInitial
- ? "Your name, org, or Discord — whatever helps us know who’s asking"
- : "Name or org (a short reminder is fine)";
+ ? 'Your name, org, or Discord — whatever helps us know who’s asking'
+ : 'Name or org (a short reminder is fine)';
- const whyLabel = isInitial ? "What you’re building" : "What’s changing?";
+ const whyLabel = isInitial ? 'What you’re building' : 'What’s changing?';
const whyPlaceholder = isInitial
- ? "e.g. a flight tracker, a community tool, integration with …"
- : "What will you use these new endpoints for?";
+ ? 'e.g. a flight tracker, a community tool, integration with …'
+ : 'What will you use these new endpoints for?';
- const scopeLabel = isInitial ? "Scopes you need now" : "New scopes to add";
+ const scopeLabel = isInitial ? 'Scopes you need now' : 'New scopes to add';
const [validationError, setValidationError] = useState(null);
@@ -71,22 +73,24 @@ export default function DeveloperAccessRequestForm({
const handleTrySubmit = () => {
if (submitting) return;
if (selectedScopes.size === 0) {
- setValidationError("Choose at least one scope before sending your request.");
+ setValidationError(
+ 'Choose at least one scope before sending your request.'
+ );
return;
}
const whoLen = who.trim().length;
if (whoLen < whoMinLen) {
setValidationError(
whoMinLen <= 1
- ? "Please tell us a little about yourself before sending."
- : `The “about you” field is too short — add at least ${whoMinLen} characters (you have ${whoLen}).`,
+ ? 'Please tell us a little about yourself before sending.'
+ : `The “about you” field is too short — add at least ${whoMinLen} characters (you have ${whoLen}).`
);
return;
}
const whyLen = why.trim().length;
if (whyLen < whyMinLen) {
setValidationError(
- `The project description is too short — write at least ${whyMinLen} characters so we can review your request (you have ${whyLen}).`,
+ `The project description is too short — write at least ${whyMinLen} characters so we can review your request (you have ${whyLen}).`
);
return;
}
@@ -109,16 +113,20 @@ export default function DeveloperAccessRequestForm({
-
{title}
-
{subtitle}
+
+ {title}
+
+
+ {subtitle}
+
{isInitial && (
- Note: you only have to pick what you
- need today. After you're approved, you can come back anytime and use this same flow
- to ask for additional scopes.
+ Note: you only have
+ to pick what you need today. After you're approved, you can come
+ back anytime and use this same flow to ask for additional scopes.
)}
@@ -156,14 +164,16 @@ export default function DeveloperAccessRequestForm({
{scopeLabel}
{selectedScopes.size > 0 && (
- {selectedScopes.size} selected
+
+ {selectedScopes.size} selected
+
)}
{catalog.length === 0 ? (
{isInitial
- ? "No scopes are available to choose right now."
- : "You already have every scope we offer — nothing more to request."}
+ ? 'No scopes are available to choose right now.'
+ : 'You already have every scope we offer — nothing more to request.'}
) : (
@@ -182,7 +192,10 @@ export default function DeveloperAccessRequestForm({
className="flex items-start gap-2 rounded-xl border border-red-900/55 bg-red-950/50 px-3.5 py-3 text-sm text-red-100 ring-1 ring-red-900/30"
role="alert"
>
-
+
{validationError}
)}
@@ -190,8 +203,8 @@ export default function DeveloperAccessRequestForm({
{selectedScopes.size === 0
- ? "Choose at least one scope to send your request."
- : "We usually review within a few days. Thanks for your patience!"}
+ ? 'Choose at least one scope to send your request.'
+ : 'We usually review within a few days. Thanks for your patience!'}
{onDismiss && (
@@ -209,11 +222,15 @@ export default function DeveloperAccessRequestForm({
onClick={handleTrySubmit}
className="px-6 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-500 disabled:opacity-45 disabled:pointer-events-none text-white text-sm font-semibold shadow-lg shadow-sky-950/30 transition-colors"
>
- {submitting ? "Sending…" : isInitial ? "Send my application" : "Send scope request"}
+ {submitting
+ ? 'Sending…'
+ : isInitial
+ ? 'Send my application'
+ : 'Send scope request'}
);
-}
\ 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 (
);
-}
\ No newline at end of file
+}
diff --git a/src/components/dropdowns/RunwayDropdown.tsx b/src/components/dropdowns/RunwayDropdown.tsx
index 1fdf8d33..7dde5c71 100644
--- a/src/components/dropdowns/RunwayDropdown.tsx
+++ b/src/components/dropdowns/RunwayDropdown.tsx
@@ -36,8 +36,8 @@ export default function RunwayDropdown({
const isLoading = useMemo(() => {
return Boolean(
airportIcao &&
- fetchedAirports.has(airportIcao) &&
- !airportRunways[airportIcao]
+ fetchedAirports.has(airportIcao) &&
+ !airportRunways[airportIcao]
);
}, [airportIcao, fetchedAirports, airportRunways]);
diff --git a/src/components/dropdowns/SidDropdown.tsx b/src/components/dropdowns/SidDropdown.tsx
index e4e6f566..456d1eae 100644
--- a/src/components/dropdowns/SidDropdown.tsx
+++ b/src/components/dropdowns/SidDropdown.tsx
@@ -34,8 +34,8 @@ export default function SidDropdown({
const isLoading = useMemo(() => {
return Boolean(
airportIcao &&
- fetchedAirports.has(airportIcao) &&
- !airportSids[airportIcao]
+ fetchedAirports.has(airportIcao) &&
+ !airportSids[airportIcao]
);
}, [airportIcao, fetchedAirports, airportSids]);
diff --git a/src/components/home/ProductShowcase.tsx b/src/components/home/ProductShowcase.tsx
index 29ed9763..5265c325 100644
--- a/src/components/home/ProductShowcase.tsx
+++ b/src/components/home/ProductShowcase.tsx
@@ -1,7 +1,28 @@
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
import gsap from 'gsap';
import { usePostHog } from 'posthog-js/react';
-import { Check, Copy, GripHorizontal, GripVertical, HelpCircle, Info, Loader2, Map, MessageCircle, MoreHorizontal, Pause, Play, Radio, RefreshCw, RotateCcw, Route, Settings, TowerControl, Wand2, X } from 'lucide-react';
+import {
+ Check,
+ Copy,
+ GripHorizontal,
+ GripVertical,
+ HelpCircle,
+ Info,
+ Loader2,
+ Map,
+ MessageCircle,
+ MoreHorizontal,
+ Pause,
+ Play,
+ Radio,
+ RefreshCw,
+ RotateCcw,
+ Route,
+ Settings,
+ TowerControl,
+ Wand2,
+ X,
+} from 'lucide-react';
import { useAuth } from '../../hooks/auth/useAuth';
import { fetchRoute } from '../../utils/fetch/data';
import Dropdown from '../common/Dropdown';
@@ -13,20 +34,79 @@ import FrequencyDisplay from '../tools/FrequencyDisplay';
import RouteMap from '../map/RouteMap';
//To those trying to maintain this component: Only god and claude knows what's going on in here, good luck and godspeed
const ALL_FLIGHTS = [
- { id: 1, time: '14:32', callsign: 'BAW123', aircraft: 'B738', stand: '205', dest: 'LCLK', rwy: '26L', sid: 'BOGNA1X', rfl: '070', sqk: 2341 },
- { id: 2, time: '14:35', callsign: 'EZY456', aircraft: 'A320', stand: '122', dest: 'LEMH', rwy: '26L', sid: 'BOGNA1X', rfl: '060', sqk: 5342 },
- { id: 3, time: '14:41', callsign: 'AAL2314', aircraft: 'B77W', stand: '437', dest: 'MDPC', rwy: '26L', sid: 'NOVMA1X', rfl: '080', sqk: 3614 },
+ {
+ id: 1,
+ time: '14:32',
+ callsign: 'BAW123',
+ aircraft: 'B738',
+ stand: '205',
+ dest: 'LCLK',
+ rwy: '26L',
+ sid: 'BOGNA1X',
+ rfl: '070',
+ sqk: 2341,
+ },
+ {
+ id: 2,
+ time: '14:35',
+ callsign: 'EZY456',
+ aircraft: 'A320',
+ stand: '122',
+ dest: 'LEMH',
+ rwy: '26L',
+ sid: 'BOGNA1X',
+ rfl: '060',
+ sqk: 5342,
+ },
+ {
+ id: 3,
+ time: '14:41',
+ callsign: 'AAL2314',
+ aircraft: 'B77W',
+ stand: '437',
+ dest: 'MDPC',
+ rwy: '26L',
+ sid: 'NOVMA1X',
+ rfl: '080',
+ sqk: 3614,
+ },
] as const;
const PHASES: { duration: number; caption: string }[] = [
{ duration: 4000, caption: 'Open a session for your airport.' },
- { duration: 5000, caption: 'Share the view link to bring in another controller.' },
- { duration: 4000, caption: 'Share the submit link so pilots can file their flight plans.' },
- { duration: 7000, caption: 'Strips appear as pilots submit, without controllers needing to reload.' },
- { duration: 8000, caption: 'When pilots call and you can\'t accommodate them immediately. You can mark strips as on Request.' },
- { duration: 7000, caption: 'Once cleared, strips can be given a status so other controllers know exactly where each flight is at.' },
- { duration: 5000, caption: 'Squawk codes are assigned automatically and can be regenerated at any time.' },
- { duration: 8000, caption: 'Generate or edit routes for any strip and preview them on the map.' },
+ {
+ duration: 5000,
+ caption: 'Share the view link to bring in another controller.',
+ },
+ {
+ duration: 4000,
+ caption: 'Share the submit link so pilots can file their flight plans.',
+ },
+ {
+ duration: 7000,
+ caption:
+ 'Strips appear as pilots submit, without controllers needing to reload.',
+ },
+ {
+ duration: 8000,
+ caption:
+ "When pilots call and you can't accommodate them immediately. You can mark strips as on Request.",
+ },
+ {
+ duration: 7000,
+ caption:
+ 'Once cleared, strips can be given a status so other controllers know exactly where each flight is at.',
+ },
+ {
+ duration: 5000,
+ caption:
+ 'Squawk codes are assigned automatically and can be regenerated at any time.',
+ },
+ {
+ duration: 8000,
+ caption:
+ 'Generate or edit routes for any strip and preview them on the map.',
+ },
];
function reqColor(elapsed: number) {
@@ -34,57 +114,111 @@ function reqColor(elapsed: number) {
}
function fmtReq(elapsed: number) {
- return `${Math.floor(elapsed / 60)}:${Math.floor(elapsed % 60).toString().padStart(2, '0')}`;
+ return `${Math.floor(elapsed / 60)}:${Math.floor(elapsed % 60)
+ .toString()
+ .padStart(2, '0')}`;
}
type ReqData = { label: string; elapsed: number } | null;
function ReqCell({ req }: { req: ReqData }) {
- if (!req) return REQ
;
+ if (!req)
+ return (
+
+ REQ
+
+ );
const color = reqColor(req.elapsed);
return (
- {req.label}
- {fmtReq(req.elapsed)}
+
+ {req.label}
+
+
+ {fmtReq(req.elapsed)}
+
);
}
-function SqkCell({ value, highlight, spinning }: { value: number; highlight: boolean; spinning?: boolean }) {
+function SqkCell({
+ value,
+ highlight,
+ spinning,
+}: {
+ value: number;
+ highlight: boolean;
+ spinning?: boolean;
+}) {
return (
-
+
{value > 0 ? String(value).padStart(4, '0') : ''}
-
+
);
}
-function CopyBtn({ label, variant, copied, highlight }: {
- label: string; variant: 'submit' | 'view'; copied: boolean; highlight: boolean;
+function CopyBtn({
+ label,
+ variant,
+ copied,
+ highlight,
+}: {
+ label: string;
+ variant: 'submit' | 'view';
+ copied: boolean;
+ highlight: boolean;
}) {
- const base = variant === 'submit' ? 'bg-blue-600 border-blue-600' : 'bg-red-600 border-red-600';
+ const base =
+ variant === 'submit'
+ ? 'bg-blue-600 border-blue-600'
+ : 'bg-red-600 border-red-600';
const active = copied ? 'bg-emerald-600 border-emerald-600' : base;
- const ring = highlight && !copied ? 'ring-2 ring-white/50 ring-offset-1 ring-offset-black/50' : '';
+ const ring =
+ highlight && !copied
+ ? 'ring-2 ring-white/50 ring-offset-1 ring-offset-black/50'
+ : '';
return (
-
-
-
+
+
+
{copied ? 'Copied!' : label}
- {copied &&
}
+ {copied && (
+
+ )}
);
}
-function MockNavbar({ viewCopied, viewHighlight, submitCopied, submitHighlight }: {
- viewCopied: boolean; viewHighlight: boolean;
- submitCopied: boolean; submitHighlight: boolean;
+function MockNavbar({
+ viewCopied,
+ viewHighlight,
+ submitCopied,
+ submitHighlight,
+}: {
+ viewCopied: boolean;
+ viewHighlight: boolean;
+ submitCopied: boolean;
+ submitHighlight: boolean;
}) {
const [utc, setUtc] = useState(() => {
const n = new Date();
@@ -93,16 +227,23 @@ function MockNavbar({ viewCopied, viewHighlight, submitCopied, submitHighlight }
useEffect(() => {
const iv = setInterval(() => {
const n = new Date();
- setUtc(`${String(n.getUTCHours()).padStart(2, '0')}:${String(n.getUTCMinutes()).padStart(2, '0')}:${String(n.getUTCSeconds()).padStart(2, '0')} UTC`);
+ setUtc(
+ `${String(n.getUTCHours()).padStart(2, '0')}:${String(n.getUTCMinutes()).padStart(2, '0')}:${String(n.getUTCSeconds()).padStart(2, '0')} UTC`
+ );
}, 1000);
return () => clearInterval(iv);
}, []);
return (
-
+
- PFControl
+
+ PFControl
+
@@ -110,8 +251,18 @@ function MockNavbar({ viewCopied, viewHighlight, submitCopied, submitHighlight }
Support
{utc}
-
-
+
+
);
@@ -132,7 +283,9 @@ function MockToolbar({ avatarCount }: { avatarCount: number }) {
alt="iceit"
className="w-8 h-8 rounded-full shadow-md object-cover shrink-0"
style={{ border: '2px solid #3b82f6' }}
- onError={(e) => { e.currentTarget.style.display = 'none'; }}
+ onError={(e) => {
+ e.currentTarget.style.display = 'none';
+ }}
/>
= 2 ? 1 : 0,
transform: avatarCount >= 2 ? 'scale(1)' : 'scale(0.6)',
}}
- onError={(e) => { e.currentTarget.style.display = 'none'; }}
+ onError={(e) => {
+ e.currentTarget.style.display = 'none';
+ }}
/>
- EGKK
+
+ EGKK
+
@@ -166,12 +323,21 @@ function MockToolbar({ avatarCount }: { avatarCount: number }) {
className="min-w-[100px]"
/>
{}}
size="sm"
/>
-
+
ATIS A
@@ -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() {
-
+
- TIME
- CALLSIGN
- REQ
- STAND
- ATYP
- ADES
- RWY
- SID
- RFL
- CFL
- RTE
- ASSR
- C
- STS
-
+
+ TIME
+
+
+ CALLSIGN
+
+
+ REQ
+
+
+ STAND
+
+
+ ATYP
+
+
+ ADES
+
+
+ RWY
+
+
+ SID
+
+
+ RFL
+
+
+ CFL
+
+
+ RTE
+
+
+ ASSR
+
+
+ C
+
+
+ STS
+
+
+
+
@@ -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,
+ }}
>
-
- {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) => (
goTo(i)}
- style={{ border: 'none', padding: 0, cursor: 'pointer', background: 'none', flexShrink: 0, width: '36px', display: 'flex', justifyContent: 'center' }}
+ style={{
+ border: 'none',
+ padding: 0,
+ cursor: 'pointer',
+ background: 'none',
+ flexShrink: 0,
+ width: '36px',
+ display: 'flex',
+ justifyContent: 'center',
+ }}
>
{ dotRefs.current[i] = el; }}
+ ref={(el) => {
+ dotRefs.current[i] = el;
+ }}
style={{
width: 11,
height: '11px',
@@ -832,31 +1296,51 @@ export default function ProductShowcase() {
}}
>
{i === slide && !done && (
-
+
)}
))}
{/* Done dot */}
{ setSlide(PHASES.length - 1); setDone(true); setPaused(true); }}
- style={{ border: 'none', padding: 0, cursor: 'pointer', background: 'none', flexShrink: 0, width: '36px', display: 'flex', justifyContent: 'center' }}
+ onClick={() => {
+ setSlide(PHASES.length - 1);
+ setDone(true);
+ setPaused(true);
+ }}
+ style={{
+ border: 'none',
+ padding: 0,
+ cursor: 'pointer',
+ background: 'none',
+ flexShrink: 0,
+ width: '36px',
+ display: 'flex',
+ justifyContent: 'center',
+ }}
>
{ dotRefs.current[PHASES.length] = el; }}
+ ref={(el) => {
+ dotRefs.current[PHASES.length] = el;
+ }}
style={{
width: 11,
height: '11px',
borderRadius: 9999,
- background: done ? 'rgba(255,255,255,0.95)' : 'rgba(255,255,255,0.25)',
+ background: done
+ ? 'rgba(255,255,255,0.95)'
+ : 'rgba(255,255,255,0.25)',
transition: 'background 0.3s',
flexShrink: 0,
}}
@@ -870,20 +1354,45 @@ export default function ProductShowcase() {
goTo(0, 'restart')}
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors cursor-pointer"
- style={{ background: 'rgba(255,255,255,0.1)', backdropFilter: 'blur(8px)', border: 'none' }}
+ style={{
+ background: 'rgba(255,255,255,0.1)',
+ backdropFilter: 'blur(8px)',
+ border: 'none',
+ }}
>
) : (
{ setPaused(v => { posthog?.capture('showcase_playback', { action: v ? 'play' : 'pause', slide }); return !v; }); }}
+ onClick={() => {
+ setPaused((v) => {
+ posthog?.capture('showcase_playback', {
+ action: v ? 'play' : 'pause',
+ slide,
+ });
+ return !v;
+ });
+ }}
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors cursor-pointer"
- style={{ background: 'rgba(255,255,255,0.1)', backdropFilter: 'blur(8px)', border: 'none' }}
+ style={{
+ background: 'rgba(255,255,255,0.1)',
+ backdropFilter: 'blur(8px)',
+ border: 'none',
+ }}
>
- {paused
- ?
- :
- }
+ {paused ? (
+
+ ) : (
+
+ )}
)}
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({
Time
- {(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}
-
+
+ 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
+
- {copied ? "Copied!" : "Copy"}
+
+ {copied ? 'Copied!' : 'Copy'}
+
{copied && (
@@ -438,7 +482,9 @@ export default function ATIS({
)}
-
ATIS Identifier
+
+ ATIS Identifier
+
{identOptions.map((letter) => (
setIdent(letter)}
className={`p-2 rounded-xl text-center text-sm font-medium transition-colors ${
letter === ident
- ? "bg-blue-600 text-white border border-blue-500"
- : "bg-zinc-800 text-gray-300 hover:bg-zinc-700 border border-zinc-700"
+ ? 'bg-blue-600 text-white border border-blue-500'
+ : 'bg-zinc-800 text-gray-300 hover:bg-zinc-700 border border-zinc-700'
}`}
>
{letter}
@@ -458,7 +504,9 @@ export default function ATIS({
-
Approach Types
+
+ Approach Types
+
{approachOptions.map((approach) => (
toggleApproachType(approach)}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${
selectedApproaches.includes(approach)
- ? "bg-blue-600 text-white border border-blue-500"
- : "bg-zinc-800 text-gray-300 hover:bg-zinc-700 border border-zinc-700"
+ ? 'bg-blue-600 text-white border border-blue-500'
+ : 'bg-zinc-800 text-gray-300 hover:bg-zinc-700 border border-zinc-700'
}`}
>
{approach}
@@ -478,7 +526,9 @@ export default function ATIS({
-
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 && (
{flight.route && flight.route.trim().length > 0 && (
-
+
1 && looksLikeProcedure(last) && last.toUpperCase() !== sid
+ relevant.length > 1 &&
+ looksLikeProcedure(last) &&
+ last.toUpperCase() !== sid
? last.toUpperCase()
: undefined;
@@ -61,7 +64,9 @@ export default function RouteModal({
}: RouteModalProps) {
const [editedRoute, setEditedRoute] = useState(flight?.route || '');
const [displaySid, setDisplaySid] = useState(flight?.sid);
- const [displayStar, setDisplayStar] = useState(flight?.star);
+ const [displayStar, setDisplayStar] = useState(
+ flight?.star
+ );
const [translate, setTranslate] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
@@ -96,7 +101,11 @@ export default function RouteModal({
const handleRouteChange = (value: string) => {
setEditedRoute(value);
- const { sid, star } = parseSidStar(value, flight?.departure, flight?.arrival);
+ const { sid, star } = parseSidStar(
+ value,
+ flight?.departure,
+ flight?.arrival
+ );
setDisplaySid(sid);
setDisplayStar(star);
@@ -113,7 +122,7 @@ export default function RouteModal({
y: e.clientY - translate.y,
});
},
- [translate],
+ [translate]
);
useEffect(() => {
@@ -144,7 +153,7 @@ export default function RouteModal({
const result = await fetchRoute(
flight.departure,
flight.arrival,
- activeRunway ?? undefined,
+ activeRunway ?? undefined
);
if (result.success && result.route) {
setEditedRoute(result.route);
@@ -202,20 +211,34 @@ export default function RouteModal({
{/* Dep / Arr / SID / STAR */}
-
Departure
-
{flight.departure || '—'}
+
+ Departure
+
+
+ {flight.departure || '—'}
+
-
Arrival
-
{flight.arrival || '—'}
+
+ Arrival
+
+
+ {flight.arrival || '—'}
+
SID
-
{displaySid || '—'}
+
+ {displaySid || '—'}
+
-
STAR
-
{displayStar || '—'}
+
+ STAR
+
+
+ {displayStar || '—'}
+
@@ -259,7 +282,10 @@ export default function RouteModal({
{/* Route Map preview */}
{showMap && (
-
+
- Alternate
- {flight.alternate}
+
+ Alternate
+
+
+ {flight.alternate}
+
)}
diff --git a/src/components/tools/Toolbar.tsx b/src/components/tools/Toolbar.tsx
index 79de674e..c367918e 100644
--- a/src/components/tools/Toolbar.tsx
+++ b/src/components/tools/Toolbar.tsx
@@ -645,4 +645,4 @@ export default function Toolbar({
);
-}
\ No newline at end of file
+}
diff --git a/src/components/tools/UserButton.tsx b/src/components/tools/UserButton.tsx
index e0e89d40..1ffe6250 100644
--- a/src/components/tools/UserButton.tsx
+++ b/src/components/tools/UserButton.tsx
@@ -388,4 +388,4 @@ export default function CustomUserButton({
)}
);
-}
\ 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 (
);
@@ -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
-
+
{pressureDisplay.value}
@@ -403,7 +438,7 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size
{metarData.fltCat}
@@ -423,8 +458,10 @@ const WindDisplay: React.FC = ({ icao, forceHide = false, size
)}
{metarData.name}
@@ -435,9 +472,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.
)}
@@ -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
- setShowAccountPrompt(false)}>
+ setShowAccountPrompt(false)}
+ >
Skip for 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() {
setTimeRange(days)}
- variant={timeRange === days ? "primary" : "outline"}
+ variant={timeRange === days ? 'primary' : 'outline'}
size={btnSize}
>
{days}d
@@ -247,20 +247,20 @@ export default function Admin() {
<>
-
+
@@ -277,15 +277,15 @@ export default function Admin() {
·
- {activityChartView === "accounts" ? (
+ {activityChartView === 'accounts' ? (
<>
{period.total_logins.toLocaleString()}
- {" "}
- logins ·{" "}
+ {' '}
+ logins ·{' '}
{period.total_users.toLocaleString()}
- {" "}
+ {' '}
new users in period
>
) : activeActivityView.periodKey ? (
@@ -294,7 +294,7 @@ export default function Admin() {
{period[
activeActivityView.periodKey
].toLocaleString()}
- {" "}
+ {' '}
{activeActivityView.label.toLowerCase()} in period
>
) : null}
@@ -326,7 +326,7 @@ export default function Admin() {
- {activityChartView === "accounts" ? (
+ {activityChartView === 'accounts' ? (
- {hasPermission("audit") ? (
+ {hasPermission('audit') ? (
@@ -412,4 +412,4 @@ export default function Admin() {
)}
);
-}
\ No newline at end of file
+}
diff --git a/src/pages/Create.tsx b/src/pages/Create.tsx
index 4773acab..b9193aae 100644
--- a/src/pages/Create.tsx
+++ b/src/pages/Create.tsx
@@ -620,4 +620,4 @@ export default function Create() {
)}
);
-}
\ No newline at end of file
+}
diff --git a/src/pages/Flights.tsx b/src/pages/Flights.tsx
index 9ab3c244..c1f6e202 100644
--- a/src/pages/Flights.tsx
+++ b/src/pages/Flights.tsx
@@ -1,40 +1,46 @@
-import { useEffect, useState, useMemo, useCallback, useRef } from "react";
-import { useParams, useSearchParams } from "react-router-dom";
-import { useMediaQuery } from "react-responsive";
-import { fetchFlights, addFlight } from "../utils/fetch/flights";
-import { fetchSession, updateSession } from "../utils/fetch/sessions";
-import { fetchBackgrounds } from "../utils/fetch/data";
-import { createFlightsSocket } from "../sockets/flightsSocket";
-import { createArrivalsSocket } from "../sockets/arrivalsSocket";
-import { createSessionUsersSocket } from "../sockets/sessionUsersSocket";
-import { useAuth } from "../hooks/auth/useAuth";
-import { playSoundWithSettings } from "../utils/playSound";
-import { useSettings } from "../hooks/settings/useSettings";
-import { steps } from "../components/tutorial/TutorialStepsFlights";
-import { updateTutorialStatus } from "../utils/fetch/auth";
-import { getChartsForAirport } from "../utils/acars";
-import { createChartHandlers } from "../utils/charts";
-import { useData } from "../hooks/data/useData";
-import type { Flight } from "../types/flight";
-import type { Position } from "../types/session";
-import type { ArrivalsTableColumnSettings, DepartureTableColumnSettings } from "../types/settings";
-import type { FieldEditingState } from "../sockets/sessionUsersSocket";
-import Joyride, { type CallBackProps, STATUS } from "react-joyride-react19-compat";
-import { usePostHog } from "@posthog/react";
-import { trackTutorialEvent } from "../utils/tutorialTracking";
-import Navbar from "../components/Navbar";
-import Toolbar from "../components/tools/Toolbar";
-import DepartureTable from "../components/tables/DepartureTable";
-import ArrivalsTable from "../components/tables/ArrivalsTable";
-import CombinedFlightsTable from "../components/tables/CombinedFlightsTable";
-import AccessDenied from "../components/AccessDenied";
-import AddCustomFlightModal from "../components/modals/AddCustomFlightModal";
-import ContactAcarsSidebar from "../components/tools/ContactAcarsSidebar";
-import CustomTooltip from "../components/tutorial/CustomTooltip";
-import ChartDrawer from "../components/tools/ChartDrawer";
-import Button from "../components/common/Button";
-import Loader from "../components/common/Loader";
-import { hasAdvancedNetworkFeatures } from "../utils/sessionKind";
+import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
+import { useParams, useSearchParams } from 'react-router-dom';
+import { useMediaQuery } from 'react-responsive';
+import { fetchFlights, addFlight } from '../utils/fetch/flights';
+import { fetchSession, updateSession } from '../utils/fetch/sessions';
+import { fetchBackgrounds } from '../utils/fetch/data';
+import { createFlightsSocket } from '../sockets/flightsSocket';
+import { createArrivalsSocket } from '../sockets/arrivalsSocket';
+import { createSessionUsersSocket } from '../sockets/sessionUsersSocket';
+import { useAuth } from '../hooks/auth/useAuth';
+import { playSoundWithSettings } from '../utils/playSound';
+import { useSettings } from '../hooks/settings/useSettings';
+import { steps } from '../components/tutorial/TutorialStepsFlights';
+import { updateTutorialStatus } from '../utils/fetch/auth';
+import { getChartsForAirport } from '../utils/acars';
+import { createChartHandlers } from '../utils/charts';
+import { useData } from '../hooks/data/useData';
+import type { Flight } from '../types/flight';
+import type { Position } from '../types/session';
+import type {
+ ArrivalsTableColumnSettings,
+ DepartureTableColumnSettings,
+} from '../types/settings';
+import type { FieldEditingState } from '../sockets/sessionUsersSocket';
+import Joyride, {
+ type CallBackProps,
+ STATUS,
+} from 'react-joyride-react19-compat';
+import { usePostHog } from '@posthog/react';
+import { trackTutorialEvent } from '../utils/tutorialTracking';
+import Navbar from '../components/Navbar';
+import Toolbar from '../components/tools/Toolbar';
+import DepartureTable from '../components/tables/DepartureTable';
+import ArrivalsTable from '../components/tables/ArrivalsTable';
+import CombinedFlightsTable from '../components/tables/CombinedFlightsTable';
+import AccessDenied from '../components/AccessDenied';
+import AddCustomFlightModal from '../components/modals/AddCustomFlightModal';
+import ContactAcarsSidebar from '../components/tools/ContactAcarsSidebar';
+import CustomTooltip from '../components/tutorial/CustomTooltip';
+import ChartDrawer from '../components/tools/ChartDrawer';
+import Button from '../components/common/Button';
+import Loader from '../components/common/Loader';
+import { hasAdvancedNetworkFeatures } from '../utils/sessionKind';
const API_BASE_URL = import.meta.env.VITE_SERVER_URL;
@@ -60,8 +66,8 @@ interface AvailableImage {
export default function Flights() {
const { sessionId } = useParams<{ sessionId?: string }>();
const [searchParams] = useSearchParams();
- const accessId = searchParams.get("accessId") ?? undefined;
- const startTutorial = searchParams.get("tutorial") === "true";
+ const accessId = searchParams.get('accessId') ?? undefined;
+ const startTutorial = searchParams.get('tutorial') === 'true';
const isMobile = useMediaQuery({ maxWidth: 1000 });
const posthog = usePostHog();
@@ -72,9 +78,9 @@ export default function Flights() {
const [loading, setLoading] = useState(true);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [flashingPDCIds, setFlashingPDCIds] = useState
>(new Set());
- const [flightsSocket, setFlightsSocket] = useState | null>(
- null,
- );
+ const [flightsSocket, setFlightsSocket] = useState | null>(null);
const [arrivalsSocket, setArrivalsSocket] = useState | null>(null);
@@ -84,22 +90,36 @@ export default function Flights() {
const { user } = useAuth();
const { settings } = useSettings();
const { airports } = useData();
- const [currentView, setCurrentView] = useState<"departures" | "arrivals">("departures");
+ const [currentView, setCurrentView] = useState<'departures' | 'arrivals'>(
+ 'departures'
+ );
const [externalArrivals, setExternalArrivals] = useState([]);
const [arrivalsLoading, setArrivalsLoading] = useState(true);
- const [localHiddenFlights, setLocalHiddenFlights] = useState>(new Set());
- const [position, setPosition] = useState("ALL");
- const [fieldEditingStates, setFieldEditingStates] = useState([]);
+ const [localHiddenFlights, setLocalHiddenFlights] = useState<
+ Set
+ >(new Set());
+ const [position, setPosition] = useState('ALL');
+ const [fieldEditingStates, setFieldEditingStates] = useState<
+ FieldEditingState[]
+ >([]);
const [sessionUsersSocket, setSessionUsersSocket] = useState | null>(null);
- const [customDepartureFlights, setCustomDepartureFlights] = useState([]);
- const [customArrivalFlights, setCustomArrivalFlights] = useState([]);
+ const [customDepartureFlights, setCustomDepartureFlights] = useState<
+ Flight[]
+ >([]);
+ const [customArrivalFlights, setCustomArrivalFlights] = useState(
+ []
+ );
const [showAddDepartureModal, setShowAddDepartureModal] = useState(false);
const [showAddArrivalModal, setShowAddArrivalModal] = useState(false);
const [showContactAcarsModal, setShowContactAcarsModal] = useState(false);
- const [activeAcarsFlights, setActiveAcarsFlights] = useState>(new Set());
- const [activeAcarsFlightData, setActiveAcarsFlightData] = useState([]);
+ const [activeAcarsFlights, setActiveAcarsFlights] = useState<
+ Set
+ >(new Set());
+ const [activeAcarsFlightData, setActiveAcarsFlightData] = useState(
+ []
+ );
const [showChartsDrawer, setShowChartsDrawer] = useState(false);
const [selectedChart, setSelectedChart] = useState(null);
const [chartLoadError, setChartLoadError] = useState(false);
@@ -126,8 +146,12 @@ export default function Flights() {
const handleMentionReceived = useCallback(() => {
const currentUser = userRef.current;
if (currentUser) {
- playSoundWithSettings("chatNotificationSound", currentUser.settings, 0.7).catch((error) => {
- console.warn("Failed to play chat notification sound:", error);
+ playSoundWithSettings(
+ 'chatNotificationSound',
+ currentUser.settings,
+ 0.7
+ ).catch((error) => {
+ console.warn('Failed to play chat notification sound:', error);
});
}
}, []);
@@ -154,7 +178,7 @@ export default function Flights() {
const data = await fetchBackgrounds();
setAvailableImages(data);
} catch (error) {
- console.error("Error loading available images:", error);
+ console.error('Error loading available images:', error);
}
};
loadImages();
@@ -162,13 +186,13 @@ export default function Flights() {
useEffect(() => {
if (!sessionId) {
- setAccessError("Session ID is required");
+ setAccessError('Session ID is required');
setValidatingAccess(false);
return;
}
if (!accessId) {
- setAccessError("Access ID is required. Please use a valid session link.");
+ setAccessError('Access ID is required. Please use a valid session link.');
setValidatingAccess(false);
return;
}
@@ -178,25 +202,37 @@ export default function Flights() {
}, [sessionId, accessId]);
useEffect(() => {
- if (!sessionId || sessionId === lastSessionId || initialLoadComplete || accessError) return;
+ if (
+ !sessionId ||
+ sessionId === lastSessionId ||
+ initialLoadComplete ||
+ accessError
+ )
+ return;
setLoading(true);
setLastSessionId(sessionId);
Promise.all([
- fetchSession(sessionId, accessId ?? "").catch((error) => {
- console.error("Error fetching session:", error);
- if (error.message?.includes("403") || error.message?.includes("Invalid session access")) {
- setAccessError("Invalid access link or session expired");
- } else if (error.message?.includes("404") || error.message?.includes("not found")) {
- setAccessError("Session not found");
+ fetchSession(sessionId, accessId ?? '').catch((error) => {
+ console.error('Error fetching session:', error);
+ if (
+ error.message?.includes('403') ||
+ error.message?.includes('Invalid session access')
+ ) {
+ setAccessError('Invalid access link or session expired');
+ } else if (
+ error.message?.includes('404') ||
+ error.message?.includes('not found')
+ ) {
+ setAccessError('Session not found');
} else {
- setAccessError("Unable to access session");
+ setAccessError('Unable to access session');
}
return null;
}),
fetchFlights(sessionId).catch((error) => {
- console.error("Error fetching flights:", error);
+ console.error('Error fetching flights:', error);
return [];
}),
])
@@ -207,9 +243,11 @@ export default function Flights() {
setFlights(flightsData);
setInitialLoadComplete(true);
if (!startupSoundPlayed && user && settings) {
- playSoundWithSettings("startupSound", settings, 0.7).catch((error) => {
- console.warn("Failed to play session startup sound:", error);
- });
+ playSoundWithSettings('startupSound', settings, 0.7).catch(
+ (error) => {
+ console.warn('Failed to play session startup sound:', error);
+ }
+ );
setStartupSoundPlayed(true);
}
})
@@ -253,29 +291,39 @@ export default function Flights() {
const currentSettings = settingsRef.current;
if (currentSettings) {
- playSoundWithSettings("newStripSound", currentSettings, 0.7).catch((error) => {
- console.warn("Failed to play new strip sound:", error);
- });
+ playSoundWithSettings('newStripSound', currentSettings, 0.7).catch(
+ (error) => {
+ console.warn('Failed to play new strip sound:', error);
+ }
+ );
}
};
- const handleFlightDeleted = ({ flightId }: { flightId: string | number }) => {
+ const handleFlightDeleted = ({
+ flightId,
+ }: {
+ flightId: string | number;
+ }) => {
setFlights((prev) => prev.filter((flight) => flight.id !== flightId));
};
const socket = createFlightsSocket(
sessionId,
accessId,
- user?.userId || "",
- user?.username || "",
+ user?.userId || '',
+ user?.username || '',
handleFlightUpdate,
handleFlightAdded,
handleFlightDeleted,
- (error: { action: string; flightId?: string | number; error: string }) => {
- console.error("Flight websocket error:", error);
- },
+ (error: {
+ action: string;
+ flightId?: string | number;
+ error: string;
+ }) => {
+ console.error('Flight websocket error:', error);
+ }
);
- socket.socket.on("sessionUpdated", (updates) => {
+ socket.socket.on('sessionUpdated', (updates) => {
setSession((prev) => (prev ? { ...prev, ...updates } : null));
});
setFlightsSocket(socket);
@@ -283,13 +331,20 @@ export default function Flights() {
flightsSocketConnectedRef.current = false;
socket.socket.disconnect();
};
- }, [sessionId, accessId, initialLoadComplete, accessError, user?.userId, user?.username]);
+ }, [
+ sessionId,
+ accessId,
+ initialLoadComplete,
+ accessError,
+ user?.userId,
+ user?.username,
+ ]);
const handleIssuePDC = async (flightId: string | number, pdcText: string) => {
if (!flightsSocket?.socket) {
- console.warn("handleIssuePDC: no flights socket available");
- throw new Error("No flights socket");
+ console.warn('handleIssuePDC: no flights socket available');
+ throw new Error('No flights socket');
}
- flightsSocket.socket.emit("issuePDC", { flightId, pdcText });
+ flightsSocket.socket.emit('issuePDC', { flightId, pdcText });
setFlashingPDCIds((prev) => {
const next = new Set(prev);
@@ -306,8 +361,8 @@ export default function Flights() {
const response = await fetch(
`${import.meta.env.VITE_SERVER_URL}/api/flights/acars/active`,
{
- credentials: "include",
- },
+ credentials: 'include',
+ }
);
if (response.ok) {
@@ -327,12 +382,12 @@ export default function Flights() {
flightId: string | number,
message: string,
station: string,
- position: string,
+ position: string
) => {
if (!flightsSocket?.socket) {
- throw new Error("No flights socket");
+ throw new Error('No flights socket');
}
- flightsSocket.socket.emit("contactMe", {
+ flightsSocket.socket.emit('contactMe', {
flightId,
message,
station,
@@ -369,13 +424,13 @@ export default function Flights() {
},
// onArrivalError
(error) => {
- console.error("Arrival websocket error:", error);
+ console.error('Arrival websocket error:', error);
},
// onInitialExternalArrivals
(flights: Flight[]) => {
setExternalArrivals(flights);
setArrivalsLoading(false);
- },
+ }
);
setArrivalsSocket(socket);
return () => {
@@ -386,7 +441,11 @@ export default function Flights() {
// For sessions without advanced network features, arrivals come from own flights (already loaded)
useEffect(() => {
- if (initialLoadComplete && session && !hasAdvancedNetworkFeatures(session)) {
+ if (
+ initialLoadComplete &&
+ session &&
+ !hasAdvancedNetworkFeatures(session)
+ ) {
setArrivalsLoading(false);
}
}, [initialLoadComplete, session]);
@@ -423,24 +482,32 @@ export default function Flights() {
() => {},
() => {},
handleMentionReceived,
- (editingStates: FieldEditingState[]) => setFieldEditingStates(editingStates),
- "ALL",
+ (editingStates: FieldEditingState[]) =>
+ setFieldEditingStates(editingStates),
+ 'ALL'
);
setSessionUsersSocket(socket);
if (socket) {
- socket.on("atisUpdate", handleAtisUpdateFromSocket);
+ socket.on('atisUpdate', handleAtisUpdateFromSocket);
}
return () => {
sessionUsersSocketConnectedRef.current = false;
if (socket) {
- socket.off("atisUpdate", handleAtisUpdateFromSocket);
+ socket.off('atisUpdate', handleAtisUpdateFromSocket);
socket.disconnect();
}
};
- }, [sessionId, accessId, user?.userId, user?.username, user?.avatar, handleMentionReceived]);
+ }, [
+ sessionId,
+ accessId,
+ user?.userId,
+ user?.username,
+ user?.avatar,
+ handleMentionReceived,
+ ]);
useEffect(() => {
if (sessionUsersSocket && sessionUsersSocket.emitPositionChange) {
@@ -461,13 +528,16 @@ export default function Flights() {
});
};
- flightsSocket.socket.on("pdcRequest", onPdcRequest);
+ flightsSocket.socket.on('pdcRequest', onPdcRequest);
return () => {
- flightsSocket.socket.off("pdcRequest", onPdcRequest);
+ flightsSocket.socket.off('pdcRequest', onPdcRequest);
};
}, [flightsSocket]);
- const handleToggleClearance = (flightId: string | number, checked: boolean) => {
+ const handleToggleClearance = (
+ flightId: string | number,
+ checked: boolean
+ ) => {
handleFlightUpdate(flightId, { clearance: checked });
if (checked) {
@@ -479,8 +549,11 @@ export default function Flights() {
}
};
- const handleFlightUpdate = (flightId: string | number, updates: Partial) => {
- if (Object.prototype.hasOwnProperty.call(updates, "hidden")) {
+ const handleFlightUpdate = (
+ flightId: string | number,
+ updates: Partial
+ ) => {
+ if (Object.prototype.hasOwnProperty.call(updates, 'hidden')) {
if (updates.hidden) {
setLocalHiddenFlights((prev) => new Set(prev).add(flightId));
} else {
@@ -493,10 +566,12 @@ export default function Flights() {
return;
}
- const isCustomDeparture = customDepartureFlights.some((f) => f.id === flightId);
+ const isCustomDeparture = customDepartureFlights.some(
+ (f) => f.id === flightId
+ );
if (isCustomDeparture) {
setCustomDepartureFlights((prev) =>
- prev.map((f) => (f.id === flightId ? { ...f, ...updates } : f)),
+ prev.map((f) => (f.id === flightId ? { ...f, ...updates } : f))
);
return;
}
@@ -504,30 +579,37 @@ export default function Flights() {
const isCustomArrival = customArrivalFlights.some((f) => f.id === flightId);
if (isCustomArrival) {
setCustomArrivalFlights((prev) =>
- prev.map((f) => (f.id === flightId ? { ...f, ...updates } : f)),
+ prev.map((f) => (f.id === flightId ? { ...f, ...updates } : f))
);
return;
}
const isLocalFlight = flights.some((f) => f.id === flightId);
- const isExternalArrival = !isLocalFlight && externalArrivals.some((f) => f.id === flightId);
+ const isExternalArrival =
+ !isLocalFlight && externalArrivals.some((f) => f.id === flightId);
if (isExternalArrival && arrivalsSocket?.socket?.connected) {
arrivalsSocket.updateArrival(flightId, updates);
} else if (flightsSocket?.socket?.connected) {
flightsSocket.updateFlight(flightId, updates);
} else {
- console.warn("Socket not connected, updating local state only");
+ console.warn('Socket not connected, updating local state only');
setFlights((prev) =>
- prev.map((flight) => (flight.id === flightId ? { ...flight, ...updates } : flight)),
+ prev.map((flight) =>
+ flight.id === flightId ? { ...flight, ...updates } : flight
+ )
);
}
};
const handleFlightDelete = (flightId: string | number) => {
- const isCustomDeparture = customDepartureFlights.some((f) => f.id === flightId);
+ const isCustomDeparture = customDepartureFlights.some(
+ (f) => f.id === flightId
+ );
if (isCustomDeparture) {
- setCustomDepartureFlights((prev) => prev.filter((f) => f.id !== flightId));
+ setCustomDepartureFlights((prev) =>
+ prev.filter((f) => f.id !== flightId)
+ );
return;
}
@@ -540,7 +622,7 @@ export default function Flights() {
if (flightsSocket?.socket?.connected) {
flightsSocket.deleteFlight(flightId);
} else {
- console.warn("Socket not connected, updating local state only");
+ console.warn('Socket not connected, updating local state only');
setFlights((prev) => prev.filter((flight) => flight.id !== flightId));
}
};
@@ -549,19 +631,19 @@ export default function Flights() {
if (!sessionId) return;
const newFlightData: Partial = {
- callsign: flightData.callsign || "",
- aircraft: flightData.aircraft || "",
- departure: session?.airportIcao || "",
- arrival: flightData.arrival || "",
- flight_type: flightData.flight_type || "IFR",
+ callsign: flightData.callsign || '',
+ aircraft: flightData.aircraft || '',
+ departure: session?.airportIcao || '',
+ arrival: flightData.arrival || '',
+ flight_type: flightData.flight_type || 'IFR',
stand: flightData.stand,
runway: flightData.runway,
sid: flightData.sid,
cruisingFL: flightData.cruisingFL,
clearedFL: flightData.clearedFL,
squawk: flightData.squawk,
- wtc: flightData.wtc || "M",
- status: flightData.status || "PENDING",
+ wtc: flightData.wtc || 'M',
+ status: flightData.status || 'PENDING',
remark: flightData.remark,
hidden: false,
};
@@ -569,7 +651,7 @@ export default function Flights() {
try {
await addFlight(sessionId, newFlightData);
} catch (error) {
- console.error("Failed to add custom departure:", error);
+ console.error('Failed to add custom departure:', error);
}
};
@@ -577,19 +659,19 @@ export default function Flights() {
if (!sessionId) return;
const newFlightData: Partial = {
- callsign: flightData.callsign || "",
- aircraft: flightData.aircraft || "",
- departure: flightData.departure || "",
- arrival: session?.airportIcao || "",
- flight_type: flightData.flight_type || "IFR",
+ callsign: flightData.callsign || '',
+ aircraft: flightData.aircraft || '',
+ departure: flightData.departure || '',
+ arrival: session?.airportIcao || '',
+ flight_type: flightData.flight_type || 'IFR',
gate: flightData.gate,
runway: flightData.runway,
star: flightData.star,
cruisingFL: flightData.cruisingFL,
clearedFL: flightData.clearedFL,
squawk: flightData.squawk,
- wtc: flightData.wtc || "M",
- status: flightData.status || "APPR",
+ wtc: flightData.wtc || 'M',
+ status: flightData.status || 'APPR',
remark: flightData.remark,
hidden: false,
};
@@ -597,43 +679,45 @@ export default function Flights() {
try {
await addFlight(sessionId, newFlightData);
} catch (error) {
- console.error("Failed to add custom arrival:", error);
+ console.error('Failed to add custom arrival:', error);
}
};
const handleRunwayChange = async (selectedRunway: string) => {
if (!sessionId) return;
try {
- await updateSession(sessionId, accessId ?? "", {
+ await updateSession(sessionId, accessId ?? '', {
activeRunway: selectedRunway,
});
- setSession((prev) => (prev ? { ...prev, activeRunway: selectedRunway } : null));
+ setSession((prev) =>
+ prev ? { ...prev, activeRunway: selectedRunway } : null
+ );
if (flightsSocket?.socket?.connected) {
flightsSocket.updateSession({ activeRunway: selectedRunway });
} else {
- console.warn("Socket not connected, runway updated via API only");
+ console.warn('Socket not connected, runway updated via API only');
}
} catch (error) {
- console.error("Failed to update runway:", error);
+ console.error('Failed to update runway:', error);
}
};
- const handleViewChange = (view: "departures" | "arrivals") => {
+ const handleViewChange = (view: 'departures' | 'arrivals') => {
setCurrentView(view);
};
const getAllowedStatuses = (pos: Position): string[] => {
switch (pos) {
- case "ALL":
+ case 'ALL':
return [];
- case "DEL":
- return ["PENDING", "STUP"];
- case "GND":
- return ["STUP", "PUSH", "TAXI"];
- case "TWR":
- return ["TAXI", "RWY", "DEPA"];
- case "APP":
- return ["RWY", "DEPA"];
+ case 'DEL':
+ return ['PENDING', 'STUP'];
+ case 'GND':
+ return ['STUP', 'PUSH', 'TAXI'];
+ case 'TWR':
+ return ['TAXI', 'RWY', 'DEPA'];
+ case 'APP':
+ return ['RWY', 'DEPA'];
default:
return [];
}
@@ -641,7 +725,11 @@ export default function Flights() {
const departureFlights = useMemo(() => {
const regularDepartures = flights
- .filter((flight) => flight.departure?.toUpperCase() === session?.airportIcao?.toUpperCase())
+ .filter(
+ (flight) =>
+ flight.departure?.toUpperCase() ===
+ session?.airportIcao?.toUpperCase()
+ )
.map((flight) => ({
...flight,
hidden: localHiddenFlights.has(flight.id),
@@ -649,19 +737,26 @@ export default function Flights() {
let allDepartures = [...regularDepartures, ...customDepartureFlights];
- if (position !== "ALL") {
+ if (position !== 'ALL') {
const allowedStatuses = getAllowedStatuses(position);
allDepartures = allDepartures.filter((flight) =>
- allowedStatuses.includes(flight.status || ""),
+ allowedStatuses.includes(flight.status || '')
);
}
return allDepartures;
- }, [flights, session?.airportIcao, localHiddenFlights, customDepartureFlights, position]);
+ }, [
+ flights,
+ session?.airportIcao,
+ localHiddenFlights,
+ customDepartureFlights,
+ position,
+ ]);
const arrivalFlights = useMemo(() => {
const ownArrivals = flights.filter(
- (flight) => flight.arrival?.toUpperCase() === session?.airportIcao?.toUpperCase(),
+ (flight) =>
+ flight.arrival?.toUpperCase() === session?.airportIcao?.toUpperCase()
);
let baseArrivals = ownArrivals;
@@ -689,14 +784,17 @@ export default function Flights() {
const filteredFlights = useMemo(() => {
let baseFlights: Flight[] = [];
- if (currentView === "arrivals") {
+ if (currentView === 'arrivals') {
const ownArrivals = flights.filter(
- (flight) => flight.arrival?.toUpperCase() === session?.airportIcao?.toUpperCase(),
+ (flight) =>
+ flight.arrival?.toUpperCase() === session?.airportIcao?.toUpperCase()
);
if (session && hasAdvancedNetworkFeatures(session)) {
const ownIds = new Set(ownArrivals.map((f) => f.id));
- const dedupedExternal = externalArrivals.filter((f) => !ownIds.has(f.id));
+ const dedupedExternal = externalArrivals.filter(
+ (f) => !ownIds.has(f.id)
+ );
baseFlights = [...ownArrivals, ...dedupedExternal];
} else {
baseFlights = ownArrivals;
@@ -705,15 +803,19 @@ export default function Flights() {
baseFlights = [...baseFlights, ...customArrivalFlights];
} else {
baseFlights = flights.filter(
- (flight) => flight.departure?.toUpperCase() === session?.airportIcao?.toUpperCase(),
+ (flight) =>
+ flight.departure?.toUpperCase() ===
+ session?.airportIcao?.toUpperCase()
);
baseFlights = [...baseFlights, ...customDepartureFlights];
}
- if (currentView === "departures" && position !== "ALL") {
+ if (currentView === 'departures' && position !== 'ALL') {
const allowedStatuses = getAllowedStatuses(position);
- baseFlights = baseFlights.filter((flight) => allowedStatuses.includes(flight.status || ""));
+ baseFlights = baseFlights.filter((flight) =>
+ allowedStatuses.includes(flight.status || '')
+ );
}
return baseFlights.map((flight) => ({
@@ -737,32 +839,37 @@ export default function Flights() {
let bgImage = 'url("/assets/app/backgrounds/mdpc_01.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})`;
}
}
@@ -774,12 +881,13 @@ export default function Flights() {
availableImages,
]);
- const showCombinedView = !isMobile && settings?.layout?.showCombinedView && !startTutorial;
+ const showCombinedView =
+ !isMobile && settings?.layout?.showCombinedView && !startTutorial;
const flightRowOpacity = settings?.layout?.flightRowOpacity ?? 100;
const getBackgroundStyle = (opacity: number) => {
if (opacity === 0) {
- return { backgroundColor: "transparent" };
+ return { backgroundColor: 'transparent' };
}
const alpha = opacity / 100;
return {
@@ -838,13 +946,19 @@ export default function Flights() {
...settings?.arrivalsTableColumns,
};
- const handleFieldEditingStart = (flightId: string | number, fieldName: string) => {
+ const handleFieldEditingStart = (
+ flightId: string | number,
+ fieldName: string
+ ) => {
if (sessionUsersSocket?.emitFieldEditingStart) {
sessionUsersSocket.emitFieldEditingStart(flightId, fieldName);
}
};
- const handleFieldEditingStop = (flightId: string | number, fieldName: string) => {
+ const handleFieldEditingStop = (
+ flightId: string | number,
+ fieldName: string
+ ) => {
if (sessionUsersSocket?.emitFieldEditingStop) {
sessionUsersSocket.emitFieldEditingStop(flightId, fieldName);
}
@@ -852,7 +966,7 @@ export default function Flights() {
useEffect(() => {
if (!session) return;
- posthog?.group("session", session.sessionId, {
+ posthog?.group('session', session.sessionId, {
airport_icao: session.airportIcao,
is_pfatc: session.isPFATC,
is_advanced_atc: Boolean(session.isAdvancedATC),
@@ -863,7 +977,7 @@ export default function Flights() {
}, [session, posthog]);
const handleJoyrideCallback = (data: CallBackProps) => {
- trackTutorialEvent("flights", data);
+ trackTutorialEvent('flights', data);
const { status } = data;
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
updateTutorialStatus(true);
@@ -882,9 +996,16 @@ export default function Flights() {
chartDragStart,
setChartDragStart,
containerRef as React.RefObject,
- imageSize,
+ imageSize
),
- [chartZoom, chartPan, isChartDragging, chartDragStart, imageSize.width, imageSize.height],
+ [
+ chartZoom,
+ chartPan,
+ isChartDragging,
+ chartDragStart,
+ imageSize.width,
+ imageSize.height,
+ ]
);
const {
@@ -911,7 +1032,13 @@ export default function Flights() {
}
if (accessError) {
- return ;
+ return (
+
+ );
}
const handleCloseAllSidebars = () => {
@@ -926,19 +1053,19 @@ export default function Flights() {
className="absolute inset-0 z-0"
style={{
backgroundImage,
- backgroundSize: "cover",
- backgroundPosition: "center",
- backgroundRepeat: "no-repeat",
- backgroundAttachment: "fixed",
+ backgroundSize: 'cover',
+ backgroundPosition: 'center',
+ backgroundRepeat: 'no-repeat',
+ backgroundAttachment: 'fixed',
opacity: 0.2,
- pointerEvents: "none",
+ pointerEvents: 'none',
}}
/>
{loading ? (
-
Loading {currentView}...
+
+ Loading {currentView}...
+
) : showCombinedView ? (
<>
-
+
-
+
) : (
<>
- {currentView === "departures" ? (
+ {currentView === 'departures' ? (
<>
);
-}
\ No newline at end of file
+}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 00e0a5f6..6427159c 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -5,31 +5,31 @@ import {
TowerControl,
Users,
Crown,
-} from "lucide-react";
-import { useState, useEffect, useMemo } from "react";
+} from 'lucide-react';
+import { useState, useEffect, useMemo } from 'react';
import {
fetchStatistics,
fetchLeaderboard,
fetchBackgrounds,
-} from "../utils/fetch/data";
-import { useSearchParams } from "react-router-dom";
-import { updateTutorialStatus } from "../utils/fetch/auth";
-import { useAuth } from "../hooks/auth/useAuth";
-import { useSettings } from "../hooks/settings/useSettings";
-import { steps } from "../components/tutorial/TutorialStepsHome";
+} from '../utils/fetch/data';
+import { useSearchParams } from 'react-router-dom';
+import { updateTutorialStatus } from '../utils/fetch/auth';
+import { useAuth } from '../hooks/auth/useAuth';
+import { useSettings } from '../hooks/settings/useSettings';
+import { steps } from '../components/tutorial/TutorialStepsHome';
import Joyride, {
type CallBackProps,
STATUS,
-} from "react-joyride-react19-compat";
-import { trackTutorialEvent } from "../utils/tutorialTracking";
-import { posthog } from "../utils/posthog";
-import Modal from "../components/common/Modal";
-import CustomTooltip from "../components/tutorial/CustomTooltip";
-import Footer from "../components/Footer";
-import Button from "../components/common/Button";
-import Navbar from "../components/Navbar";
-import ProductShowcase from "../components/home/ProductShowcase";
-import { useCountUp } from "../hooks/useCountUp";
+} from 'react-joyride-react19-compat';
+import { trackTutorialEvent } from '../utils/tutorialTracking';
+import { posthog } from '../utils/posthog';
+import Modal from '../components/common/Modal';
+import CustomTooltip from '../components/tutorial/CustomTooltip';
+import Footer from '../components/Footer';
+import Button from '../components/common/Button';
+import Navbar from '../components/Navbar';
+import ProductShowcase from '../components/home/ProductShowcase';
+import { useCountUp } from '../hooks/useCountUp';
const API_BASE_URL = import.meta.env.VITE_SERVER_URL;
@@ -64,7 +64,7 @@ export default function Home({ standalone = true }: HomeProps) {
const [customLoaded, setCustomLoaded] = useState(false);
const [searchParams] = useSearchParams();
- const startTutorial = searchParams.get("tutorial") === "true";
+ const startTutorial = searchParams.get('tutorial') === 'true';
const [showTutorialPrompt, setShowTutorialPrompt] = useState(false);
const { user } = useAuth();
const { settings } = useSettings();
@@ -74,32 +74,32 @@ export default function Home({ standalone = true }: HomeProps) {
const [flightsCount, flightsRef] = useCountUp(stats.flightsLogged);
const statTitles: Record
= {
- total_sessions_created: "Sessions Created",
- "total_flights_submitted.total": "Flights Submitted",
- total_time_controlling_minutes: "Time Controlling",
- "total_flight_edits.total_edit_actions": "Flight Edits",
+ total_sessions_created: 'Sessions Created',
+ 'total_flights_submitted.total': 'Flights Submitted',
+ total_time_controlling_minutes: 'Time Controlling',
+ 'total_flight_edits.total_edit_actions': 'Flight Edits',
};
useEffect(() => {
if (user && !user.settings?.tutorialCompleted && !startTutorial) {
setShowTutorialPrompt(true);
- posthog.capture("tutorial_prompt_shown");
+ posthog.capture('tutorial_prompt_shown');
}
}, [user, startTutorial]);
const handleTutorialChoice = (start: boolean) => {
setShowTutorialPrompt(false);
if (start) {
- posthog.capture("tutorial_prompt_accepted");
- window.location.href = "/?tutorial=true";
+ posthog.capture('tutorial_prompt_accepted');
+ window.location.href = '/?tutorial=true';
} else {
- posthog.capture("tutorial_prompt_declined");
+ posthog.capture('tutorial_prompt_declined');
updateTutorialStatus(true);
}
};
const handleJoyrideCallback = (data: CallBackProps) => {
- trackTutorialEvent("home", data);
+ trackTutorialEvent('home', data);
const { status } = data;
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
updateTutorialStatus(true);
@@ -128,7 +128,7 @@ export default function Home({ standalone = true }: HomeProps) {
const data = await fetchBackgrounds();
setAvailableImages(data);
} catch (error) {
- console.error("Error loading available images:", error);
+ console.error('Error loading available images:', error);
}
};
loadImages();
@@ -139,21 +139,21 @@ export default function Home({ standalone = true }: HomeProps) {
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 =
@@ -161,15 +161,15 @@ export default function Home({ standalone = true }: HomeProps) {
const favImageUrl = getImageUrl(randomFav);
if (
favImageUrl &&
- favImageUrl !== "random" &&
- favImageUrl !== "favorites"
+ 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})`;
}
}
@@ -189,7 +189,7 @@ export default function Home({ standalone = true }: HomeProps) {
const getDiscordAvatar = (userId: string, avatarHash: string | null) => {
if (!avatarHash) {
- return "/assets/app/default/avatar.webp";
+ return '/assets/app/default/avatar.webp';
}
return `https://cdn.discordapp.com/avatars/${userId}/${avatarHash}.png?size=256`;
};
@@ -203,11 +203,11 @@ export default function Home({ standalone = true }: HomeProps) {
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',
}}
/>
@@ -227,8 +227,8 @@ export default function Home({ standalone = true }: HomeProps) {
(window.location.href = startTutorial
- ? "/create?tutorial=true"
- : "/create")
+ ? '/create?tutorial=true'
+ : '/create')
}
variant="outline"
className="flex items-center justify-center px-8 py-4 text-base sm:text-lg font-semibold transition-all w-full sm:w-auto"
@@ -238,7 +238,7 @@ export default function Home({ standalone = true }: HomeProps) {
(window.location.href = "/pfatc")}
+ onClick={() => (window.location.href = '/pfatc')}
variant="ghost"
className="flex items-center justify-center px-8 py-4 text-base sm:text-lg font-semibold transition-all w-full sm:w-auto"
id="pfatc-flights-btn"
@@ -315,7 +315,7 @@ export default function Home({ standalone = true }: HomeProps) {
- {" "}
+ {' '}
{Object.entries(leaderboard)
.filter(([key]) => !/chat|message/i.test(key))
.map(([key, users]) => (
@@ -326,8 +326,8 @@ export default function Home({ standalone = true }: HomeProps) {
>
{statTitles[key] ||
key
- .replace(/total_|_/g, " ")
- .replace("submitted total", "Flights Submitted")
+ .replace(/total_|_/g, ' ')
+ .replace('submitted total', 'Flights Submitted')
.trim()}
{/* Podium — order: 2nd | 1st | 3rd */}
@@ -336,11 +336,11 @@ export default function Home({ standalone = true }: HomeProps) {
const u = users[rank];
if (!u) return null;
const podiumHeights = [80, 52, 36]; // 1st, 2nd, 3rd bar heights (px)
- const podiumColors = ["#fbbf24", "#c0c0c0", "#ad6823"];
+ const podiumColors = ['#fbbf24', '#c0c0c0', '#ad6823'];
const avatarSizes = [
- "w-24 h-24",
- "w-20 h-20",
- "w-16 h-16",
+ 'w-24 h-24',
+ 'w-20 h-20',
+ 'w-16 h-16',
];
const barHeight = podiumHeights[rank];
const barColor = podiumColors[rank];
@@ -364,7 +364,7 @@ export default function Home({ standalone = true }: HomeProps) {
style={{
color: barColor,
filter:
- "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
+ 'drop-shadow(0 1px 3px rgba(0,0,0,0.6))',
}}
/>
@@ -375,7 +375,7 @@ export default function Home({ standalone = true }: HomeProps) {
className="font-mono font-bold text-sm mb-2"
style={{ color: barColor }}
>
- {key === "total_time_controlling_minutes"
+ {key === 'total_time_controlling_minutes'
? (() => {
const mins = Math.floor(u.score);
if (mins >= 60) {
@@ -478,11 +478,11 @@ export default function Home({ standalone = true }: HomeProps) {
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',
}}
/>
@@ -517,8 +517,8 @@ export default function Home({ standalone = true }: HomeProps) {
{
window.location.href = startTutorial
- ? "/create?tutorial=true"
- : "/create";
+ ? '/create?tutorial=true'
+ : '/create';
}}
variant="outline"
className="px-8 py-4 text-base sm:text-lg font-semibold"
@@ -541,15 +541,15 @@ export default function Home({ standalone = true }: HomeProps) {
tooltipComponent={CustomTooltip}
styles={{
options: {
- primaryColor: "#3b82f6",
- textColor: "#ffffff",
- backgroundColor: "#1f2937",
+ primaryColor: '#3b82f6',
+ textColor: '#ffffff',
+ backgroundColor: '#1f2937',
zIndex: 1000,
},
spotlight: {
- border: "2px solid #fbbf24",
- borderRadius: "24px",
- boxShadow: "0 0 20px rgba(251, 191, 36, 0.5)",
+ border: '2px solid #fbbf24',
+ borderRadius: '24px',
+ boxShadow: '0 0 20px rgba(251, 191, 36, 0.5)',
},
}}
/>
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index 552443e0..c6ad07dc 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -142,7 +142,8 @@ export default function Login() {
- PFControl is an independent service and is not in any way affiliated with Project Flight.
+ PFControl is an independent service and is not in any way
+ affiliated with Project Flight.
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 */}
-
+
(
- {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)}>
-
setLightboxSrc(null)}>
+ setLightboxSrc(null)}
+ >
+
setLightboxSrc(null)}
+ >
-
e.stopPropagation()} />
+
e.stopPropagation()}
+ />
)}
{/* Hero */}
-
+
{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() {
- {copied ? <> Copied> : <> Share>}
+ {copied ? (
+ <>
+ Copied
+ >
+ ) : (
+ <>
+ Share
+ >
+ )}
-
+
Back to My Flights
@@ -498,8 +574,16 @@ export default function MyFlightDetail() {
{snapUploading ? 'Uploading…' : 'Add Photo'}
-
- {snapError &&
{snapError}
}
+
+ {snapError && (
+
{snapError}
+ )}
{snaps.length === 0 && !snapUploading ? (
snapInputRef.current?.click()}
@@ -511,7 +595,10 @@ export default function MyFlightDetail() {
) : (
{snaps.map((snap) => (
-
+
)}
- {snaps.length >= 12 &&
12/12 photos
}
+ {snaps.length >= 12 && (
+
+ 12/12 photos
+
+ )}
{/* Flight Details */}
-
{flight.departure || '----'}
+
+ {flight.departure || '----'}
+
-
{flight.arrival || '----'}
+
+ {flight.arrival || '----'}
+
{flight.route && (
<>
·
-
{flight.route}
+
+ {flight.route}
+
>
)}
{flight.route && (
-
+
-
+
@@ -580,12 +685,20 @@ export default function MyFlightDetail() {
Created:
- {flight.created_at ? new Date(flight.created_at).toLocaleString() : 'N/A'}
+
+ {flight.created_at
+ ? new Date(flight.created_at).toLocaleString()
+ : 'N/A'}
+
Updated:
- {flight.updated_at ? new Date(flight.updated_at).toLocaleString() : 'N/A'}
+
+ {flight.updated_at
+ ? new Date(flight.updated_at).toLocaleString()
+ : 'N/A'}
+
@@ -605,9 +718,13 @@ export default function MyFlightDetail() {
-
Flight Notes
+
+ Flight Notes
+
-
+
✓ saved
@@ -619,23 +736,30 @@ export default function MyFlightDetail() {
maxLength={2000}
className="w-full bg-gray-900/40 border border-gray-800 rounded-2xl p-4 text-sm text-gray-200 font-mono resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/30 placeholder-gray-600 transition-all"
/>
-
{notes.length}/2000
+
+ {notes.length}/2000
+
{/* Status Timeline */}
-
Status Timeline
+
+ Status Timeline
+
{statusTimeline.length === 0 ? (
logsDiscardedDueToAge ? (
- This flight is older than 90 days. Status/action logs were discarded by retention policy.
+ This flight is older than 90 days. Status/action logs were
+ discarded by retention policy.
) : (
-
No status-change logs available for this flight.
+
+ No status-change logs available for this flight.
+
)
) : (
@@ -643,7 +767,9 @@ export default function MyFlightDetail() {
{statusTimeline.map((item, index) => (
-
{item.label}
+
+ {item.label}
+
{new Date(item.at).toLocaleString()}
diff --git a/src/pages/MyFlights.tsx b/src/pages/MyFlights.tsx
index e9434b93..75c756a6 100644
--- a/src/pages/MyFlights.tsx
+++ b/src/pages/MyFlights.tsx
@@ -1,7 +1,23 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
-import { ArrowRight, Calendar, Check, ExternalLink, MoreVertical, Plane, Route, Search, Share2, Star, Workflow } from 'lucide-react';
-import { claimSubmittedFlight, fetchMyFlights, toggleFeaturedOnProfile } from '../utils/fetch/flights';
+import {
+ ArrowRight,
+ Calendar,
+ Check,
+ ExternalLink,
+ MoreVertical,
+ Plane,
+ Route,
+ Search,
+ Share2,
+ Star,
+ Workflow,
+} from 'lucide-react';
+import {
+ claimSubmittedFlight,
+ fetchMyFlights,
+ toggleFeaturedOnProfile,
+} from '../utils/fetch/flights';
import type { Flight } from '../types/flight';
import Navbar from '../components/Navbar';
import { useSettings } from '../hooks/settings/useSettings';
@@ -132,20 +148,32 @@ function FlightCard({
atCap
? 'text-zinc-600 cursor-not-allowed'
: featured
- ? 'text-amber-400 hover:bg-amber-600/20'
- : 'text-zinc-400 hover:bg-blue-800 hover:text-zinc-50'
+ ? 'text-amber-400 hover:bg-amber-600/20'
+ : 'text-zinc-400 hover:bg-blue-800 hover:text-zinc-50'
}`}
>
-
+
- {featured ? 'Unfeature' : atCap ? 'Max 3 featured' : 'Feature flight'}
+ {featured
+ ? 'Unfeature'
+ : atCap
+ ? 'Max 3 featured'
+ : 'Feature flight'}
-
+
Share flight
-
+
Open ACARS
@@ -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 && (
<>
- {copied ? : }
+ {copied ? (
+
+ ) : (
+
+ )}
{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 */}
-
+
@@ -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
+
({
label: icao,
value: icao,
@@ -1312,12 +1459,16 @@ export default function PFATCFlights() {
setNetworkSessionFilter(v as "all" | "pfatc" | "advanced_atc")}
+ onChange={(v) =>
+ setNetworkSessionFilter(
+ v as 'all' | 'pfatc' | 'advanced_atc'
+ )
+ }
placeholder="Session type"
size="md"
/>
@@ -1326,8 +1477,12 @@ export default function PFATCFlights() {
{allFlights.some((f) => f.hidden) && (
- setShowHidden((s) => !s)} variant="outline" size="sm">
- {showHidden ? "Hide Hidden Flights" : "Show Hidden Flights"}
+ setShowHidden((s) => !s)}
+ variant="outline"
+ size="sm"
+ >
+ {showHidden ? 'Hide Hidden Flights' : 'Show Hidden Flights'}
)}
@@ -1338,16 +1493,36 @@ export default function PFATCFlights() {
- Time
- Callsign
- Status
- Departure
- Arrival
- Aircraft
- RFL
- CFL
- SID
- STAR
+
+ Time
+
+
+ Callsign
+
+
+ Status
+
+
+ Departure
+
+
+ Arrival
+
+
+ Aircraft
+
+
+ RFL
+
+
+ CFL
+
+
+ SID
+
+
+ STAR
+
Remark
@@ -1361,10 +1536,16 @@ export default function PFATCFlights() {
{sortedFlights.length === 0 ? (
-
- {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'}
) : (
@@ -1376,26 +1557,36 @@ export default function PFATCFlights() {
{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 ? (
-