diff --git a/.env.example b/.env.example index 0207b84..acf5030 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ POSTGRES_DB_URL_FLIGHTS=postgresql:// POSTGRES_DB_URL_CHATS=postgresql:// POSTGRES_DB_PW= DB_ENCRYPTION_KEY= +REDIS_URL=redis:// DISCORD_CLIENT_ID= DISCORD_CLIENT_SECRET= diff --git a/.prettierrc b/.prettierrc index 9e7e2ac..1912e5a 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "singleQuote": true, - "tabWidth": 4, - "trailingComma": "es5" + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5" } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1099465..3430144 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# filepath: c:\Users\rcxga\OneDrive\Desktop\CODE\PFConnect\pfcontrol-2\Dockerfile # Multi-stage build for PFControl v2 FROM node:20-alpine AS builder @@ -16,7 +17,10 @@ COPY . . # Copy frontend env for Vite build COPY .env.vite.production .env.production -# Build the application +# Build the backend (TypeScript compilation) +RUN npm run build:server + +# Build the application (frontend) RUN npm run build # Production stage @@ -27,7 +31,7 @@ RUN apk add --no-cache dumb-init # Create app user for security RUN addgroup -g 1001 -S nodejs && \ - adduser -S nodeuser -u 1001 + adduser -S nodeuser -u 1001 # Set working directory WORKDIR /app @@ -41,7 +45,7 @@ RUN npm ci --omit=dev && npm cache clean --force # Copy built application from builder stage COPY --from=builder --chown=nodeuser:nodejs /app/dist ./dist COPY --from=builder --chown=nodeuser:nodejs /app/public ./public -COPY --from=builder --chown=nodeuser:nodejs /app/server ./server +COPY --from=builder --chown=nodeuser:nodejs /app/server/dist ./server/dist # Create logs directory RUN mkdir -p logs && chown nodeuser:nodejs logs @@ -54,8 +58,8 @@ EXPOSE 9900 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD node -e "require('http').get('http://localhost:9900/api/data/airports', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" + CMD node -e "require('http').get('http://localhost:9900/api/data/airports', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" # Start the application ENTRYPOINT ["dumb-init", "--"] -CMD ["node", "server/server.js"] \ No newline at end of file +CMD ["node", "server/dist/main.js"] \ No newline at end of file diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..6b8fff1 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["server"], + "ext": "ts,json", + "exec": "tsx server/main.ts" +} diff --git a/package-lock.json b/package-lock.json index 23e8637..5ad4552 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,9 @@ "form-data": "^4.0.4", "helmet": "^8.1.0", "https-proxy-agent": "^7.0.6", + "ioredis": "^5.8.1", "jsonwebtoken": "^9.0.2", + "kysely": "^0.28.8", "lucide-react": "^0.544.0", "multer": "^2.0.2", "node-fetch": "^3.3.2", @@ -44,6 +46,12 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@types/cookie-parser": "^1.4.9", + "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.0.0", + "@types/node": "^24.7.2", + "@types/pg": "^8.15.5", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.3", @@ -54,6 +62,8 @@ "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", "typescript-eslint": "^8.44.0", "vite": "^7.1.7" @@ -341,6 +351,30 @@ "node": ">=6.9.0" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1023,6 +1057,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", + "license": "MIT" + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1787,6 +1827,34 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1832,6 +1900,37 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -1910,6 +2009,38 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1917,15 +2048,76 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { - "version": "24.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", - "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "version": "24.7.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", + "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", "license": "MIT", "dependencies": { - "undici-types": "~7.12.0" + "undici-types": "~7.14.0" } }, + "node_modules/@types/pg": { + "version": "8.15.5", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", + "integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", @@ -1946,6 +2138,39 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", + "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", + "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2304,6 +2529,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2374,6 +2612,13 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2704,6 +2949,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2903,6 +3157,13 @@ "node": ">= 0.10" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -3118,6 +3379,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3137,6 +3407,16 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dnd-kit": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/dnd-kit/-/dnd-kit-0.0.2.tgz", @@ -4082,6 +4362,19 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.12.0.tgz", + "integrity": "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4318,6 +4611,30 @@ "node": ">=12" } }, + "node_modules/ioredis": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.1.tgz", + "integrity": "sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ip-address": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", @@ -4546,6 +4863,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kysely": { + "version": "0.28.8", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.8.tgz", + "integrity": "sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4804,12 +5130,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -4899,6 +5237,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/matchmediaquery": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.4.2.tgz", @@ -5947,6 +6292,27 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -5987,6 +6353,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -6514,6 +6890,12 @@ "node": ">= 10.x" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -6743,12 +7125,76 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6852,9 +7298,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", "license": "MIT" }, "node_modules/unpipe": { @@ -6923,6 +7369,13 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -7197,6 +7650,16 @@ "node": ">=12" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index ed5b3be..9f615f9 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,10 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "set NODE_ENV=development && concurrently \"vite\" \"nodemon server/server.js\"", - "prod": "cross-env NODE_ENV=production node server/server.js", - "build": "vite build --mode production", + "dev": "set NODE_ENV=development && concurrently \"vite\" \"npx nodemon\"", + "prod": "cross-env NODE_ENV=production node server/dist/main.js", + "build": "npm run build:server && vite build --mode production", + "build:server": "tsc --project server/tsconfig.json", "build:dev": "vite build --mode development", "build-only": "vite build", "lint": "eslint .", @@ -31,7 +32,9 @@ "form-data": "^4.0.4", "helmet": "^8.1.0", "https-proxy-agent": "^7.0.6", + "ioredis": "^5.8.1", "jsonwebtoken": "^9.0.2", + "kysely": "^0.28.8", "lucide-react": "^0.544.0", "multer": "^2.0.2", "node-fetch": "^3.3.2", @@ -51,6 +54,12 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@types/cookie-parser": "^1.4.9", + "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.0.0", + "@types/node": "^24.7.2", + "@types/pg": "^8.15.5", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.3", @@ -61,6 +70,8 @@ "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", "typescript-eslint": "^8.44.0", "vite": "^7.1.7" diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..a22c2fe --- /dev/null +++ b/server/README.md @@ -0,0 +1,2 @@ +I will convert everything tomorrow, please don't touch the backend if possible. +Also don't try to help me and then mess up... \ No newline at end of file diff --git a/server/db/admin.js b/server/db/admin.js deleted file mode 100644 index 9d81038..0000000 --- a/server/db/admin.js +++ /dev/null @@ -1,378 +0,0 @@ -import pool from './connections/connection.js'; -import flightsPool from './connections/flightsConnection.js'; -import { getAllSessions } from './sessions.js'; -import { cleanupOldStatistics } from './statistics.js'; -import { isAdmin, getAdminIds } from '../middleware/isAdmin.js'; -import { getActiveUsers } from '../websockets/sessionUsersWebsocket.js'; -import { decrypt } from '../tools/encryption.js'; - -export async function getDailyStatistics(days = 30) { - try { - await cleanupOldStatistics(); - - const result = await pool.query(` - SELECT - date, - COALESCE(logins_count, 0) as logins_count, - COALESCE(new_sessions_count, 0) as new_sessions_count, - COALESCE(new_flights_count, 0) as new_flights_count, - COALESCE(new_users_count, 0) as new_users_count - FROM daily_statistics - WHERE date >= CURRENT_DATE - INTERVAL '${days} days' - ORDER BY date ASC - `); - - if (result.rows.length === 0) { - await backfillStatistics(); - return getDailyStatistics(days); - } - - return result.rows; - } catch (error) { - console.error('Error fetching daily statistics:', error); - return []; - } -} - -export async function getTotalStatistics() { - try { - const directStats = await calculateDirectStatistics(); - - const dailyResult = await pool.query(` - SELECT - COALESCE(SUM(logins_count), 0) as total_logins, - COALESCE(SUM(new_sessions_count), 0) as total_sessions, - COALESCE(SUM(new_flights_count), 0) as total_flights, - COALESCE(SUM(new_users_count), 0) as total_users - FROM daily_statistics - `); - - const dailyStats = dailyResult.rows[0]; - - return { - total_logins: dailyStats.total_logins || 0, - total_sessions: directStats.total_sessions, - total_flights: directStats.total_flights, - total_users: directStats.total_users - }; - } catch (error) { - console.error('Error fetching total statistics:', error); - return { - total_logins: 0, - total_sessions: 0, - total_flights: 0, - total_users: 0 - }; - } -} - -async function calculateDirectStatistics() { - try { - const usersResult = await pool.query('SELECT COUNT(*) FROM users'); - const sessionsResult = await pool.query('SELECT COUNT(*) FROM sessions'); - - const sessions = await getAllSessions(); - let totalFlights = 0; - - for (const session of sessions) { - try { - const flightResult = await flightsPool.query( - `SELECT COUNT(*) FROM flights_${session.session_id}` - ); - totalFlights += parseInt(flightResult.rows[0].count, 10); - } catch (error) { - console.warn(`Could not count flights for session ${session.session_id}`); - } - } - - return { - total_logins: 0, - total_sessions: parseInt(sessionsResult.rows[0].count, 10), - total_flights: totalFlights, - total_users: parseInt(usersResult.rows[0].count, 10) - }; - } catch (error) { - console.error('Error calculating direct statistics:', error); - return { - total_logins: 0, - total_sessions: 0, - total_flights: 0, - total_users: 0 - }; - } -} - -async function backfillStatistics() { - try { - const directStats = await calculateDirectStatistics(); - - const today = new Date().toISOString().split('T')[0]; - - await pool.query(` - INSERT INTO daily_statistics ( - date, - logins_count, - new_sessions_count, - new_flights_count, - new_users_count - ) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (date) DO UPDATE SET - new_sessions_count = EXCLUDED.new_sessions_count, - new_flights_count = EXCLUDED.new_flights_count, - new_users_count = EXCLUDED.new_users_count, - updated_at = NOW() - `, [ - today, - 0, - directStats.total_sessions, - directStats.total_flights, - directStats.total_users - ]); - - console.log('Statistics backfilled successfully'); - } catch (error) { - console.error('Error backfilling statistics:', error); - } -} - -export async function getAllUsers(page = 1, limit = 50, search = '', filterAdmin = 'all') { - try { - const offset = (page - 1) * limit; - - let whereConditions = []; - let queryParams = []; - let paramIndex = 1; - - if (search && search.trim()) { - whereConditions.push(`(u.username ILIKE $${paramIndex} OR u.id = $${paramIndex + 1})`); - queryParams.push(`%${search.trim()}%`, search.trim()); - paramIndex += 2; - } - - if (filterAdmin === 'admin' || filterAdmin === 'non-admin') { - const adminIds = getAdminIds(); - if (adminIds.length > 0) { - const placeholders = adminIds.map((_, i) => `$${paramIndex + i}`).join(', '); - if (filterAdmin === 'admin') { - whereConditions.push(`u.id IN (${placeholders})`); - } else { - whereConditions.push(`u.id NOT IN (${placeholders})`); - } - queryParams.push(...adminIds); - paramIndex += adminIds.length; - } else if (filterAdmin === 'admin') { - return { - users: [], - pagination: { page, limit, total: 0, pages: 0 } - }; - } - } - - const whereClause = whereConditions.length > 0 - ? `WHERE ${whereConditions.join(' AND ')}` - : ''; - - const result = await pool.query(` - 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 - FROM users u - LEFT JOIN roles r ON u.role_id = r.id - ${whereClause} - ORDER BY u.last_login DESC NULLS LAST - LIMIT $${paramIndex} OFFSET $${paramIndex + 1} - `, [...queryParams, limit, offset]); - - const countQuery = whereConditions.length > 0 - ? `SELECT COUNT(*) FROM users u ${whereClause}` - : 'SELECT COUNT(*) FROM users u'; - - const countResult = await pool.query( - countQuery, - whereConditions.length > 0 ? queryParams : [] - ); - const totalUsers = parseInt(countResult.rows[0].count); - - const usersWithAdminStatus = result.rows.map(user => { - let decryptedSettings = null; - try { - if (user.settings) { - decryptedSettings = decrypt(JSON.parse(user.settings)); - } - } catch (error) { - console.warn(`Failed to decrypt settings for user ${user.id}`); - } - - let rolePermissions = null; - try { - if (user.role_permissions) { - if (typeof user.role_permissions === 'string') { - rolePermissions = JSON.parse(user.role_permissions); - } else if (typeof user.role_permissions === 'object') { - rolePermissions = user.role_permissions; - } - } - } catch (error) { - console.warn(`Failed to parse role permissions for user ${user.id}:`, error); - rolePermissions = null; - } - - let decryptedIP = null; - if (user.ip_address) { - try { - if (typeof user.ip_address === 'string' && user.ip_address.trim().startsWith('{')) { - const parsed = JSON.parse(user.ip_address); - decryptedIP = decrypt(parsed); - } else if (typeof user.ip_address === 'object' && user.ip_address.iv && user.ip_address.data && user.ip_address.authTag) { - decryptedIP = decrypt(user.ip_address); - } else { - decryptedIP = user.ip_address; - } - } catch (error) { - console.warn(`Failed to parse/decrypt ip_address for user ${user.id}:`, error.message); - decryptedIP = user.ip_address; - } - } - user.ip_address = decryptedIP; - - return { - ...user, - is_admin: isAdmin(user.id), - settings: decryptedSettings, - roleId: user.role_id, - roleName: user.role_name, - rolePermissions: rolePermissions - }; - }); - - return { - users: usersWithAdminStatus, - pagination: { - page, - limit, - total: totalUsers, - pages: Math.ceil(totalUsers / limit) - } - }; - } catch (error) { - console.error('Error fetching users:', error); - throw error; - } -} - -export async function getSystemInfo() { - try { - const dbStats = await pool.query(` - SELECT - schemaname, - tablename, - COALESCE(n_tup_ins, 0) as inserts, - COALESCE(n_tup_upd, 0) as updates, - COALESCE(n_tup_del, 0) as deletes - FROM pg_stat_user_tables - WHERE schemaname = 'public' - ORDER BY tablename - `); - - return { - database: dbStats.rows, - server: { - nodeVersion: process.version, - uptime: process.uptime(), - memoryUsage: process.memoryUsage(), - platform: process.platform - } - }; - } catch (error) { - console.error('Error fetching system info:', error); - throw error; - } -} - -export async function getAdminSessions() { - try { - const sessionsResult = await pool.query(` - SELECT - s.session_id, - s.access_id, - s.airport_icao, - s.active_runway, - (s.created_at AT TIME ZONE 'UTC') as created_at, - s.created_by, - s.is_pfatc, - u.username, - u.discriminator, - u.avatar - FROM sessions s - LEFT JOIN users u ON s.created_by = u.id - ORDER BY s.created_at DESC - `); - - const activeUsers = getActiveUsers(); - - const sessionsWithFlights = await Promise.all( - sessionsResult.rows.map(async (session) => { - try { - const flightResult = await flightsPool.query( - `SELECT COUNT(*) FROM flights_${session.session_id}` - ); - const activeSessionUsers = activeUsers.get(session.session_id) || []; - return { - ...session, - flight_count: parseInt(flightResult.rows[0].count, 10), - active_users: activeSessionUsers, - active_user_count: activeSessionUsers.length - }; - } catch (error) { - const activeSessionUsers = activeUsers.get(session.session_id) || []; - return { - ...session, - flight_count: 0, - active_users: activeSessionUsers, - active_user_count: activeSessionUsers.length - }; - } - }) - ); - - return sessionsWithFlights; - } catch (error) { - console.error('Error fetching admin sessions:', error); - throw error; - } -} - -export async function syncUserSessionCounts() { - try { - // Get all sessions grouped by user - const result = await pool.query(` - SELECT created_by, COUNT(*) as session_count - FROM sessions - GROUP BY created_by - `); - - // Update each user's total_sessions_created - for (const row of result.rows) { - await pool.query(` - UPDATE users - SET total_sessions_created = $2 - WHERE id = $1 - `, [row.created_by, parseInt(row.session_count, 10)]); - } - - // Set total_sessions_created to 0 for users with no sessions - await pool.query(` - UPDATE users - SET total_sessions_created = 0 - WHERE id NOT IN (SELECT DISTINCT created_by FROM sessions) - `); - - return { message: 'Session counts synced successfully', updatedUsers: result.rows.length }; - } catch (error) { - console.error('Error syncing user session counts:', error); - throw error; - } -} \ No newline at end of file diff --git a/server/db/admin.ts b/server/db/admin.ts new file mode 100644 index 0000000..07c21d9 --- /dev/null +++ b/server/db/admin.ts @@ -0,0 +1,396 @@ +import { mainDb, flightsDb } from "./connection.js"; +import { cleanupOldStatistics } from './statistics.js'; +import { getAllSessions } from './sessions.js'; +import { sql } from 'kysely'; +import { redisConnection } from './connection.js'; +import { decrypt } from '../utils/encryption.js'; +import { getAdminIds, isAdmin } from '../middleware/admin.js'; +import { getActiveUsers } from "../websockets/sessionUsersWebsocket.js"; + +async function calculateDirectStatistics() { + try { + const usersResult = await mainDb + .selectFrom('users') + .select(({ fn }) => fn.countAll().as('count')) + .executeTakeFirst(); + + const sessionsResult = await mainDb + .selectFrom('sessions') + .select(({ fn }) => fn.countAll().as('count')) + .executeTakeFirst(); + + const sessions = await getAllSessions(); + let totalFlights = 0; + + for (const session of sessions) { + try { + const tableName = `flights_${session.session_id}`; + const flightResult = await flightsDb + .selectFrom(tableName) + .select(({ fn }) => fn.countAll().as('count')) + .executeTakeFirst(); + totalFlights += Number(flightResult?.count) || 0; + } catch { + console.warn(`Could not count flights for session ${session.session_id}`); + } + } + + return { + total_logins: 0, + total_sessions: Number(sessionsResult?.count) || 0, + total_flights: totalFlights, + total_users: Number(usersResult?.count) || 0, + }; + } catch (error) { + console.error('Error calculating direct statistics:', error); + return { + total_logins: 0, + total_sessions: 0, + total_flights: 0, + total_users: 0, + }; + } +} + +async function backfillStatistics() { + try { + const directStats = await calculateDirectStatistics(); + + const today = new Date(); + + await mainDb + .insertInto('daily_statistics') + .values({ + id: sql`DEFAULT`, + date: today, + logins_count: 0, + new_sessions_count: directStats.total_sessions, + new_flights_count: directStats.total_flights, + new_users_count: directStats.total_users, + }) + .onConflict((oc) => + 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'), + }) + ) + .execute(); + + console.log('Statistics backfilled successfully'); + } catch (error) { + console.error('Error backfilling statistics:', error); + } +} + +export async function getDailyStatistics(days = 30) { + try { + await cleanupOldStatistics(); + + const result = await mainDb + .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'), + ]) + .where('date', '>=', new Date(Date.now() - days * 24 * 60 * 60 * 1000)) + .orderBy('date', 'asc') + .execute(); + + if (result.length === 0) { + await backfillStatistics(); + return getDailyStatistics(days); + } + + return result; + } catch (error) { + console.error('Error fetching daily statistics:', error); + return []; + } +} + +export async function getTotalStatistics() { + try { + const directStats = await calculateDirectStatistics(); + + const dailyStatsResult = await mainDb + .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'), + ]) + .executeTakeFirst(); + + return { + total_logins: Number(dailyStatsResult?.total_logins) || 0, + total_sessions: directStats.total_sessions, + total_flights: directStats.total_flights, + total_users: directStats.total_users, + }; + } catch (error) { + console.error('Error fetching total statistics:', error); + return { + total_logins: 0, + total_sessions: 0, + total_flights: 0, + total_users: 0, + }; + } +} + +export async function getAllUsers(page = 1, limit = 50, search = '', filterAdmin = 'all') { + try { + const offset = (page - 1) * limit; + const cacheKey = `allUsers:${page}:${limit}:${search}:${filterAdmin}`; + + // Check Redis cache first + const cached = await redisConnection.get(cacheKey); + let rawUsers = null; + let totalUsers = 0; + + if (cached) { + const parsed = JSON.parse(cached); + rawUsers = parsed.users; + totalUsers = parsed.total; + } else { + // Build query with Kysely + let query = mainDb + .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' + ]) + .orderBy('u.last_login', 'desc'); + + // Apply search filter + if (search && search.trim()) { + query = query.where((eb) => + eb.or([ + eb('u.username', 'ilike', `%${search.trim()}%`), + eb('u.id', '=', search.trim()) + ]) + ); + } + + // Apply admin filter + if (filterAdmin === 'admin' || filterAdmin === 'non-admin') { + const adminIds = getAdminIds(); + if (adminIds.length > 0) { + if (filterAdmin === 'admin') { + query = query.where('u.id', 'in', adminIds); + } else { + query = query.where('u.id', 'not in', adminIds); + } + } else if (filterAdmin === 'admin') { + return { + users: [], + pagination: { page, limit, total: 0, pages: 0 } + }; + } + } + + const countQuery = query.clearSelect().clearOrderBy().select(({ fn }) => fn.countAll().as('count')); + const countResult = await countQuery.executeTakeFirst(); + totalUsers = Number(countResult?.count) || 0; + + rawUsers = await query.limit(limit).offset(offset).execute(); + + await redisConnection.set(cacheKey, JSON.stringify({ users: rawUsers, total: totalUsers }), 'EX', 300); // 5 minutes + } + + interface RawUser { + id: string; + username: string; + discriminator: string; + avatar: string | null; + last_login: Date | null; + ip_address: string | object | null; + is_vpn: boolean; + total_sessions_created: number; + total_minutes: number; + created_at: Date; + settings: string | null; + roblox_username: string | null; + role_id: string | null; + role_name?: string | null; + role_permissions?: string | object | null; + [key: string]: unknown; + } + + const usersWithAdminStatus = (rawUsers as RawUser[]).map((user: RawUser) => { + let decryptedSettings = null; + try { + if (user.settings) { + decryptedSettings = decrypt(JSON.parse(user.settings)); + } + } catch { + console.warn(`Failed to decrypt settings for user ${user.id}`); + } + + let rolePermissions = null; + try { + if (user.role_permissions) { + if (typeof user.role_permissions === 'string') { + rolePermissions = JSON.parse(user.role_permissions); + } else if (typeof user.role_permissions === 'object') { + rolePermissions = user.role_permissions; + } + } + } catch (error) { + console.warn(`Failed to parse role permissions for user ${user.id}:`, error); + rolePermissions = null; + } + + let decryptedIP = null; + if (user.ip_address) { + try { + if (typeof user.ip_address === 'string' && user.ip_address.trim().startsWith('{')) { + const parsed = JSON.parse(user.ip_address); + decryptedIP = decrypt(parsed); + } else if ( + typeof user.ip_address === 'object' && + (user.ip_address as { iv?: string; data?: string; authTag?: string }).iv && + (user.ip_address as { iv?: string; data?: string; authTag?: string }).data && + (user.ip_address as { iv?: string; data?: string; authTag?: string }).authTag + ) { + decryptedIP = decrypt(user.ip_address as { iv: string; data: string; authTag: string }); + } else { + decryptedIP = user.ip_address; + } + } catch { + console.warn(`Failed to parse/decrypt ip_address for user ${user.id}`); + decryptedIP = user.ip_address; + } + } + user.ip_address = decryptedIP; + + return { + ...user, + is_admin: isAdmin(user.id), + settings: decryptedSettings, + roleId: user.role_id, + roleName: user.role_name, + rolePermissions: rolePermissions + }; + }); + + return { + users: usersWithAdminStatus, + pagination: { + page, + limit, + total: totalUsers, + pages: Math.ceil(totalUsers / limit) + } + }; + } catch (error) { + console.error('Error fetching users:', error); + throw error; + } +} + +export async function getAdminSessions() { + try { + // Get all sessions with user info + const sessions = await mainDb + .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', + 'u.username', + 'u.discriminator', + 'u.avatar' + ]) + .orderBy('s.created_at', 'desc') + .execute(); + + const activeUsers = getActiveUsers(); + + const sessionsWithFlights = await Promise.all( + sessions.map(async (session) => { + let flight_count = 0; + try { + const tableName = `flights_${session.session_id}`; + const flightResult = await flightsDb + .selectFrom(tableName) + .select(({ fn }) => fn.countAll().as('count')) + .executeTakeFirst(); + flight_count = Number(flightResult?.count) || 0; + } catch { + // Table may not exist, keep flight_count as 0 + } + const activeSessionUsers = activeUsers.get(session.session_id) || []; + return { + ...session, + flight_count, + active_users: activeSessionUsers, + active_user_count: activeSessionUsers.length + }; + }) + ); + + return sessionsWithFlights; + } catch (error) { + console.error('Error fetching admin sessions:', error); + throw error; + } +} + + +export async function syncUserSessionCounts() { + try { + const sessionCounts = await mainDb + .selectFrom('sessions') + .select([ + 'created_by', + mainDb.fn.countAll().as('session_count') + ]) + .groupBy('created_by') + .execute(); + + for (const row of sessionCounts) { + await mainDb + .updateTable('users') + .set({ total_sessions_created: Number(row.session_count) }) + .where('id', '=', row.created_by) + .execute(); + } + + await mainDb + .updateTable('users') + .set({ total_sessions_created: 0 }) + .where('id', 'not in', sessionCounts.map(r => r.created_by)) + .execute(); + + return { message: 'Session counts synced successfully', updatedUsers: sessionCounts.length }; + } catch (error) { + console.error('Error syncing user session counts:', error); + throw error; + } +} \ No newline at end of file diff --git a/server/db/audit.js b/server/db/audit.js deleted file mode 100644 index 1bd23ce..0000000 --- a/server/db/audit.js +++ /dev/null @@ -1,280 +0,0 @@ -import pool from './connections/connection.js'; -import { encrypt, decrypt } from '../tools/encryption.js'; - -async function initializeAuditTable() { - try { - const result = await pool.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'audit_log' - ) - `); - const exists = result.rows[0].exists; - - if (!exists) { - await pool.query(` - CREATE TABLE audit_log ( - id SERIAL PRIMARY KEY, - admin_id VARCHAR(20) NOT NULL, - admin_username VARCHAR(32) NOT NULL, - action_type VARCHAR(50) NOT NULL, - target_user_id VARCHAR(20), - target_username VARCHAR(32), - details JSONB, - ip_address TEXT, - user_agent TEXT, - timestamp TIMESTAMP DEFAULT NOW(), - created_at TIMESTAMP DEFAULT NOW() - ) - `); - - await pool.query(` - CREATE INDEX IF NOT EXISTS idx_audit_log_admin_id ON audit_log(admin_id); - CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp); - CREATE INDEX IF NOT EXISTS idx_audit_log_action_type ON audit_log(action_type); - CREATE INDEX IF NOT EXISTS idx_audit_log_target_user_id ON audit_log(target_user_id); - `); - } else { - const columns = await pool.query(` - SELECT column_name, data_type - FROM information_schema.columns - WHERE table_name = 'audit_log' - ORDER BY ordinal_position - `); - } - } catch (error) { - console.error('Error initializing audit log table:', error); - console.error('Error stack:', error.stack); - } -} - -export async function logAdminAction(actionData) { - const { - adminId, - adminUsername, - actionType, - targetUserId = null, - targetUsername = null, - details = {}, - ipAddress = null, - userAgent = null - } = actionData; - - try { - const encryptedIP = ipAddress ? encrypt(ipAddress) : null; - - const result = await pool.query(` - INSERT INTO audit_log ( - admin_id, admin_username, action_type, target_user_id, - target_username, details, ip_address, user_agent - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, timestamp - `, [ - adminId, - adminUsername, - actionType, - targetUserId, - targetUsername, - JSON.stringify(details), - encryptedIP ? JSON.stringify(encryptedIP) : null, - userAgent - ]); - - return result.rows[0].id; - } catch (error) { - console.error('Error logging admin action:', error); - throw error; - } -} - -export async function getAuditLogs(page = 1, limit = 50, filters = {}) { - try { - const offset = (page - 1) * limit; - let query = ` - SELECT - id, admin_id, admin_username, action_type, target_user_id, - target_username, details, ip_address, user_agent, timestamp - FROM audit_log - `; - - const conditions = []; - const values = []; - let paramCount = 1; - - if (filters.adminId) { - conditions.push(`(admin_id ILIKE $${paramCount} OR admin_username ILIKE $${paramCount})`); - values.push(`%${filters.adminId}%`); - paramCount++; - } - - if (filters.actionType) { - conditions.push(`action_type = $${paramCount++}`); - values.push(filters.actionType); - } - - if (filters.targetUserId) { - conditions.push(`(target_user_id ILIKE $${paramCount} OR target_username ILIKE $${paramCount})`); - values.push(`%${filters.targetUserId}%`); - paramCount++; - } - - if (filters.dateFrom) { - conditions.push(`timestamp >= $${paramCount++}`); - values.push(filters.dateFrom); - } - - if (filters.dateTo) { - conditions.push(`timestamp <= $${paramCount++}`); - values.push(filters.dateTo); - } - - if (conditions.length > 0) { - query += ' WHERE ' + conditions.join(' AND '); - } - - query += ` ORDER BY timestamp DESC LIMIT $${paramCount++} OFFSET $${paramCount++}`; - values.push(limit, offset); - - const result = await pool.query(query, values); - - const logs = result.rows.map(log => { - let decryptedIP = null; - if (log.ip_address) { - try { - const parsed = JSON.parse(log.ip_address); - decryptedIP = decrypt(parsed); - } catch (error) { - console.warn(`Failed to parse/decrypt ip_address for audit log ${log.id}:`, error.message); - decryptedIP = decrypt(log.ip_address); - } - } - return { - ...log, - ip_address: decryptedIP, - details: typeof log.details === 'string' ? JSON.parse(log.details) : log.details - }; - }); - - // Get total count for pagination - let countQuery = 'SELECT COUNT(*) FROM audit_log'; - const countValues = []; - - if (conditions.length > 0) { - countQuery += ' WHERE ' + conditions.join(' AND '); - // Add the same filter values except limit and offset - for (let i = 0; i < values.length - 2; i++) { - countValues.push(values[i]); - } - } - - const countResult = await pool.query(countQuery, countValues); - const totalLogs = parseInt(countResult.rows[0].count); - - return { - logs, - pagination: { - page, - limit, - total: totalLogs, - pages: Math.ceil(totalLogs / limit) - } - }; - } catch (error) { - console.error('Error fetching audit logs:', error); - console.error('Error stack:', error.stack); - throw error; - } -} - -export async function getAuditLogById(logId) { - try { - const result = await pool.query(` - SELECT - id, admin_id, admin_username, action_type, target_user_id, - target_username, details, ip_address, user_agent, timestamp - FROM audit_log - WHERE id = $1 - `, [logId]); - - if (result.rows.length === 0) { - return null; - } - - const log = result.rows[0]; - let decryptedIP = null; - if (log.ip_address) { - try { - const parsed = JSON.parse(log.ip_address); - decryptedIP = decrypt(parsed); - } catch (error) { - console.warn(`Failed to parse/decrypt ip_address for audit log ${logId}:`, error.message); - decryptedIP = decrypt(log.ip_address); - } - } - - return { - ...log, - ip_address: decryptedIP, - details: typeof log.details === 'string' ? JSON.parse(log.details) : log.details - }; - } catch (error) { - console.error('Error fetching audit log by ID:', error); - throw error; - } -} - -export async function cleanupOldAuditLogs(daysToKeep = 14) { - try { - const result = await pool.query(` - DELETE FROM audit_log - WHERE timestamp < NOW() - INTERVAL '${daysToKeep} days' - `); - - const deletedCount = result.rowCount; - return deletedCount; - } catch (error) { - console.error('Error cleaning up audit logs:', error); - throw error; - } -} - -// Automatic cleanup scheduler -let cleanupInterval = null; - -function startAutomaticCleanup() { - // Run cleanup every 12 hours (12 * 60 * 60 * 1000 ms) - const CLEANUP_INTERVAL = 12 * 60 * 60 * 1000; - - // Run initial cleanup after 1 minute - setTimeout(async () => { - try { - await cleanupOldAuditLogs(14); - } catch (error) { - console.error('Initial audit log cleanup failed:', error); - } - }, 60 * 1000); - - // Set up recurring cleanup - cleanupInterval = setInterval(async () => { - try { - await cleanupOldAuditLogs(14); - } catch (error) { - console.error('Scheduled audit log cleanup failed:', error); - } - }, CLEANUP_INTERVAL); -} - -function stopAutomaticCleanup() { - if (cleanupInterval) { - clearInterval(cleanupInterval); - cleanupInterval = null; - } -} - -// Initialize the table and start cleanup when the module is imported -initializeAuditTable().then(() => { - startAutomaticCleanup(); -}); - -export { initializeAuditTable, startAutomaticCleanup, stopAutomaticCleanup }; \ No newline at end of file diff --git a/server/db/audit.ts b/server/db/audit.ts new file mode 100644 index 0000000..f972d00 --- /dev/null +++ b/server/db/audit.ts @@ -0,0 +1,274 @@ +import { mainDb } from "./connection.js"; +import { encrypt, decrypt } from "../utils/encryption.js"; +import { sql } from "kysely"; + +export interface AdminActionData { + adminId: number | string; + adminUsername: string; + actionType: string; + targetUserId?: number | string | null; + targetUsername?: string | null; + details?: Record; + ipAddress?: string | null; + userAgent?: string | null; +} + +export async function logAdminAction(actionData: AdminActionData) { + const { + adminId, + adminUsername, + actionType, + targetUserId = null, + targetUsername = null, + details = {}, + ipAddress = null, + userAgent = null + } = actionData; + + try { + const encryptedIP = ipAddress ? encrypt(ipAddress) : null; + + const result = await mainDb + .insertInto('audit_log') + .values({ + id: sql`DEFAULT`, + admin_id: String(adminId), + admin_username: adminUsername, + action_type: actionType, + target_user_id: targetUserId !== null && targetUserId !== undefined ? String(targetUserId) : undefined, + target_username: targetUsername !== null && targetUsername !== undefined ? targetUsername : undefined, + details: details as object, + ip_address: encryptedIP ? JSON.stringify(encryptedIP) : undefined, + user_agent: userAgent !== null && userAgent !== undefined ? userAgent : undefined + }) + .returning(['id', 'timestamp']) + .executeTakeFirst(); + + return result?.id; + } catch (error) { + console.error('Error logging admin action:', error); + throw error; + } +} + +interface AuditLogFilters { + adminId?: string; + actionType?: string; + targetUserId?: string; + dateFrom?: string; + dateTo?: string; +} + +export async function getAuditLogs( + page = 1, + limit = 50, + filters: AuditLogFilters = {} +) { + try { + const offset = (page - 1) * limit; + + let query = mainDb + .selectFrom('audit_log') + .select([ + 'id', + 'admin_id', + 'admin_username', + 'action_type', + 'target_user_id', + 'target_username', + 'details', + 'ip_address', + 'user_agent', + 'timestamp' + ]); + + if (filters.adminId) { + query = query.where(q => + q.or([ + q('admin_id', 'ilike', `%${filters.adminId}%`), + q('admin_username', 'ilike', `%${filters.adminId}%`) + ]) + ); + } + + if (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}%`) + ]) + ); + } + + if (filters.dateFrom) { + query = query.where('timestamp', '>=', new Date(filters.dateFrom)); + } + + if (filters.dateTo) { + query = query.where('timestamp', '<=', new Date(filters.dateTo)); + } + + const logsResult = await query + .orderBy('timestamp', 'desc') + .limit(limit) + .offset(offset) + .execute(); + + const logs = logsResult.map(log => { + let decryptedIP = null; + if (log.ip_address) { + try { + const parsed = JSON.parse(log.ip_address as string); + decryptedIP = decrypt(parsed); + } catch { + try { + decryptedIP = decrypt(JSON.parse(log.ip_address as string)); + } catch { + decryptedIP = null; + } + } + } + return { + ...log, + ip_address: decryptedIP, + details: typeof log.details === 'string' ? JSON.parse(log.details) : log.details + }; + }); + + // Count query for pagination + let countQuery = mainDb.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}%`) + ]) + ); + } + if (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}%`) + ]) + ); + } + if (filters.dateFrom) { + countQuery = countQuery.where('timestamp', '>=', new Date(filters.dateFrom)); + } + if (filters.dateTo) { + countQuery = countQuery.where('timestamp', '<=', new Date(filters.dateTo)); + } + + const countResult = await countQuery.executeTakeFirst(); + const totalLogs = Number(countResult?.count ?? 0); + + return { + logs, + pagination: { + page, + limit, + total: totalLogs, + pages: Math.ceil(totalLogs / limit) + } + }; + } catch (error) { + console.error('Error fetching audit logs:', error); + throw error; + } +} + +export async function getAuditLogById(logId: number | string) { + try { + const result = await mainDb + .selectFrom('audit_log') + .select([ + 'id', + 'admin_id', + 'admin_username', + 'action_type', + 'target_user_id', + 'target_username', + 'details', + 'ip_address', + 'user_agent', + 'timestamp' + ]) + .where('id', '=', typeof logId === 'string' ? Number(logId) : logId) + .executeTakeFirst(); + + if (!result) { + return null; + } + + let decryptedIP = null; + if (result.ip_address) { + try { + const parsed = JSON.parse(result.ip_address as string); + decryptedIP = decrypt(parsed); + } catch { + try { + decryptedIP = decrypt(JSON.parse(result.ip_address as string)); + } catch { + decryptedIP = null; + } + } + } + + return { + ...result, + ip_address: decryptedIP, + details: typeof result.details === 'string' ? JSON.parse(result.details) : result.details + }; + } catch (error) { + console.error('Error fetching audit log by ID:', error); + throw error; + } +} + +export async function cleanupOldAuditLogs(daysToKeep = 14) { + try { + const result = await mainDb + .deleteFrom('audit_log') + .where('timestamp', '<', new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000)) + .executeTakeFirst(); + + return Number(result?.numDeletedRows ?? 0); + } catch (error) { + console.error('Error cleaning up audit logs:', error); + throw error; + } +} + +let cleanupInterval: NodeJS.Timeout | null = null; + +function startAutomaticCleanup() { + const CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; + + // Run initial cleanup after 1 minute + setTimeout(async () => { + try { + await cleanupOldAuditLogs(14); + } catch (error) { + console.error('Initial audit log cleanup failed:', error); + } + }, 60 * 1000); + + // Set up recurring cleanup + cleanupInterval = setInterval(async () => { + try { + await cleanupOldAuditLogs(14); + } catch (error) { + console.error('Scheduled audit log cleanup failed:', error); + } + }, CLEANUP_INTERVAL); +} + +startAutomaticCleanup(); \ No newline at end of file diff --git a/server/db/ban.js b/server/db/ban.js deleted file mode 100644 index ed0ca26..0000000 --- a/server/db/ban.js +++ /dev/null @@ -1,86 +0,0 @@ -import pool from './connections/connection.js'; - -export async function initializeBanTable() { - try { - const result = await pool.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'bans' - ) - `); - if (!result.rows[0].exists) { - await pool.query(` - CREATE TABLE bans ( - id SERIAL PRIMARY KEY, - user_id VARCHAR(20), -- Nullable for IP bans - ip_address VARCHAR(45), -- New: Support IPv4/IPv6 - username VARCHAR(32), - reason TEXT, - banned_by VARCHAR(20) NOT NULL, - banned_at TIMESTAMP DEFAULT NOW(), - expires_at TIMESTAMP, - active BOOLEAN DEFAULT true - ) - `); - await pool.query(`CREATE INDEX IF NOT EXISTS idx_bans_user_id ON bans(user_id)`); - await pool.query(`CREATE INDEX IF NOT EXISTS idx_bans_ip_address ON bans(ip_address)`); - } - } catch (error) { - console.error('Error initializing bans table:', error); - } -} - -export async function banUser({ userId, ip, username, reason, bannedBy, expiresAt }) { - if (!userId && !ip) { - throw new Error('Either userId or ip must be provided'); - } - const expiresAtValue = expiresAt === '' ? null : expiresAt; - await pool.query( - `INSERT INTO bans (user_id, ip_address, username, reason, banned_by, expires_at, active) - VALUES ($1, $2, $3, $4, $5, $6, true)`, - [userId || null, ip || null, username, reason, bannedBy, expiresAtValue] - ); -} - -export async function unbanUser(userIdOrIp) { - await pool.query( - `UPDATE bans SET active = false WHERE (user_id = $1 OR ip_address = $1) AND active = true`, - [userIdOrIp] - ); -} - -export async function isUserBanned(userId) { - const res = await pool.query( - `SELECT * FROM bans WHERE user_id = $1 AND active = true AND (expires_at IS NULL OR expires_at > NOW())`, - [userId] - ); - return res.rows.length > 0 ? res.rows[0] : null; -} - -export async function isIpBanned(ip) { - const res = await pool.query( - `SELECT * FROM bans WHERE ip_address = $1 AND active = true AND (expires_at IS NULL OR expires_at > NOW())`, - [ip] - ); - return res.rows.length > 0 ? res.rows[0] : null; -} - -export async function getAllBans(page = 1, limit = 50) { - const offset = (page - 1) * limit; - const res = await pool.query( - `SELECT * FROM bans ORDER BY banned_at DESC LIMIT $1 OFFSET $2`, - [limit, offset] - ); - const countRes = await pool.query('SELECT COUNT(*) FROM bans'); - return { - bans: res.rows, - pagination: { - page, - limit, - total: parseInt(countRes.rows[0].count), - pages: Math.ceil(countRes.rows[0].count / limit) - } - }; -} - -initializeBanTable(); \ No newline at end of file diff --git a/server/db/ban.ts b/server/db/ban.ts new file mode 100644 index 0000000..49a5b8d --- /dev/null +++ b/server/db/ban.ts @@ -0,0 +1,112 @@ +import { mainDb } from "./connection.js"; +import { sql } from "kysely"; + +export async function banUser({ + userId, + ip, + username, + reason, + bannedBy, + expiresAt, +}: { + userId?: string; + ip?: string; + username: string; + reason: string; + bannedBy: string; + expiresAt?: string; +}): Promise { + if (!userId && !ip) { + throw new Error("Either userId or ip must be provided"); + } + const expiresAtValue = expiresAt === "" ? undefined : expiresAt; + + await mainDb + .insertInto("bans") + .values({ + id: sql`DEFAULT`, + user_id: userId || undefined, + ip_address: ip || undefined, + username, + reason, + banned_by: bannedBy, + expires_at: expiresAtValue as Date | undefined, + active: true, + }) + .execute(); +} + +export async function unbanUser(userIdOrIp: string): Promise { + await mainDb + .updateTable("bans") + .set({ active: false }) + .where(({ or, eb }) => + or([ + eb("user_id", "=", userIdOrIp), + eb("ip_address", "=", userIdOrIp), + ]) + ) + .where("active", "=", true) + .execute(); +} + +export async function isUserBanned(userId: string) { + const result = await mainDb + .selectFrom("bans") + .selectAll() + .where("user_id", "=", userId) + .where("active", "=", true) + .where(({ or, eb }) => + or([ + eb("expires_at", "is", null), + eb("expires_at", ">", new Date()), + ]) + ) + .executeTakeFirst(); + return result ?? null; +} + +export async function isIpBanned(ip: string) { + const result = await mainDb + .selectFrom("bans") + .selectAll() + .where("ip_address", "=", ip) + .where("active", "=", true) + .where(({ or, eb }) => + or([ + eb("expires_at", "is", null), + eb("expires_at", ">", new Date()), + ]) + ) + .executeTakeFirst(); + return result ?? null; +} + +export async function getAllBans(page = 1, limit = 50) { + const offset = (page - 1) * limit; + + const bans = await mainDb + .selectFrom("bans") + .selectAll() + .orderBy("banned_at", "desc") + .limit(limit) + .offset(offset) + .execute(); + + const [{ count }] = await mainDb + .selectFrom("bans") + .select(({ fn }) => [fn.countAll().as("count")]) + .execute(); + + const total = Number(count); + + return { + bans, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit), + }, + }; +} \ No newline at end of file diff --git a/server/db/chats.js b/server/db/chats.js deleted file mode 100644 index 57b9dee..0000000 --- a/server/db/chats.js +++ /dev/null @@ -1,69 +0,0 @@ -import chatsPool from './connections/chatsConnection.js'; -import { encrypt, decrypt } from '../tools/encryption.js'; -import { validateSessionId } from '../utils/validation.js'; - -export async function ensureChatTable(sessionId) { - const validSessionId = validateSessionId(sessionId); - await chatsPool.query(` - CREATE TABLE IF NOT EXISTS chat_${validSessionId} ( - id SERIAL PRIMARY KEY, - user_id VARCHAR(20) NOT NULL, - username VARCHAR(32), - avatar VARCHAR(128), - message TEXT NOT NULL, - mentions TEXT[], - sent_at TIMESTAMP DEFAULT NOW() - ) - `); -} - -export async function addChatMessage(sessionId, { userId, username, avatar, message, mentions = [] }) { - const validSessionId = validateSessionId(sessionId); - await ensureChatTable(validSessionId); - const encryptedMsg = encrypt(message); - const result = await chatsPool.query( - `INSERT INTO chat_${validSessionId} (user_id, username, avatar, message, mentions) VALUES ($1, $2, $3, $4, $5) RETURNING *`, - [String(userId), username, avatar, encryptedMsg, mentions] - ); - return { ...result.rows[0], message, mentions }; -} - -export async function getChatMessages(sessionId, limit = 50) { - const validSessionId = validateSessionId(sessionId); - await ensureChatTable(validSessionId); - const result = await chatsPool.query( - `SELECT * FROM chat_${validSessionId} ORDER BY sent_at DESC LIMIT $1`, - [limit] - ); - return result.rows - .map(row => { - let decryptedMsg = ''; - try { - if (row.message) { - decryptedMsg = decrypt(JSON.parse(row.message)); - } - } catch (e) { - decryptedMsg = ''; - } - return { - id: row.id, - userId: row.user_id, - username: row.username, - avatar: row.avatar, - message: decryptedMsg || '', - mentions: row.mentions || [], - sent_at: row.sent_at - }; - }) - .reverse(); -} - -export async function deleteChatMessage(sessionId, messageId, userId) { - const validSessionId = validateSessionId(sessionId); - await ensureChatTable(validSessionId); - const result = await chatsPool.query( - `DELETE FROM chat_${validSessionId} WHERE id = $1 AND user_id = $2 RETURNING *`, - [messageId, userId] - ); - return result.rowCount > 0; -} \ No newline at end of file diff --git a/server/db/chats.ts b/server/db/chats.ts new file mode 100644 index 0000000..3e85609 --- /dev/null +++ b/server/db/chats.ts @@ -0,0 +1,90 @@ +import { chatsDb } from "./connection.js"; +import { encrypt, decrypt } from "../utils/encryption.js"; +import { validateSessionId } from "../utils/validation.js"; +import { sql } from "kysely"; + +export async function ensureChatTable(sessionId: string) { + const validSessionId = validateSessionId(sessionId); + const tableName = `chat_${validSessionId}`; + await chatsDb.schema + .createTable(tableName) + .ifNotExists() + .addColumn("id", "serial", col => col.primaryKey()) + .addColumn("user_id", "varchar(20)", col => col.notNull()) + .addColumn("username", "varchar(32)") + .addColumn("avatar", "varchar(128)") + .addColumn("message", "text", col => col.notNull()) + .addColumn("mentions", "jsonb") + .addColumn("sent_at", "timestamp", col => col.defaultTo('CURRENT_TIMESTAMP')) + .execute(); +} + +export async function addChatMessage(sessionId: string, { userId, username, avatar, message, mentions = [] }: { userId: string, username: string, avatar: string, message: string, mentions?: string[] }) { + const validSessionId = validateSessionId(sessionId); + await ensureChatTable(validSessionId); + const encryptedMsg = encrypt(message); + if (!encryptedMsg) { + throw new Error("Encryption failed for chat message."); + } + + const tableName = `chat_${validSessionId}`; + const result = await chatsDb + .insertInto(tableName) + .values({ + id: sql`DEFAULT`, + user_id: String(userId), + username, + avatar, + message: JSON.stringify(encryptedMsg), + mentions + }) + .returningAll() + .executeTakeFirst(); + + return { ...result, message, mentions }; +} + +export async function getChatMessages(sessionId: string, limit = 50) { + const validSessionId = validateSessionId(sessionId); + await ensureChatTable(validSessionId); + const tableName = `chat_${validSessionId}`; + const rows = await chatsDb + .selectFrom(tableName) + .selectAll() + .orderBy('sent_at', 'desc') + .limit(limit) + .execute(); + + return rows + .map(row => { + let decryptedMsg = ''; + try { + if (row.message) { + decryptedMsg = decrypt(JSON.parse(row.message)); + } + } catch { + decryptedMsg = ''; + } + return { + id: row.id, + userId: row.user_id, + username: row.username, + avatar: row.avatar, + message: decryptedMsg || '', + mentions: row.mentions || [], + sent_at: row.sent_at + }; + }) + .reverse(); +} +export async function deleteChatMessage(sessionId: string, messageId: number, userId: string) { + const validSessionId = validateSessionId(sessionId); + await ensureChatTable(validSessionId); + const tableName = `chat_${validSessionId}`; + const result = await chatsDb + .deleteFrom(tableName) + .where('id', '=', messageId) + .where('user_id', '=', userId) + .executeTakeFirst(); + return (result?.numDeletedRows ?? 0) > 0; +} \ No newline at end of file diff --git a/server/db/connection.ts b/server/db/connection.ts new file mode 100644 index 0000000..3f15134 --- /dev/null +++ b/server/db/connection.ts @@ -0,0 +1,48 @@ +import { Kysely, PostgresDialect } from 'kysely'; +import { createMainTables } 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' : '.env.development' }); + +import type { MainDatabase } from './types/connection/MainDatabase'; +import type { FlightsDatabase } from './types/connection/FlightsDatabase'; +import type { ChatsDatabase } from './types/connection/ChatsDatabase'; + +export const mainDb = new Kysely({ + dialect: new PostgresDialect({ + pool: new pg.Pool({ + connectionString: process.env.POSTGRES_DB_URL, + ssl: { rejectUnauthorized: false } + }) + }) +}); + +export const flightsDb = new Kysely({ + dialect: new PostgresDialect({ + pool: new pg.Pool({ + connectionString: process.env.POSTGRES_DB_URL_FLIGHTS, + ssl: { rejectUnauthorized: false } + }) + }) +}); + +export const chatsDb = new Kysely({ + dialect: new PostgresDialect({ + pool: new pg.Pool({ + connectionString: process.env.POSTGRES_DB_URL_CHATS, + ssl: { rejectUnauthorized: false } + }) + }) +}); + +if (!process.env.REDIS_URL) { + throw new Error('REDIS_URL is not defined in environment variables'); +} +export const redisConnection = new Redis(process.env.REDIS_URL as string); + +createMainTables().catch((err) => { + console.error('Failed to create main tables:', err); + process.exit(1); +}); \ No newline at end of file diff --git a/server/db/connections/chatsConnection.js b/server/db/connections/chatsConnection.js deleted file mode 100644 index d732b0e..0000000 --- a/server/db/connections/chatsConnection.js +++ /dev/null @@ -1,27 +0,0 @@ -import pg from 'pg'; -import dotenv from 'dotenv'; -import chalk from 'chalk'; - -const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development'; -dotenv.config({ path: envFile }); - -const { Pool } = pg; - -const chatsPool = new Pool({ - connectionString: process.env.POSTGRES_DB_URL_CHATS, - ssl: { - rejectUnauthorized: false, - require: true - } -}); - -(async () => { - try { - await chatsPool.query('SELECT 1'); - console.log(chalk.blue('Chats DB connected')); - } catch (err) { - console.error('Error connecting to Chats DB:', err); - } -})(); - -export default chatsPool; \ No newline at end of file diff --git a/server/db/connections/connection.js b/server/db/connections/connection.js deleted file mode 100644 index d1f2545..0000000 --- a/server/db/connections/connection.js +++ /dev/null @@ -1,27 +0,0 @@ -import pg from 'pg'; -import dotenv from 'dotenv'; -import chalk from 'chalk'; - -const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development'; -dotenv.config({ path: envFile }); - -const { Pool } = pg; - -const pool = new Pool({ - connectionString: process.env.POSTGRES_DB_URL, - ssl: { - rejectUnauthorized: false, - require: true - } -}); - -(async () => { - try { - await pool.query('SELECT 1'); - console.log(chalk.blue('PFControl DB connected')); - } catch (err) { - console.error('Error connecting to PFControl DB:', err); - } -})(); - -export default pool; \ No newline at end of file diff --git a/server/db/connections/flightsConnection.js b/server/db/connections/flightsConnection.js deleted file mode 100644 index 6ec5958..0000000 --- a/server/db/connections/flightsConnection.js +++ /dev/null @@ -1,27 +0,0 @@ -import pg from 'pg'; -import dotenv from 'dotenv'; -import chalk from 'chalk'; - -const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development'; -dotenv.config({ path: envFile }); - -const { Pool } = pg; - -const flightsPool = new Pool({ - connectionString: process.env.POSTGRES_DB_URL_FLIGHTS, - ssl: { - rejectUnauthorized: false, - require: true - } -}); - -(async () => { - try { - await flightsPool.query('SELECT 1'); - console.log(chalk.blue('Flights DB connected')); - } catch (err) { - console.error('Error connecting to Flights DB:', err); - } -})(); - -export default flightsPool; \ No newline at end of file diff --git a/server/db/flights.js b/server/db/flights.js deleted file mode 100644 index 61e2928..0000000 --- a/server/db/flights.js +++ /dev/null @@ -1,336 +0,0 @@ -import { - generateSID, - generateSquawk, - getWakeTurbulence, - generateRandomId, -} from '../utils/flightUtils.js'; -import { getSessionById } from './sessions.js'; -import flightsPool from './connections/flightsConnection.js'; -import pool from './connections/connection.js'; -import crypto from 'crypto'; -import { validateSessionId, validateFlightId } from '../utils/validation.js'; - -function sanitizeFlightForClient(flight) { - const { user_id, ip_address, acars_token, ...sanitizedFlight } = flight; - return { - ...sanitizedFlight, - cruisingFL: flight.cruisingfl, - clearedFL: flight.clearedfl, - }; -} - -export async function getFlightsBySession(sessionId) { - const validSessionId = validateSessionId(sessionId); - const tableName = `flights_${validSessionId}`; - - try { - const flightsResult = await flightsPool.query( - `SELECT * FROM ${tableName} ORDER BY created_at ASC` - ); - - const flights = flightsResult.rows; - const userIds = [...new Set(flights.map(f => f.user_id).filter(Boolean))]; - - let usersMap = new Map(); - if (userIds.length > 0) { - try { - const usersResult = await pool.query( - `SELECT id, username as discord_username, avatar as discord_avatar_url - FROM users - WHERE id = ANY($1)`, - [userIds] - ); - - usersResult.rows.forEach(user => { - usersMap.set(user.id, { - discord_username: user.discord_username, - discord_avatar_url: user.discord_avatar_url - ? `https://cdn.discordapp.com/avatars/${user.id}/${user.discord_avatar_url}.png` - : null - }); - }); - } catch (userError) { - console.error('Error fetching user data:', userError); - } - } - const enrichedFlights = flights.map(flight => { - const sanitized = sanitizeFlightForClient(flight); - - if (flight.user_id && usersMap.has(flight.user_id)) { - sanitized.user = usersMap.get(flight.user_id); - } - - return sanitized; - }); - - return enrichedFlights; - } catch (error) { - console.error('Error fetching flights:', error); - // Fallback to basic query without user data - const result = await flightsPool.query( - `SELECT * FROM ${tableName} ORDER BY created_at ASC` - ); - return result.rows.map((flight) => sanitizeFlightForClient(flight)); - } -} - -export async function validateAcarsAccess(sessionId, flightId, acarsToken) { - try { - const validSessionId = validateSessionId(sessionId); - const validFlightId = validateFlightId(flightId); - const tableName = `flights_${validSessionId}`; - const result = await flightsPool.query( - `SELECT acars_token FROM ${tableName} WHERE id = $1`, - [validFlightId] - ); - - if (result.rows.length === 0) { - return { valid: false }; - } - - const isValid = result.rows[0].acars_token === acarsToken; - - if (!isValid) { - return { valid: false }; - } - - const session = await getSessionById(sessionId); - - return { - valid: true, - accessId: session?.access_id || null - }; - } catch (error) { - console.error('Error validating ACARS access:', error); - return { valid: false }; - } -} - -export async function getFlightsBySessionWithTime(sessionId, hoursBack = 2) { - try { - const validSessionId = validateSessionId(sessionId); - const tableName = `flights_${validSessionId}`; - - const tableExists = await flightsPool.query( - ` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = $1 - ) - `, - [tableName] - ); - - if (!tableExists.rows[0].exists) { - return []; - } - - const result = await flightsPool.query( - `SELECT * FROM ${tableName} - WHERE created_at >= NOW() - INTERVAL '${hoursBack} hours' - ORDER BY created_at ASC` - ); - - const flights = result.rows.map((flight) => - sanitizeFlightForClient(flight) - ); - return flights; - } catch (error) { - console.error( - `Error fetching flights for session ${sessionId}:`, - error - ); - return []; - } -} - -function validateFlightFields(updates) { - if (updates.callsign && updates.callsign.length > 16) { - throw new Error('Callsign must be 16 characters or less'); - } - if (updates.stand && updates.stand.length > 8) { - throw new Error('Stand must be 8 characters or less'); - } - if (updates.squawk) { - if (updates.squawk.length > 4 || !/^\d{1,4}$/.test(updates.squawk)) { - throw new Error('Squawk must be up to 4 numeric digits'); - } - } - if (updates.remark && updates.remark.length > 50) { - throw new Error('Remark must be 50 characters or less'); - } - if (updates.cruisingFL !== undefined) { - const fl = parseInt(updates.cruisingFL, 10); - if (isNaN(fl) || fl < 0 || fl > 200 || fl % 5 !== 0) { - throw new Error( - 'Cruising FL must be between 0 and 200 in 50-step increments' - ); - } - } - if (updates.clearedFL !== undefined) { - const fl = parseInt(updates.clearedFL, 10); - if (isNaN(fl) || fl < 0 || fl > 200 || fl % 5 !== 0) { - throw new Error( - 'Cleared FL must be between 0 and 200 in 50-step increments' - ); - } - } -} - -export async function addFlight(sessionId, flightData) { - const validSessionId = validateSessionId(sessionId); - const tableName = `flights_${validSessionId}`; - try { - await flightsPool.query( - `ALTER TABLE ${tableName} ADD COLUMN IF NOT EXISTS gate VARCHAR(8);` - ); - } catch (error) { - // Column might already exist, continue - } - - const fields = ['session_id']; - const values = [validSessionId]; - const placeholders = ['$1']; - let idx = 2; - - flightData.id = await generateRandomId(); - flightData.squawk = await generateSquawk(flightData); - flightData.wtc = await getWakeTurbulence(flightData.aircraft_type); - if (!flightData.timestamp) { - flightData.timestamp = new Date().toISOString(); - } - flightData.acars_token = crypto.randomBytes(4).toString('hex'); - - if (flightData.aircraft_type) { - flightData.aircraft = flightData.aircraft_type; - delete flightData.aircraft_type; - } - - flightData.icao = flightData.departure; - - if (!flightData.runway) { - try { - const session = await getSessionById(validSessionId); - if (session && session.active_runway) { - flightData.runway = session.active_runway; - } - } catch (error) { - console.error( - 'Error fetching session for runway assignment:', - error - ); - } - } - - if (!flightData.sid) { - const sidResult = await generateSID(flightData); - flightData.sid = sidResult.sid; - } - - if (flightData.cruisingFL) { - flightData.cruisingfl = flightData.cruisingFL; - delete flightData.cruisingFL; - } - if (flightData.clearedFL) { - flightData.clearedfl = flightData.clearedFL; - delete flightData.clearedFL; - } - - const { icao, ...flightDataForDb } = flightData; - - for (const [key, value] of Object.entries(flightDataForDb)) { - fields.push(key); - values.push(value); - placeholders.push(`$${idx++}`); - } - - const query = ` - INSERT INTO ${tableName} (${fields.join(', ')}) - VALUES (${placeholders.join(', ')}) - RETURNING * - `; - const result = await flightsPool.query(query, values); - - const flight = result.rows[0]; - return { - ...flight, - cruisingFL: flight.cruisingfl, - clearedFL: flight.clearedfl, - }; -} - -export async function updateFlight(sessionId, flightId, updates) { - const validSessionId = validateSessionId(sessionId); - const validFlightId = validateFlightId(flightId); - const tableName = `flights_${validSessionId}`; - - const allowedColumns = [ - 'callsign', 'aircraft', 'departure', 'arrival', 'flight_type', - 'stand', 'gate', 'runway', 'sid', 'star', 'cruisingfl', 'clearedfl', - 'squawk', 'wtc', 'status', 'remark', 'clearance', 'pdc_remarks', 'hidden' - ]; - - const safeCols = Object.keys(updates).filter((k) => - allowedColumns.includes(k.toLowerCase()) - ); - for (const col of safeCols) { - try { - await flightsPool.query( - `ALTER TABLE ${tableName} ADD COLUMN IF NOT EXISTS "${col}" text;` - ); - } catch (err) { - // ignore - column creation failure shouldn't stop update - console.error( - 'Could not ensure column exists:', - col, - err?.message || err - ); - } - } - - const fields = []; - const values = []; - let idx = 1; - - const dbUpdates = { ...updates }; - if (dbUpdates.cruisingFL) { - dbUpdates.cruisingfl = dbUpdates.cruisingFL; - delete dbUpdates.cruisingFL; - } - if (dbUpdates.clearedFL) { - dbUpdates.clearedfl = dbUpdates.clearedFL; - delete dbUpdates.clearedFL; - } - - validateFlightFields(updates); - - for (const [key, value] of Object.entries(dbUpdates)) { - let processedValue = value; - if (key === 'clearance' && typeof value === 'string') { - processedValue = value.toLowerCase() === 'true'; - } - - fields.push(`${key} = $${idx++}`); - values.push(processedValue); - } - values.push(validFlightId); - - const query = ` - UPDATE ${tableName} SET ${fields.join(', ')}, updated_at = NOW() - WHERE id = $${idx} - RETURNING * - `; - const result = await flightsPool.query(query, values); - - const flight = result.rows[0]; - return sanitizeFlightForClient(flight); -} - -export async function deleteFlight(sessionId, flightId) { - const validSessionId = validateSessionId(sessionId); - const validFlightId = validateFlightId(flightId); - const tableName = `flights_${validSessionId}`; - await flightsPool.query(`DELETE FROM ${tableName} WHERE id = $1`, [ - validFlightId, - ]); -} diff --git a/server/db/flights.ts b/server/db/flights.ts new file mode 100644 index 0000000..a4bc5cf --- /dev/null +++ b/server/db/flights.ts @@ -0,0 +1,409 @@ +import { FlightsDatabase } from "./types/connection/FlightsDatabase.js"; +import { validateSessionId } from "../utils/validation.js"; +import { flightsDb, mainDb } from "./connection.js"; +import { 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"; + +export interface ClientFlight { + id: string; + session_id: string; + user_id?: string; + ip_address?: string; + callsign?: string; + aircraft?: string; + flight_type?: string; + departure?: string; + arrival?: string; + alternate?: string; + route?: string; + sid?: string; + star?: string; + runway?: string; + cruisingFL?: string; + clearedFL?: string; + stand?: string; + gate?: string; + remark?: string; + timestamp?: string; + created_at?: Date; + updated_at?: Date; + status?: string; + clearance?: string; + position?: object; + squawk?: string; + wtc?: string; + hidden?: boolean; + acars_token?: string; + pdc_remarks?: string; + user?: { + id: string; + discord_username: string; + discord_avatar_url: string | null; + }; +} + +function sanitizeFlightForClient(flight: FlightsDatabase[string]): ClientFlight { + const { user_id, ip_address, acars_token, cruisingfl, clearedfl, ...sanitizedFlight } = flight; + return { + ...sanitizedFlight, + cruisingFL: cruisingfl, + clearedFL: clearedfl, + }; +} + +function validateFlightFields(updates: Partial) { + if (typeof updates.callsign === "string" && (updates.callsign as string).length > 16) { + throw new Error('Callsign must be 16 characters or less'); + } + if (typeof updates.stand === "string" && (updates.stand as string).length > 8) { + throw new Error('Stand must be 8 characters or less'); + } + if (typeof updates.squawk === "string") { + if ((updates.squawk as string).length > 4 || !/^\d{1,4}$/.test(updates.squawk as string)) { + throw new Error('Squawk must be up to 4 numeric digits'); + } + } + if (typeof updates.remark === "string" && (updates.remark as string).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 > 200 || fl % 5 !== 0) { + throw new Error( + 'Cruising FL must be between 0 and 200 in 50-step increments' + ); + } + } + if (updates.clearedfl !== undefined) { + const fl = parseInt(String(updates.clearedfl), 10); + if (isNaN(fl) || fl < 0 || fl > 200 || fl % 5 !== 0) { + throw new Error( + 'Cleared FL must be between 0 and 200 in 50-step increments' + ); + } + } +} + +export async function getFlightsBySession(sessionId: string) { + const validSessionId = validateSessionId(sessionId); + const tableName = `flights_${validSessionId}`; + + let tableExistsResult: boolean; + try { + await flightsDb.selectFrom(tableName).select('id').limit(1).execute(); + tableExistsResult = true; + } catch { + tableExistsResult = false; + } + + if (!tableExistsResult) { + return []; + } + + try { + const flights = await flightsDb + .selectFrom(tableName) + .selectAll() + .orderBy('created_at', 'asc') + .execute(); + + const userIds = [...new Set(flights.map(f => f.user_id).filter((id): id is string => typeof id === 'string'))]; + + const usersMap = new Map(); + if (userIds.length > 0) { + try { + const users = await mainDb + .selectFrom('users') + .select([ + 'id', + 'username as discord_username', + 'avatar as discord_avatar_url' + ]) + .where('id', 'in', userIds) + .execute(); + + users.forEach(user => { + usersMap.set(user.id, { + id: user.id, + discord_username: user.discord_username, + discord_avatar_url: user.discord_avatar_url + ? `https://cdn.discordapp.com/avatars/${user.id}/${user.discord_avatar_url}.png` + : null + }); + }); + } catch (userError) { + console.error('Error fetching user data:', userError); + } + } + + const enrichedFlights = flights.map(flight => { + const sanitized = sanitizeFlightForClient(flight as unknown as FlightsDatabase[string]); + + let user = undefined; + if (flight.user_id && usersMap.has(flight.user_id)) { + user = usersMap.get(flight.user_id); + } + + return { + ...sanitized, + user, + }; + }); + + return enrichedFlights; + } catch (error) { + console.error('Error fetching flights:', error); + const fallbackFlights = await flightsDb + .selectFrom(tableName) + .selectAll() + .orderBy('created_at', 'asc') + .execute(); + return fallbackFlights.map((flight) => sanitizeFlightForClient(flight as unknown as FlightsDatabase[string])); + } +} + +export async function validateAcarsAccess(sessionId: string, flightId: string, acarsToken: string) { + try { + const validSessionId = validateSessionId(sessionId); + const validFlightId = validateFlightId(flightId); + const tableName = `flights_${validSessionId}`; + + const result = await flightsDb + .selectFrom(tableName) + .select(['acars_token']) + .where('id', '=', validFlightId) + .executeTakeFirst(); + + if (!result) { + return { valid: false }; + } + + const isValid = result.acars_token === acarsToken; + + if (!isValid) { + return { valid: false }; + } + + const session = await getSessionById(sessionId); + + return { + valid: true, + accessId: session?.access_id || null + }; + } catch (error) { + console.error('Error validating ACARS access:', error); + return { valid: false }; + } +} + +export async function getFlightsBySessionWithTime(sessionId: string, hoursBack = 2) { + try { + const validSessionId = validateSessionId(sessionId); + const tableName = `flights_${validSessionId}`; + + let tableExistsResult: boolean; + try { + await flightsDb.selectFrom(tableName).select('id').limit(1).execute(); + tableExistsResult = true; + } catch { + tableExistsResult = false; + } + + if (!tableExistsResult) { + return []; + } + + const sinceDate = new Date(Date.now() - hoursBack * 60 * 60 * 1000); + + const flights = await flightsDb + .selectFrom(tableName) + .selectAll() + .where('created_at', '>=', sinceDate) + .orderBy('created_at', 'asc') + .execute(); + + return flights.map(flight => sanitizeFlightForClient(flight as unknown as FlightsDatabase[string])); + } catch (error) { + console.error( + `Error fetching flights for session ${sessionId}:`, + error + ); + return []; + } +} + +export interface AddFlightData { + id?: string; + squawk?: string; + wtc?: string; + timestamp?: string; + acars_token?: string; + aircraft_type?: string; + aircraft?: string; + icao?: string; + departure?: string; + runway?: string; + sid?: string; + cruisingFL?: number; + clearedFL?: number; + cruisingfl?: number; + clearedfl?: number; + gate?: string; + [key: string]: unknown; +} + +export async function addFlight(sessionId: string, flightData: AddFlightData) { + const validSessionId = validateSessionId(sessionId); + const tableName = `flights_${validSessionId}`; + + try { + await sql`ALTER TABLE ${sql.table(tableName)} ADD COLUMN IF NOT EXISTS gate VARCHAR(8);`.execute(flightsDb); + } catch { + // Column might already exist, continue + } + + flightData.id = await generateRandomId(); + flightData.squawk = await generateSquawk(flightData); + flightData.wtc = await getWakeTurbulence(flightData.aircraft || ''); + if (!flightData.timestamp) { + flightData.timestamp = new Date().toISOString(); + } + flightData.acars_token = crypto.randomBytes(4).toString('hex'); + + if (flightData.aircraft_type) { + flightData.aircraft = flightData.aircraft_type; + delete flightData.aircraft_type; + } + + flightData.icao = flightData.departure; + + if (!flightData.runway) { + try { + const session = await getSessionById(validSessionId); + if (session && session.active_runway) { + flightData.runway = session.active_runway; + } + } catch (error) { + console.error( + 'Error fetching session for runway assignment:', + error + ); + } + } + + if (!flightData.sid) { + const sidResult = await generateSID(flightData); + flightData.sid = sidResult.sid; + } + + if (flightData.cruisingFL !== undefined) { + flightData.cruisingfl = flightData.cruisingFL; + delete flightData.cruisingFL; + } + if (flightData.clearedFL !== undefined) { + flightData.clearedfl = flightData.clearedFL; + delete flightData.clearedFL; + } + + const { icao, ...flightDataForDb } = flightData; + + const result = await flightsDb + .insertInto(tableName) + .values({ + id: flightDataForDb.id ?? sql`DEFAULT`, + session_id: validSessionId, + ...flightDataForDb, + cruisingfl: flightDataForDb.cruisingfl !== undefined && flightDataForDb.cruisingfl !== null + ? String(flightDataForDb.cruisingfl) + : undefined, + clearedfl: flightDataForDb.clearedfl !== undefined && flightDataForDb.clearedfl !== null + ? String(flightDataForDb.clearedfl) + : undefined, + }) + .returningAll() + .executeTakeFirst(); + + if (!result) { + throw new Error('Failed to insert flight'); + } + + return sanitizeFlightForClient(result); +} + +export async function updateFlight( + sessionId: string, + flightId: string, + updates: Record +) { + const validSessionId = validateSessionId(sessionId); + const validFlightId = validateFlightId(flightId); + const tableName = `flights_${validSessionId}`; + + const allowedColumns = [ + 'callsign', 'aircraft', 'departure', 'arrival', 'flight_type', + 'stand', 'gate', 'runway', 'sid', 'star', 'cruisingFL', 'clearedFL', + 'squawk', 'wtc', 'status', 'remark', 'clearance', 'pdc_remarks', 'hidden' + ]; + + 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 (allowedColumns.includes(key)) { + if (dbKey === 'clearance') { + dbUpdates[dbKey] = String(value); + } else { + dbUpdates[dbKey] = value; + } + } + } + + validateFlightFields(dbUpdates as Partial); + + for (const col of Object.keys(dbUpdates)) { + try { + await sql`ALTER TABLE ${sql.table(tableName)} ADD COLUMN IF NOT EXISTS ${sql.raw(`"${col}"`)} TEXT;`.execute(flightsDb); + } catch (err) { + console.error('Could not ensure column exists:', col, String(err)); + } + } + + if (Object.keys(dbUpdates).length === 0) { + throw new Error('No valid fields to update'); + } + + dbUpdates.updated_at = new Date(); + + try { + await sql`ALTER TABLE ${sql.table(tableName)} ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP;`.execute(flightsDb); + } catch (err) { + console.error('Could not ensure column exists:', 'updated_at', String(err)); + } + + const result = await flightsDb + .updateTable(tableName) + .set(dbUpdates) + .where('id', '=', validFlightId) + .returningAll() + .executeTakeFirst(); + + if (!result) { + throw new Error('Flight not found or update failed'); + } + + return sanitizeFlightForClient(result as unknown as FlightsDatabase[string]); +} + +export async function deleteFlight(sessionId: string, flightId: string) { + const validSessionId = validateSessionId(sessionId); + const validFlightId = validateFlightId(flightId); + const tableName = `flights_${validSessionId}`; + await flightsDb + .deleteFrom(tableName) + .where('id', '=', validFlightId) + .execute(); +} \ No newline at end of file diff --git a/server/db/logbook.js b/server/db/logbook.js deleted file mode 100644 index fcdf298..0000000 --- a/server/db/logbook.js +++ /dev/null @@ -1,1367 +0,0 @@ -import pool from './connections/connection.js'; -import { isAdmin } from '../middleware/isAdmin.js'; - -export async function initializeLogbookTables() { - try { - await pool.query(` - CREATE TABLE IF NOT EXISTS logbook_flights ( - id SERIAL PRIMARY KEY, - user_id VARCHAR(20) NOT NULL, - roblox_user_id VARCHAR(50), - roblox_username VARCHAR(50) NOT NULL, - - -- Flight Info - callsign VARCHAR(20) NOT NULL, - aircraft_model VARCHAR(50), - aircraft_icao VARCHAR(10), - livery VARCHAR(100), - - -- Route - departure_icao VARCHAR(4), - arrival_icao VARCHAR(4), - route TEXT, - - -- Timestamps - flight_start TIMESTAMP, - flight_end TIMESTAMP, - duration_minutes INTEGER, - - -- Stats - total_distance_nm DECIMAL(10,2), - max_altitude_ft INTEGER, - max_speed_kts INTEGER, - average_speed_kts INTEGER, - landing_rate_fpm INTEGER, - landing_g_force DECIMAL(4,2), - - -- Quality Scores (0-100) - smoothness_score INTEGER, - landing_score INTEGER, - route_adherence_score INTEGER, - - -- State (pending -> active -> completed/cancelled/aborted) - flight_status VARCHAR(20) DEFAULT 'pending', - controller_status VARCHAR(50), - logged_from_submit BOOLEAN DEFAULT false, - controller_managed BOOLEAN DEFAULT false, - - -- Parking/Gate Detection - departure_position_x DOUBLE PRECISION, - departure_position_y DOUBLE PRECISION, - arrival_position_x DOUBLE PRECISION, - arrival_position_y DOUBLE PRECISION, - - -- State change timestamps - activated_at TIMESTAMP, - landed_at TIMESTAMP, - - -- Landing waypoint data (from Project Flight API) - landed_runway VARCHAR(10), - landed_airport VARCHAR(4), - waypoint_landing_rate INTEGER, - - -- Sharing - share_token VARCHAR(16) UNIQUE, - - created_at TIMESTAMP DEFAULT NOW() - ) - `); - - await pool.query(` - CREATE TABLE IF NOT EXISTS logbook_telemetry ( - id SERIAL PRIMARY KEY, - flight_id INTEGER REFERENCES logbook_flights(id) ON DELETE CASCADE, - - -- Position - timestamp TIMESTAMP NOT NULL, - x DOUBLE PRECISION, - y DOUBLE PRECISION, - latitude DECIMAL(10,6), - longitude DECIMAL(10,6), - - -- Flight Data - altitude_ft INTEGER, - speed_kts INTEGER, - heading INTEGER, - vertical_speed_fpm INTEGER, - - -- Phase - flight_phase VARCHAR(20) - ) - `); - - await pool.query(` - CREATE TABLE IF NOT EXISTS logbook_active_flights ( - id SERIAL PRIMARY KEY, - roblox_username VARCHAR(50) UNIQUE NOT NULL, - callsign VARCHAR(20), - flight_id INTEGER REFERENCES logbook_flights(id), - - -- Current state - last_update TIMESTAMP, - last_altitude INTEGER, - last_speed INTEGER, - last_heading INTEGER, - last_x DOUBLE PRECISION, - last_y DOUBLE PRECISION, - - -- Flight phase tracking - current_phase VARCHAR(20), - takeoff_detected BOOLEAN DEFAULT false, - landing_detected BOOLEAN DEFAULT false, - - -- Departure detection - initial_position_x DOUBLE PRECISION, - initial_position_y DOUBLE PRECISION, - initial_position_time TIMESTAMP, - movement_started BOOLEAN DEFAULT false, - movement_start_time TIMESTAMP, - - -- Arrival detection - stationary_since TIMESTAMP, - stationary_position_x DOUBLE PRECISION, - stationary_position_y DOUBLE PRECISION, - stationary_notification_sent BOOLEAN DEFAULT false, - - -- For landing rate calculation - approach_altitudes INTEGER[], - approach_timestamps TIMESTAMP[], - - -- Waypoint data collection (from Project Flight username WebSocket) - collected_waypoints JSONB, - - created_at TIMESTAMP DEFAULT NOW() - ) - `); - - await pool.query(` - CREATE TABLE IF NOT EXISTS logbook_stats_cache ( - user_id VARCHAR(20) PRIMARY KEY, - - -- Totals - total_flights INTEGER DEFAULT 0, - total_hours DECIMAL(10,2) DEFAULT 0, - total_flight_time_minutes INTEGER DEFAULT 0, - total_distance_nm DECIMAL(10,2) DEFAULT 0, - - -- Favorites - favorite_aircraft VARCHAR(50), - favorite_aircraft_count INTEGER, - favorite_airline VARCHAR(10), - favorite_airline_count INTEGER, - favorite_departure VARCHAR(4), - favorite_departure_count INTEGER, - - -- Records - smoothest_landing_rate INTEGER, - smoothest_landing_flight_id INTEGER, - best_landing_rate INTEGER, - average_landing_score DECIMAL(5,2), - highest_altitude INTEGER, - longest_flight_distance DECIMAL(10,2), - longest_flight_id INTEGER, - - last_updated TIMESTAMP DEFAULT NOW() - ) - `); - - await pool.query(`CREATE INDEX IF NOT EXISTS idx_logbook_user ON logbook_flights(user_id)`); - await pool.query(`CREATE INDEX IF NOT EXISTS idx_logbook_roblox ON logbook_flights(roblox_username)`); - await pool.query(`CREATE INDEX IF NOT EXISTS idx_logbook_status ON logbook_flights(flight_status)`); - await pool.query(`CREATE INDEX IF NOT EXISTS idx_telemetry_flight ON logbook_telemetry(flight_id, timestamp)`); - await pool.query(`CREATE INDEX IF NOT EXISTS idx_active_flights_callsign ON logbook_active_flights(callsign)`); - - const columnsToAdd = [ - { name: 'controller_managed', type: 'BOOLEAN DEFAULT false' }, - { name: 'activated_at', type: 'TIMESTAMP' }, - { name: 'landed_at', type: 'TIMESTAMP' }, - { name: 'controller_status', type: 'VARCHAR(50)' }, - { name: 'share_token', type: 'VARCHAR(16) UNIQUE' } - ]; - - for (const col of columnsToAdd) { - const columnCheck = await pool.query(` - SELECT column_name - FROM information_schema.columns - WHERE table_name = 'logbook_flights' - AND column_name = $1 - `, [col.name]); - - if (columnCheck.rows.length === 0) { - await pool.query(` - ALTER TABLE logbook_flights - ADD COLUMN ${col.name} ${col.type} - `); - console.log('\x1b[33m%s\x1b[0m', `Added ${col.name} column to logbook_flights`); - } - } - - const activeFlightColumnsToAdd = [ - { name: 'initial_position_x', type: 'DOUBLE PRECISION' }, - { name: 'initial_position_y', type: 'DOUBLE PRECISION' }, - { name: 'initial_position_time', type: 'TIMESTAMP' }, - { name: 'movement_started', type: 'BOOLEAN DEFAULT false' }, - { name: 'movement_start_time', type: 'TIMESTAMP' }, - { name: 'stationary_since', type: 'TIMESTAMP' }, - { name: 'stationary_position_x', type: 'DOUBLE PRECISION' }, - { name: 'stationary_position_y', type: 'DOUBLE PRECISION' }, - { name: 'stationary_notification_sent', type: 'BOOLEAN DEFAULT false' } - ]; - - for (const col of activeFlightColumnsToAdd) { - const columnCheck = await pool.query(` - SELECT column_name - FROM information_schema.columns - WHERE table_name = 'logbook_active_flights' - AND column_name = $1 - `, [col.name]); - - if (columnCheck.rows.length === 0) { - await pool.query(` - ALTER TABLE logbook_active_flights - ADD COLUMN ${col.name} ${col.type} - `); - console.log('\x1b[33m%s\x1b[0m', `Added ${col.name} column to logbook_active_flights`); - } - } - - const statsCacheColumnsToAdd = [ - { name: 'total_flight_time_minutes', type: 'INTEGER DEFAULT 0' }, - { name: 'favorite_departure', type: 'VARCHAR(4)' }, - { name: 'favorite_departure_count', type: 'INTEGER' }, - { name: 'best_landing_rate', type: 'INTEGER' }, - { name: 'average_landing_score', type: 'DECIMAL(5,2)' } - ]; - - for (const col of statsCacheColumnsToAdd) { - const columnCheck = await pool.query(` - SELECT column_name - FROM information_schema.columns - WHERE table_name = 'logbook_stats_cache' - AND column_name = $1 - `, [col.name]); - - if (columnCheck.rows.length === 0) { - await pool.query(` - ALTER TABLE logbook_stats_cache - ADD COLUMN ${col.name} ${col.type} - `); - console.log('\x1b[33m%s\x1b[0m', `Added ${col.name} column to logbook_stats_cache`); - } - } - } catch (error) { - console.error('Error initializing logbook tables:', error); - } -} - -export async function createFlight({ userId, robloxUserId, robloxUsername, callsign, departureIcao, arrivalIcao, route, aircraftIcao }) { - - const crypto = await import('crypto'); - const shareToken = crypto.randomBytes(4).toString('hex'); - - const result = await pool.query(` - INSERT INTO logbook_flights ( - user_id, roblox_user_id, roblox_username, callsign, - departure_icao, arrival_icao, route, aircraft_icao, - flight_status, logged_from_submit, share_token - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', true, $9) - RETURNING id - `, [userId, robloxUserId, robloxUsername, callsign, departureIcao, arrivalIcao, route, aircraftIcao, shareToken]); - - return result.rows[0].id; -} - -export async function startActiveFlightTracking(robloxUsername, callsign, flightId) { - await pool.query(` - INSERT INTO logbook_active_flights (roblox_username, callsign, flight_id) - VALUES ($1, $2, $3) - ON CONFLICT (roblox_username) - DO UPDATE SET callsign = $2, flight_id = $3, created_at = NOW() - `, [robloxUsername, callsign, flightId]); -} - -export async function getActiveFlightByUsername(robloxUsername) { - const result = await pool.query(` - SELECT * FROM logbook_active_flights - WHERE roblox_username = $1 - `, [robloxUsername]); - - return result.rows[0] || null; -} - -export async function storeTelemetryPoint(flightId, { x, y, altitude, speed, heading, timestamp, phase, verticalSpeed }) { - // Ensure timestamp is in UTC to prevent timezone-related duplicates - const utcTimestamp = timestamp instanceof Date ? timestamp.toISOString() : timestamp; - - await pool.query(` - INSERT INTO logbook_telemetry ( - flight_id, timestamp, x, y, altitude_ft, speed_kts, heading, flight_phase, vertical_speed_fpm - ) - VALUES ($1, $2::timestamptz, $3, $4, $5, $6, $7, $8, $9) - `, [flightId, utcTimestamp, x, y, altitude, speed, heading, phase, verticalSpeed ?? 0]); -} - -export async function updateActiveFlightState(robloxUsername, { altitude, speed, heading, x, y, phase }) { - await pool.query(` - UPDATE logbook_active_flights - SET last_update = NOW(), - last_altitude = $2, - last_speed = $3, - last_heading = $4, - last_x = $5, - last_y = $6, - current_phase = $7 - WHERE roblox_username = $1 - `, [robloxUsername, altitude, speed, heading, x, y, phase]); -} - -export async function addApproachAltitude(robloxUsername, altitude, timestamp) { - await pool.query(` - UPDATE logbook_active_flights - SET approach_altitudes = array_append( - COALESCE(approach_altitudes, ARRAY[]::INTEGER[]), - $2 - ), - approach_timestamps = array_append( - COALESCE(approach_timestamps, ARRAY[]::TIMESTAMP[]), - $3 - ) - WHERE roblox_username = $1 - `, [robloxUsername, altitude, timestamp]); - - await pool.query(` - UPDATE logbook_active_flights - SET approach_altitudes = approach_altitudes[greatest(1, array_length(approach_altitudes, 1) - 29):], - approach_timestamps = approach_timestamps[greatest(1, array_length(approach_timestamps, 1) - 29):] - WHERE roblox_username = $1 - `, [robloxUsername]); -} - -export async function calculateLandingRate(robloxUsername) { - - const flightResult = await pool.query(` - SELECT laf.flight_id, lf.waypoint_landing_rate - FROM logbook_active_flights laf - JOIN logbook_flights lf ON laf.flight_id = lf.id - WHERE laf.roblox_username = $1 - `, [robloxUsername]); - - if (!flightResult.rows[0]) { - return null; - } - - const { flight_id: flightId, waypoint_landing_rate } = flightResult.rows[0]; - - if (waypoint_landing_rate !== null && waypoint_landing_rate !== undefined) { - console.log(`[Landing Rate] Using waypoint data: ${waypoint_landing_rate} fpm`); - return waypoint_landing_rate; - } - - const telemetryResult = await pool.query(` - SELECT altitude_ft, vertical_speed_fpm, timestamp, flight_phase - FROM logbook_telemetry - WHERE flight_id = $1 - AND flight_phase IN ('approach', 'landing') - AND altitude_ft < 100 - ORDER BY altitude_ft ASC - LIMIT 1 - `, [flightId]); - - if (telemetryResult.rows.length === 0) { - return null; - } - - console.log(`[Landing Rate] Using telemetry data: ${telemetryResult.rows[0].vertical_speed_fpm} fpm`); - return telemetryResult.rows[0].vertical_speed_fpm || null; -} - -export async function finalizeFlight(flightId, stats) { - await pool.query(` - UPDATE logbook_flights - SET flight_end = NOW(), - duration_minutes = $2, - total_distance_nm = $3, - max_altitude_ft = $4, - max_speed_kts = $5, - average_speed_kts = $6, - landing_rate_fpm = $7, - smoothness_score = $8, - landing_score = $9, - flight_status = 'completed' - WHERE id = $1 - `, [ - flightId, - stats.durationMinutes, - stats.totalDistance, - stats.maxAltitude, - stats.maxSpeed, - stats.averageSpeed, - stats.landingRate, - stats.smoothnessScore, - stats.landingScore - ]); -} - -export async function removeActiveFlightTracking(robloxUsername) { - await pool.query(` - DELETE FROM logbook_active_flights - WHERE roblox_username = $1 - `, [robloxUsername]); -} - -export async function getActiveFlightByCallsign(callsign) { - const result = await pool.query(` - SELECT laf.*, lf.id as flight_id, lf.user_id, lf.flight_status - FROM logbook_active_flights laf - JOIN logbook_flights lf ON laf.flight_id = lf.id - WHERE laf.callsign = $1 - `, [callsign]); - - return result.rows[0] || null; -} - -export async function activateFlightByCallsign(callsign) { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - const result = await client.query(` - UPDATE logbook_flights lf - SET flight_status = 'active', - flight_start = NOW(), - activated_at = NOW(), - controller_managed = true - FROM logbook_active_flights laf - WHERE laf.callsign = $1 - AND laf.flight_id = lf.id - AND lf.flight_status = 'pending' - RETURNING lf.id - `, [callsign]); - - if (result.rows.length === 0) { - await client.query('ROLLBACK'); - return null; - } - - await client.query('COMMIT'); - return result.rows[0].id; - } catch (error) { - await client.query('ROLLBACK'); - console.error('Error activating flight by callsign:', error); - throw error; - } finally { - client.release(); - } -} - -export async function completeFlightByCallsign(callsign) { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - const flightResult = await client.query(` - WITH flight_data AS ( - SELECT lf.*, laf.roblox_username, laf.approach_altitudes, laf.approach_timestamps - FROM logbook_active_flights laf - JOIN logbook_flights lf ON laf.flight_id = lf.id - WHERE laf.callsign = $1 AND lf.flight_status IN ('active', 'pending') - ), - telemetry_stats AS ( - SELECT - flight_id, - MAX(altitude_ft) as max_altitude, - MAX(speed_kts) as max_speed, - ROUND(AVG(speed_kts) FILTER (WHERE speed_kts > 10))::INTEGER as avg_speed, - COUNT(*) as telemetry_count, - MIN(timestamp) as first_telemetry, - MAX(timestamp) as last_telemetry - FROM logbook_telemetry - WHERE flight_id = (SELECT id FROM flight_data) - GROUP BY flight_id - ) - SELECT - fd.*, - ts.max_altitude, - ts.max_speed, - ts.avg_speed, - ts.telemetry_count, - ts.first_telemetry, - ts.last_telemetry - FROM flight_data fd - LEFT JOIN telemetry_stats ts ON ts.flight_id = fd.id - `, [callsign]); - - if (flightResult.rows.length === 0) { - await client.query('ROLLBACK'); - - const checkFlight = await client.query(` - SELECT lf.flight_status, lf.callsign, laf.callsign as active_callsign - FROM logbook_flights lf - LEFT JOIN logbook_active_flights laf ON laf.flight_id = lf.id - WHERE lf.callsign = $1 - `, [callsign]); - - if (checkFlight.rows.length > 0) { - const flight = checkFlight.rows[0]; - console.error(`[Logbook] Cannot complete ${callsign}: flight_status="${flight.flight_status}", in_active_table=${!!flight.active_callsign}`); - } else { - console.error(`[Logbook] Cannot complete ${callsign}: flight not found in logbook`); - } - - return null; - } - - const flightData = flightResult.rows[0]; - - const telemetryPoints = await client.query(` - SELECT x, y, timestamp - FROM logbook_telemetry - WHERE flight_id = $1 - ORDER BY timestamp ASC - `, [flightData.id]); - - let totalDistance = 0; - for (let i = 1; i < telemetryPoints.rows.length; i++) { - const prev = telemetryPoints.rows[i - 1]; - const curr = telemetryPoints.rows[i]; - if (prev.x && prev.y && curr.x && curr.y) { - const dx = curr.x - prev.x; - const dy = curr.y - prev.y; - const distance = Math.sqrt(dx * dx + dy * dy) / 1852; - totalDistance += distance; - } - } - - const durationResult = await client.query(` - SELECT EXTRACT(EPOCH FROM (NOW() - COALESCE(flight_start, created_at))) / 60 AS duration_minutes - FROM logbook_flights - WHERE id = $1 - `, [flightData.id]); - const durationMinutes = Math.round(durationResult.rows[0].duration_minutes); - - let landingRate = null; - if (flightData.approach_altitudes && flightData.approach_altitudes.length >= 2) { - const altitudes = flightData.approach_altitudes; - const timestamps = flightData.approach_timestamps; - const firstAlt = altitudes[0]; - const lastAlt = altitudes[altitudes.length - 1]; - const firstTime = new Date(timestamps[0]); - const lastTime = new Date(timestamps[timestamps.length - 1]); - const altChange = firstAlt - lastAlt; - const timeChange = (lastTime - firstTime) / 1000; - if (timeChange > 0) { - const feetPerSecond = altChange / timeChange; - landingRate = -Math.round(feetPerSecond * 60); - } - } - - let landingScore = null; - if (landingRate !== null) { - const absLandingRate = Math.abs(landingRate); - if (absLandingRate <= 100) landingScore = 100; - else if (absLandingRate <= 200) landingScore = 90; - else if (absLandingRate <= 300) landingScore = 80; - else if (absLandingRate <= 400) landingScore = 70; - else if (absLandingRate <= 500) landingScore = 60; - else if (absLandingRate <= 600) landingScore = 50; - else if (absLandingRate <= 700) landingScore = 40; - else if (absLandingRate <= 800) landingScore = 30; - else landingScore = 20; - } - - let smoothnessScore = null; - - const telemetryData = await client.query(` - SELECT speed_kts, vertical_speed_fpm, heading - FROM logbook_telemetry - WHERE flight_id = $1 - ORDER BY timestamp ASC - `, [flightData.id]); - - if (telemetryData.rows.length > 2) { - let score = 100; - let speedPenalty = 0; - let verticalSpeedPenalty = 0; - let headingPenalty = 0; - let validComparisons = 0; - - for (let i = 1; i < telemetryData.rows.length; i++) { - const prev = telemetryData.rows[i - 1]; - const curr = telemetryData.rows[i]; - validComparisons++; - - if (prev.speed_kts != null && curr.speed_kts != null) { - const speedChange = Math.abs(curr.speed_kts - prev.speed_kts); - if (speedChange > 30) speedPenalty += 3; - else if (speedChange > 20) speedPenalty += 2; - else if (speedChange > 10) speedPenalty += 1; - } - - if (prev.vertical_speed_fpm != null && curr.vertical_speed_fpm != null) { - const vsChange = Math.abs(curr.vertical_speed_fpm - prev.vertical_speed_fpm); - if (vsChange > 500) verticalSpeedPenalty += 3; - else if (vsChange > 300) verticalSpeedPenalty += 2; - else if (vsChange > 150) verticalSpeedPenalty += 1; - } - - if (prev.heading != null && curr.heading != null) { - let headingChange = Math.abs(curr.heading - prev.heading); - if (headingChange > 180) headingChange = 360 - headingChange; - - if (headingChange > 30) headingPenalty += 2; - else if (headingChange > 20) headingPenalty += 1; - } - } - - if (validComparisons > 0) { - const totalPenalty = (speedPenalty * 0.4) + (verticalSpeedPenalty * 0.4) + (headingPenalty * 0.2); - const avgPenalty = totalPenalty / validComparisons; - score = 100 - Math.min(avgPenalty * 10, 100); - } - - smoothnessScore = Math.max(0, Math.min(100, Math.round(score))); - } - - await client.query(` - UPDATE logbook_flights - SET flight_end = NOW(), - duration_minutes = $2, - total_distance_nm = $3, - max_altitude_ft = $4, - max_speed_kts = $5, - average_speed_kts = $6, - landing_rate_fpm = $7, - smoothness_score = $8, - landing_score = $9, - flight_status = 'completed', - controller_managed = true - WHERE id = $1 - `, [ - flightData.id, - durationMinutes, - Math.round(totalDistance * 100) / 100, - flightData.max_altitude || 0, - flightData.max_speed || 0, - flightData.avg_speed || 0, - landingRate, - smoothnessScore, - landingScore - ]); - - await client.query(` - DELETE FROM logbook_active_flights - WHERE callsign = $1 - `, [callsign]); - - await client.query('COMMIT'); - - try { - await updateUserStatsCache(flightData.user_id); - } catch (error) { - console.error('Error updating user stats cache:', error); - - } - - return flightData.id; - } catch (error) { - await client.query('ROLLBACK'); - console.error('Error completing flight by callsign:', error); - throw error; - } finally { - client.release(); - } -} - -function calculateFlightStats(flightData, telemetryData, activeFlight) { - if (!telemetryData || telemetryData.length === 0) { - return { - durationMinutes: 0, - totalDistance: 0, - maxAltitude: 0, - maxSpeed: 0, - averageSpeed: 0, - landingRate: null, - smoothnessScore: 50, - landingScore: 50 - }; - } - - const startTime = flightData.flight_start || flightData.created_at; - const endTime = new Date(); - const durationMinutes = Math.round((endTime - new Date(startTime)) / 60000); - - let totalDistance = 0; - for (let i = 1; i < telemetryData.length; i++) { - const prev = telemetryData[i - 1]; - const curr = telemetryData[i]; - if (prev.x && prev.y && curr.x && curr.y) { - const dx = curr.x - prev.x; - const dy = curr.y - prev.y; - const distance = Math.sqrt(dx * dx + dy * dy) / 1852; - totalDistance += distance; - } - } - - const maxAltitude = Math.max(...telemetryData.map(t => t.altitude_ft || 0)); - const maxSpeed = Math.max(...telemetryData.map(t => t.speed_kts || 0)); - - const speeds = telemetryData.filter(t => t.speed_kts > 10).map(t => t.speed_kts); - const averageSpeed = speeds.length > 0 - ? Math.round(speeds.reduce((a, b) => a + b, 0) / speeds.length) - : 0; - - let landingRate = null; - if (activeFlight.approach_altitudes && activeFlight.approach_altitudes.length >= 2) { - const altitudes = activeFlight.approach_altitudes; - const timestamps = activeFlight.approach_timestamps; - const firstAlt = altitudes[0]; - const lastAlt = altitudes[altitudes.length - 1]; - const firstTime = new Date(timestamps[0]); - const lastTime = new Date(timestamps[timestamps.length - 1]); - const altChange = firstAlt - lastAlt; - const timeChange = (lastTime - firstTime) / 1000; - if (timeChange > 0) { - const feetPerSecond = altChange / timeChange; - landingRate = -Math.round(feetPerSecond * 60); - } - } - - let smoothnessScore = 100; - - if (telemetryData.length > 2) { - let speedPenalty = 0; - let verticalSpeedPenalty = 0; - let headingPenalty = 0; - let validComparisons = 0; - - for (let i = 1; i < telemetryData.length; i++) { - const prev = telemetryData[i - 1]; - const curr = telemetryData[i]; - validComparisons++; - - if (prev.speed_kts != null && curr.speed_kts != null) { - const speedChange = Math.abs(curr.speed_kts - prev.speed_kts); - if (speedChange > 30) speedPenalty += 3; - else if (speedChange > 20) speedPenalty += 2; - else if (speedChange > 10) speedPenalty += 1; - } - - if (prev.vertical_speed_fpm != null && curr.vertical_speed_fpm != null) { - const vsChange = Math.abs(curr.vertical_speed_fpm - prev.vertical_speed_fpm); - if (vsChange > 500) verticalSpeedPenalty += 3; - else if (vsChange > 300) verticalSpeedPenalty += 2; - else if (vsChange > 150) verticalSpeedPenalty += 1; - } - - if (prev.heading != null && curr.heading != null) { - - let headingChange = Math.abs(curr.heading - prev.heading); - if (headingChange > 180) headingChange = 360 - headingChange; - - if (headingChange > 30) headingPenalty += 2; - else if (headingChange > 20) headingPenalty += 1; - } - } - - if (validComparisons > 0) { - const totalPenalty = (speedPenalty * 0.4) + (verticalSpeedPenalty * 0.4) + (headingPenalty * 0.2); - const avgPenalty = totalPenalty / validComparisons; - smoothnessScore = 100 - Math.min(avgPenalty * 10, 100); - } - } - - smoothnessScore = Math.max(0, Math.min(100, Math.round(smoothnessScore))); - - let landingScore = 50; - if (landingRate !== null) { - const absLandingRate = Math.abs(landingRate); - if (absLandingRate <= 100) landingScore = 100; - else if (absLandingRate <= 200) landingScore = 90; - else if (absLandingRate <= 300) landingScore = 80; - else if (absLandingRate <= 400) landingScore = 70; - else if (absLandingRate <= 500) landingScore = 60; - else if (absLandingRate <= 600) landingScore = 50; - else if (absLandingRate <= 700) landingScore = 40; - else if (absLandingRate <= 800) landingScore = 30; - else landingScore = 20; - } - - return { - durationMinutes, - totalDistance: Math.round(totalDistance * 100) / 100, - maxAltitude, - maxSpeed, - averageSpeed, - landingRate, - smoothnessScore: Math.round(smoothnessScore), - landingScore - }; -} - -export async function getUserFlights(userId, page = 1, limit = 20, status = 'completed') { - const offset = (page - 1) * limit; - - const useCreatedAt = (status === 'pending' || status === 'active'); - - const result = await pool.query(` - SELECT lf.*, laf.current_phase - FROM logbook_flights lf - LEFT JOIN logbook_active_flights laf ON laf.flight_id = lf.id - WHERE lf.user_id = $1 AND lf.flight_status = $2 - ORDER BY ${useCreatedAt ? 'lf.created_at' : 'COALESCE(lf.flight_start, lf.created_at)'} DESC - LIMIT $3 OFFSET $4 - `, [userId, status, limit, offset]); - - const countResult = await pool.query(` - SELECT COUNT(*) FROM logbook_flights - WHERE user_id = $1 AND flight_status = $2 - `, [userId, status]); - - return { - flights: result.rows, - pagination: { - page, - limit, - total: parseInt(countResult.rows[0].count), - pages: Math.ceil(countResult.rows[0].count / limit), - hasMore: page < Math.ceil(countResult.rows[0].count / limit) - } - }; -} - -export async function getFlightById(flightId) { - const result = await pool.query(` - SELECT * FROM logbook_flights - WHERE id = $1 - `, [flightId]); - - return result.rows[0] || null; -} - -export async function getActiveFlightData(flightId) { - - const flight = await getFlightById(flightId); - if (!flight) return null; - - if (flight.flight_status !== 'active' && flight.flight_status !== 'pending') { - return flight; - } - - const activeResult = await pool.query(` - SELECT * FROM logbook_active_flights - WHERE flight_id = $1 - `, [flightId]); - - const activeData = activeResult.rows[0]; - - const statsResult = await pool.query(` - SELECT - COUNT(*) as telemetry_count, - MAX(altitude_ft) as max_altitude_ft, - MAX(speed_kts) as max_speed_kts, - AVG(CASE WHEN altitude_ft > 100 THEN speed_kts ELSE NULL END) as avg_speed_kts - FROM logbook_telemetry - WHERE flight_id = $1 - `, [flightId]); - - const stats = statsResult.rows[0]; - - const distanceResult = await pool.query(` - WITH telemetry_points AS ( - SELECT x, y, timestamp, - LAG(x) OVER (ORDER BY timestamp) as prev_x, - LAG(y) OVER (ORDER BY timestamp) as prev_y - FROM logbook_telemetry - WHERE flight_id = $1 - ORDER BY timestamp ASC - ) - SELECT SUM( - CASE - WHEN prev_x IS NOT NULL AND prev_y IS NOT NULL - THEN SQRT(POWER(x - prev_x, 2) + POWER(y - prev_y, 2)) / 1852 - ELSE 0 - END - ) as total_distance - FROM telemetry_points - `, [flightId]); - - const totalDistanceNm = distanceResult.rows[0]?.total_distance ? - Math.round(distanceResult.rows[0].total_distance) : null; - - const telemetryResult = await pool.query(` - SELECT speed_kts, altitude_ft - FROM logbook_telemetry - WHERE flight_id = $1 - ORDER BY timestamp ASC - `, [flightId]); - - const telemetry = telemetryResult.rows; - let smoothnessScore = null; - - if (telemetry.length > 1) { - let score = 100; - for (let i = 1; i < telemetry.length; i++) { - const speedDelta = Math.abs((telemetry[i].speed_kts || 0) - (telemetry[i - 1].speed_kts || 0)); - const altDelta = Math.abs((telemetry[i].altitude_ft || 0) - (telemetry[i - 1].altitude_ft || 0)); - - if (speedDelta > 20) score -= 2; - - if (altDelta > 500) score -= 3; - } - smoothnessScore = Math.max(0, Math.min(100, score)); - } - - let landingRate = null; - if (activeData?.landing_detected && activeData?.roblox_username) { - landingRate = await calculateLandingRate(activeData.roblox_username); - } - - const durationMs = new Date() - new Date(flight.created_at); - const durationMinutes = Math.round(durationMs / 60000); - - return { - ...flight, - - current_altitude: activeData?.last_altitude || null, - current_speed: activeData?.last_speed || null, - current_heading: activeData?.last_heading || null, - current_phase: activeData?.current_phase || null, - last_update: activeData?.last_update || null, - landing_detected: activeData?.landing_detected || false, - stationary_notification_sent: activeData?.stationary_notification_sent || false, - - duration_minutes: durationMinutes, - max_altitude_ft: stats.max_altitude_ft || null, - max_speed_kts: stats.max_speed_kts || null, - average_speed_kts: stats.avg_speed_kts ? Math.round(stats.avg_speed_kts) : null, - total_distance_nm: totalDistanceNm, - smoothness_score: smoothnessScore, - landing_rate_fpm: landingRate, - telemetry_count: parseInt(stats.telemetry_count) || 0, - - is_active: true - }; -} - -export async function getFlightTelemetry(flightId) { - const result = await pool.query(` - SELECT * FROM logbook_telemetry - WHERE flight_id = $1 - ORDER BY timestamp ASC - `, [flightId]); - - return result.rows; -} - -export async function getUserStats(userId) { - let result = await pool.query(` - SELECT * FROM logbook_stats_cache - WHERE user_id = $1 - `, [userId]); - - if (result.rows.length === 0) { - - await pool.query(` - INSERT INTO logbook_stats_cache (user_id) - VALUES ($1) - `, [userId]); - - result = await pool.query(` - SELECT * FROM logbook_stats_cache - WHERE user_id = $1 - `, [userId]); - } - - return result.rows[0]; -} - -export async function generateShareToken(flightId, userId) { - const flight = await pool.query(` - SELECT share_token, user_id FROM logbook_flights WHERE id = $1 - `, [flightId]); - - if (!flight.rows[0]) { - throw new Error('Flight not found'); - } - - if (flight.rows[0].user_id !== userId) { - throw new Error('Not authorized'); - } - - if (flight.rows[0].share_token) { - return flight.rows[0].share_token; - } - - const crypto = await import('crypto'); - const shareToken = crypto.randomBytes(4).toString('hex'); - - await pool.query(` - UPDATE logbook_flights - SET share_token = $1 - WHERE id = $2 - `, [shareToken, flightId]); - - return shareToken; -} - -export async function getFlightByShareToken(shareToken) { - const result = await pool.query(` - SELECT - f.*, - u.username as discord_username, - u.discriminator as discord_discriminator - FROM logbook_flights f - LEFT JOIN users u ON f.user_id = u.id - WHERE f.share_token = $1 - `, [shareToken]); - - if (!result.rows[0]) { - return null; - } - - const flight = result.rows[0]; - - if (flight.flight_status === 'active' || flight.flight_status === 'pending') { - const activeData = await getActiveFlightData(flight.id); - - return { - ...activeData, - discord_username: flight.discord_username, - discord_discriminator: flight.discord_discriminator - }; - } - - return flight; -} - -export async function getPublicPilotProfile(username) { - const userResult = await pool.query(` - SELECT - u.id, - u.username, - u.discriminator, - u.avatar, - u.roblox_username, - u.roblox_user_id, - u.vatsim_cid, - u.vatsim_rating_id, - u.vatsim_rating_short, - u.vatsim_rating_long, - u.created_at - FROM users u - WHERE LOWER(u.username) = LOWER($1) - `, [username]); - - if (!userResult.rows[0]) { - return null; - } - - const user = userResult.rows[0]; - - // Fallback: derive short rating from numeric id if not stored as text - let vatsimShort = user.vatsim_rating_short; - if (!vatsimShort && (typeof user.vatsim_rating_id === 'number' || typeof user.vatsim_rating_id === 'string')) { - const ratingIdNum = typeof user.vatsim_rating_id === 'number' ? user.vatsim_rating_id : parseInt(user.vatsim_rating_id, 10); - const map = { 0: 'OBS', 1: 'S1', 2: 'S2', 3: 'S3', 4: 'C1', 5: 'C2', 6: 'C3', 7: 'I1', 8: 'I2', 9: 'I3', 10: 'SUP', 11: 'ADM' }; - if (Number.isFinite(ratingIdNum) && Object.prototype.hasOwnProperty.call(map, ratingIdNum)) { - vatsimShort = map[ratingIdNum]; - } - } - - const rolesResult = await pool.query(` - SELECT r.id, r.name, r.description, r.color, r.icon, r.priority - FROM roles r - JOIN user_roles ur ON ur.role_id = r.id - WHERE ur.user_id = $1 - ORDER BY r.priority DESC, r.created_at DESC - `, [user.id]); - - const stats = await getUserStats(user.id); - const recentFlights = await pool.query(` - SELECT - id, - callsign, - aircraft_model, - aircraft_icao, - departure_icao, - arrival_icao, - duration_minutes, - total_distance_nm, - landing_rate_fpm, - created_at, - flight_end - FROM logbook_flights - WHERE user_id = $1 AND flight_status = 'completed' - ORDER BY flight_end DESC - LIMIT 10 - `, [user.id]); - - const activityData = await pool.query(` - SELECT - DATE_TRUNC('month', flight_end) as month, - COUNT(*) as flight_count, - COALESCE(SUM(duration_minutes), 0) as total_minutes - FROM logbook_flights - WHERE user_id = $1 AND flight_status = 'completed' - AND flight_end >= NOW() - INTERVAL '12 months' - GROUP BY DATE_TRUNC('month', flight_end) - ORDER BY month DESC - `, [user.id]); - - return { - user: { - id: user.id, - username: user.username, - discriminator: user.discriminator, - avatar: user.avatar, - roblox_username: user.roblox_username, - roblox_user_id: user.roblox_user_id, - vatsim_cid: user.vatsim_cid, - vatsim_rating_short: vatsimShort, - vatsim_rating_long: user.vatsim_rating_long, - member_since: user.created_at, - is_admin: isAdmin(user.id), - roles: rolesResult.rows, - role_name: rolesResult.rows[0]?.name || null, - role_description: rolesResult.rows[0]?.description || null - }, - stats, - recentFlights: recentFlights.rows, - activityData: activityData.rows - }; -} - -export async function updateUserStatsCache(userId) { - const totals = await pool.query(` - SELECT - COUNT(*) as total_flights, - COALESCE(SUM(CASE WHEN duration_minutes > 0 THEN duration_minutes ELSE 0 END), 0) as total_minutes, - COALESCE(SUM(CASE WHEN duration_minutes > 0 THEN duration_minutes ELSE 0 END) / 60.0, 0) as total_hours, - COALESCE(SUM(total_distance_nm), 0) as total_distance - FROM logbook_flights - WHERE user_id = $1 AND flight_status = 'completed' - `, [userId]); - - const favAircraft = await pool.query(` - SELECT aircraft_model, COUNT(*) as count - FROM logbook_flights - WHERE user_id = $1 AND flight_status = 'completed' AND aircraft_model IS NOT NULL - GROUP BY aircraft_model - ORDER BY count DESC - LIMIT 1 - `, [userId]); - - const favDeparture = await pool.query(` - SELECT departure_icao, COUNT(*) as count - FROM logbook_flights - WHERE user_id = $1 AND flight_status = 'completed' AND departure_icao IS NOT NULL - GROUP BY departure_icao - ORDER BY count DESC - LIMIT 1 - `, [userId]); - - const smoothestLanding = await pool.query(` - SELECT id, landing_rate_fpm - FROM logbook_flights - WHERE user_id = $1 AND flight_status = 'completed' AND landing_rate_fpm IS NOT NULL - ORDER BY ABS(landing_rate_fpm) ASC - LIMIT 1 - `, [userId]); - - const avgLandingScore = await pool.query(` - SELECT AVG(landing_score) as avg_score - FROM logbook_flights - WHERE user_id = $1 AND flight_status = 'completed' AND landing_score IS NOT NULL - `, [userId]); - - const highestAlt = await pool.query(` - SELECT MAX(max_altitude_ft) as highest_altitude - FROM logbook_flights - WHERE user_id = $1 AND flight_status = 'completed' - `, [userId]); - - const longestFlight = await pool.query(` - SELECT id, total_distance_nm - FROM logbook_flights - WHERE user_id = $1 AND flight_status = 'completed' - ORDER BY total_distance_nm DESC - LIMIT 1 - `, [userId]); - - const totalFlights = parseInt(totals.rows[0].total_flights) || 0; - const totalMinutes = parseInt(totals.rows[0].total_minutes) || 0; - const totalHours = parseFloat(totals.rows[0].total_hours) || 0; - const totalDistance = parseFloat(totals.rows[0].total_distance) || 0; - - await pool.query(` - UPDATE logbook_stats_cache - SET total_flights = $2, - total_hours = $3, - total_flight_time_minutes = $4, - total_distance_nm = $5, - favorite_aircraft = $6, - favorite_aircraft_count = $7, - favorite_departure = $8, - favorite_departure_count = $9, - smoothest_landing_rate = $10, - smoothest_landing_flight_id = $11, - best_landing_rate = $12, - average_landing_score = $13, - highest_altitude = $14, - longest_flight_distance = $15, - longest_flight_id = $16, - last_updated = NOW() - WHERE user_id = $1 - `, [ - userId, - totalFlights, - totalHours, - totalMinutes, - totalDistance, - favAircraft.rows[0]?.aircraft_model || null, - favAircraft.rows[0]?.count || 0, - favDeparture.rows[0]?.departure_icao || null, - favDeparture.rows[0]?.count || 0, - smoothestLanding.rows[0]?.landing_rate_fpm || null, - smoothestLanding.rows[0]?.id || null, - smoothestLanding.rows[0]?.landing_rate_fpm || null, - avgLandingScore.rows[0]?.avg_score ? parseFloat(avgLandingScore.rows[0].avg_score) : null, - highestAlt.rows[0]?.highest_altitude || null, - longestFlight.rows[0]?.total_distance_nm ? parseFloat(longestFlight.rows[0].total_distance_nm) : null, - longestFlight.rows[0]?.id || null - ]); -} - -export async function deleteFlightById(flightId, userId, isAdmin = false) { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - const flightCheck = await client.query(` - SELECT id, user_id, flight_status - FROM logbook_flights - WHERE id = $1 - `, [flightId]); - - if (flightCheck.rows.length === 0) { - await client.query('ROLLBACK'); - return { success: false, error: 'Flight not found' }; - } - - const flight = flightCheck.rows[0]; - - if (flight.user_id !== userId && !isAdmin) { - await client.query('ROLLBACK'); - return { success: false, error: 'Unauthorized' }; - } - - if (!isAdmin && flight.flight_status !== 'pending') { - await client.query('ROLLBACK'); - return { success: false, error: 'Can only delete pending flights' }; - } - - await client.query(` - DELETE FROM logbook_active_flights - WHERE flight_id = $1 - `, [flightId]); - - await client.query(` - DELETE FROM logbook_telemetry - WHERE flight_id = $1 - `, [flightId]); - - await client.query(` - DELETE FROM logbook_flights - WHERE id = $1 - `, [flightId]); - - await client.query('COMMIT'); - - if (flight.flight_status === 'completed') { - try { - await updateUserStatsCache(flight.user_id); - } catch (error) { - console.error('Error updating stats cache after deletion:', error); - } - } - - return { success: true }; - } catch (error) { - await client.query('ROLLBACK'); - console.error('Error deleting flight:', error); - throw error; - } finally { - client.release(); - } -} - -export async function storeWaypoint(robloxUsername, waypointData) { - const result = await pool.query(` - SELECT collected_waypoints FROM logbook_active_flights - WHERE roblox_username = $1 - `, [robloxUsername]); - - if (result.rows.length === 0) { - console.warn(`[Waypoint] No active flight found for ${robloxUsername}`); - return; - } - - const existingWaypoints = result.rows[0].collected_waypoints || []; - - const updatedWaypoints = [...existingWaypoints, waypointData]; - - await pool.query(` - UPDATE logbook_active_flights - SET collected_waypoints = $2::jsonb - WHERE roblox_username = $1 - `, [robloxUsername, JSON.stringify(updatedWaypoints)]); - -} -export async function finalizeLandingFromWaypoints(robloxUsername) { - const result = await pool.query(` - SELECT collected_waypoints, flight_id - FROM logbook_active_flights - WHERE roblox_username = $1 - `, [robloxUsername]); - - if (result.rows.length === 0 || !result.rows[0].collected_waypoints) { - console.log(`[Waypoint] No waypoints collected for ${robloxUsername}`); - return null; - } - - const waypoints = result.rows[0].collected_waypoints; - const flightId = result.rows[0].flight_id; - - if (waypoints.length === 0) { - return null; - } - - const timestamps = waypoints.map(w => w.timestamp); - const maxTimestamp = Math.max(...timestamps); - - const recentCluster = waypoints.filter(w => { - const timeDiff = maxTimestamp - w.timestamp; - return timeDiff <= 90; - }); - - if (recentCluster.length === 0) { - return null; - } - const selectedWaypoint = recentCluster.reduce((hardest, current) => { - const hardestRate = Math.abs(hardest.landing_speed); - const currentRate = Math.abs(current.landing_speed); - return currentRate > hardestRate ? current : hardest; - }); - - await pool.query(` - UPDATE logbook_flights - SET waypoint_landing_rate = $2, - landed_runway = $3, - landed_airport = $4 - WHERE id = $1 - `, [flightId, Math.round(selectedWaypoint.landing_speed), selectedWaypoint.runway, selectedWaypoint.airport]); - - return selectedWaypoint; -} -initializeLogbookTables(); diff --git a/server/db/logbook.ts b/server/db/logbook.ts new file mode 100644 index 0000000..13b46c4 --- /dev/null +++ b/server/db/logbook.ts @@ -0,0 +1,1056 @@ +import { mainDb } from "./connection.js"; +import { sql } from "kysely"; + +// Create indexes for performance +export async function createLogbookIndexes() { + try { + await mainDb.schema + .createIndex('idx_logbook_user') + .ifNotExists() + .on('logbook_flights') + .column('user_id') + .execute(); + + await mainDb.schema + .createIndex('idx_logbook_roblox') + .ifNotExists() + .on('logbook_flights') + .column('roblox_username') + .execute(); + + await mainDb.schema + .createIndex('idx_logbook_status') + .ifNotExists() + .on('logbook_flights') + .column('flight_status') + .execute(); + + await mainDb.schema + .createIndex('idx_telemetry_flight') + .ifNotExists() + .on('logbook_telemetry') + .columns(['flight_id', 'timestamp']) + .execute(); + + await mainDb.schema + .createIndex('idx_active_flights_callsign') + .ifNotExists() + .on('logbook_active_flights') + .column('callsign') + .execute(); + + console.log('Logbook indexes created successfully'); + } catch (error) { + console.error('Error creating logbook indexes:', error); + } +} + +export async function createFlight({ + userId, + robloxUserId, + robloxUsername, + callsign, + departureIcao, + arrivalIcao, + route, + aircraftIcao +}: { + userId: string; + robloxUserId?: string; + robloxUsername: string; + callsign: string; + departureIcao?: string; + arrivalIcao?: string; + route?: string; + aircraftIcao?: string; +}) { + const crypto = await import('crypto'); + const shareToken = crypto.randomBytes(4).toString('hex'); + + const result = await mainDb + .insertInto('logbook_flights') + .values({ + id: sql`DEFAULT`, + user_id: userId, + roblox_user_id: robloxUserId, + roblox_username: robloxUsername, + callsign, + departure_icao: departureIcao, + arrival_icao: arrivalIcao, + route, + aircraft_icao: aircraftIcao, + flight_status: 'pending', + logged_from_submit: true, + share_token: shareToken, + created_at: sql`NOW()` + }) + .returning('id') + .executeTakeFirst(); + + return result?.id; +} + +export async function startActiveFlightTracking(robloxUsername: string, callsign: string, flightId: number) { + await mainDb + .insertInto('logbook_active_flights') + .values({ + id: sql`DEFAULT`, + roblox_username: robloxUsername, + callsign, + flight_id: flightId, + created_at: sql`NOW()` + }) + .onConflict((oc) => + oc.column('roblox_username').doUpdateSet({ + callsign, + flight_id: flightId, + created_at: sql`NOW()` + }) + ) + .execute(); +} + +export async function getActiveFlightByUsername(robloxUsername: string) { + const result = await mainDb + .selectFrom('logbook_active_flights') + .selectAll() + .where('roblox_username', '=', robloxUsername) + .executeTakeFirst(); + + return result || null; +} + +export async function storeTelemetryPoint( + flightId: number, + { x, y, altitude, speed, heading, timestamp, phase, verticalSpeed }: { + x?: number; + y?: number; + altitude?: number; + speed?: number; + heading?: number; + timestamp: Date | string; + phase?: string; + verticalSpeed?: number; + } +) { + const utcTimestamp = timestamp instanceof Date ? timestamp : new Date(timestamp); + + await mainDb + .insertInto('logbook_telemetry') + .values({ + id: sql`DEFAULT`, + flight_id: flightId, + timestamp: utcTimestamp, + x, + y, + altitude_ft: altitude, + speed_kts: speed, + heading, + flight_phase: phase, + vertical_speed_fpm: verticalSpeed ?? 0 + }) + .execute(); +} + +export async function updateActiveFlightState( + robloxUsername: string, + { altitude, speed, heading, x, y, phase }: { + altitude?: number; + speed?: number; + heading?: number; + x?: number; + y?: number; + phase?: string; + } +) { + await mainDb + .updateTable('logbook_active_flights') + .set({ + last_update: sql`NOW()`, + last_altitude: altitude, + last_speed: speed, + last_heading: heading, + last_x: x, + last_y: y, + current_phase: phase + }) + .where('roblox_username', '=', robloxUsername) + .execute(); +} + +export async function addApproachAltitude(robloxUsername: string, altitude: number, timestamp: Date) { + await mainDb + .updateTable('logbook_active_flights') + .set({ + approach_altitudes: sql`array_append(COALESCE(approach_altitudes, ARRAY[]::INTEGER[]), ${altitude})`, + approach_timestamps: sql`array_append(COALESCE(approach_timestamps, ARRAY[]::TIMESTAMP[]), ${timestamp})` + }) + .where('roblox_username', '=', robloxUsername) + .execute(); + + // Keep only last 30 entries + await mainDb + .updateTable('logbook_active_flights') + .set({ + approach_altitudes: sql`approach_altitudes[greatest(1, array_length(approach_altitudes, 1) - 29):]`, + approach_timestamps: sql`approach_timestamps[greatest(1, array_length(approach_timestamps, 1) - 29):]` + }) + .where('roblox_username', '=', robloxUsername) + .execute(); +} + +export async function calculateLandingRate(robloxUsername: string): Promise { + const flightResult = await mainDb + .selectFrom('logbook_active_flights as laf') + .innerJoin('logbook_flights as lf', 'laf.flight_id', 'lf.id') + .select(['laf.flight_id', 'lf.waypoint_landing_rate']) + .where('laf.roblox_username', '=', robloxUsername) + .executeTakeFirst(); + + if (!flightResult) { + return null; + } + + const { flight_id: flightId, waypoint_landing_rate } = flightResult; + + if (waypoint_landing_rate !== null && waypoint_landing_rate !== undefined) { + console.log(`[Landing Rate] Using waypoint data: ${waypoint_landing_rate} fpm`); + return waypoint_landing_rate; + } + + if (flightId === undefined) { + return null; + } + const telemetryResult = await mainDb + .selectFrom('logbook_telemetry') + .select(['altitude_ft', 'vertical_speed_fpm', 'timestamp', 'flight_phase']) + .where('flight_id', '=', flightId) + .where('flight_phase', 'in', ['approach', 'landing']) + .where('altitude_ft', '<', 100) + .orderBy('altitude_ft', 'asc') + .limit(1) + .executeTakeFirst(); + + if (!telemetryResult) { + return null; + } + + console.log(`[Landing Rate] Using telemetry data: ${telemetryResult.vertical_speed_fpm} fpm`); + return telemetryResult.vertical_speed_fpm || null; +} + +export async function finalizeFlight(flightId: number, stats: { + durationMinutes: number; + totalDistance: number; + maxAltitude: number; + maxSpeed: number; + averageSpeed: number; + landingRate: number | null; + smoothnessScore: number; + landingScore: number; +}) { + await mainDb + .updateTable('logbook_flights') + .set({ + flight_end: sql`NOW()`, + duration_minutes: stats.durationMinutes, + total_distance_nm: stats.totalDistance, + max_altitude_ft: stats.maxAltitude, + max_speed_kts: stats.maxSpeed, + average_speed_kts: stats.averageSpeed, + landing_rate_fpm: stats.landingRate ?? undefined, + smoothness_score: stats.smoothnessScore, + landing_score: stats.landingScore, + flight_status: 'completed' + }) + .where('id', '=', flightId) + .execute(); +} + +export async function removeActiveFlightTracking(robloxUsername: string) { + await mainDb + .deleteFrom('logbook_active_flights') + .where('roblox_username', '=', robloxUsername) + .execute(); +} + +export async function getActiveFlightByCallsign(callsign: string) { + const result = await mainDb + .selectFrom('logbook_active_flights as laf') + .innerJoin('logbook_flights as lf', 'laf.flight_id', 'lf.id') + .selectAll('laf') + .select(['lf.id as flight_id', 'lf.user_id', 'lf.flight_status']) + .where('laf.callsign', '=', callsign) + .executeTakeFirst(); + + return result || null; +} + +export async function activateFlightByCallsign(callsign: string): Promise { + return await mainDb.transaction().execute(async (trx) => { + const result = await trx + .updateTable('logbook_flights as lf') + .from('logbook_active_flights as laf') + .set({ + flight_status: 'active', + flight_start: sql`NOW()`, + activated_at: sql`NOW()`, + controller_managed: true + }) + .where('laf.callsign', '=', callsign) + .whereRef('laf.flight_id', '=', 'lf.id') + .where('lf.flight_status', '=', 'pending') + .returning('lf.id') + .executeTakeFirst(); + + return result?.id || null; + }); +} + +export async function completeFlightByCallsign(callsign: string): Promise { + return await mainDb.transaction().execute(async (trx) => { + // 1. Get flight and active data + const flightResult = await trx + .selectFrom('logbook_active_flights as laf') + .innerJoin('logbook_flights as lf', 'laf.flight_id', 'lf.id') + .selectAll('lf') + .select([ + 'laf.roblox_username', + 'laf.approach_altitudes', + 'laf.approach_timestamps' + ]) + .where('laf.callsign', '=', callsign) + .where('lf.flight_status', 'in', ['active', 'pending']) + .executeTakeFirst(); + + if (!flightResult) { + // Try to log why + const checkFlight = await trx + .selectFrom('logbook_flights as lf') + .leftJoin('logbook_active_flights as laf', 'laf.flight_id', 'lf.id') + .select([ + 'lf.flight_status', + 'lf.callsign', + 'laf.callsign as active_callsign' + ]) + .where('lf.callsign', '=', callsign) + .executeTakeFirst(); + + if (checkFlight) { + console.error(`[Logbook] Cannot complete ${callsign}: flight_status="${checkFlight.flight_status}", in_active_table=${!!checkFlight.active_callsign}`); + } else { + console.error(`[Logbook] Cannot complete ${callsign}: flight not found in logbook`); + } + return null; + } + + // 2. Telemetry stats + const stats = await trx + .selectFrom('logbook_telemetry') + .select(({ fn }) => [ + fn.max('altitude_ft').as('max_altitude'), + fn.max('speed_kts').as('max_speed'), + fn.avg(sql`CASE WHEN speed_kts > 10 THEN speed_kts ELSE NULL END`).as('avg_speed'), + fn.countAll().as('telemetry_count'), + fn.min('timestamp').as('first_telemetry'), + fn.max('timestamp').as('last_telemetry') + ]) + .where('flight_id', '=', flightResult.id) + .executeTakeFirst(); + + // 3. Calculate total distance + const telemetryPoints = await trx + .selectFrom('logbook_telemetry') + .select(['x', 'y', 'timestamp']) + .where('flight_id', '=', flightResult.id) + .orderBy('timestamp', 'asc') + .execute(); + + let totalDistance = 0; + for (let i = 1; i < telemetryPoints.length; i++) { + const prev = telemetryPoints[i - 1]; + const curr = telemetryPoints[i]; + if (prev.x != null && prev.y != null && curr.x != null && curr.y != null) { + const dx = curr.x - prev.x; + const dy = curr.y - prev.y; + const distance = Math.sqrt(dx * dx + dy * dy) / 1852; + totalDistance += distance; + } + } + + // 4. Duration + const now = new Date(); + const startTime = flightResult.flight_start ?? flightResult.created_at ?? null; + const durationMinutes = startTime + ? Math.round((now.getTime() - new Date(startTime as string | number | Date).getTime()) / 60000) + : 0; + + // 5. Landing rate from approach altitudes + let landingRate: number | null = null; + if ( + Array.isArray(flightResult.approach_altitudes) && + flightResult.approach_altitudes.length >= 2 && + Array.isArray(flightResult.approach_timestamps) && + flightResult.approach_timestamps.length >= 2 + ) { + const altitudes = flightResult.approach_altitudes; + const timestamps = flightResult.approach_timestamps; + const firstAlt = altitudes[0]; + const lastAlt = altitudes[altitudes.length - 1]; + const firstTime = new Date(timestamps[0] as string | number | Date); + const lastTime = new Date(timestamps[timestamps.length - 1] as string | number | Date); + const altChange = firstAlt - lastAlt; + const timeChange = (lastTime.getTime() - firstTime.getTime()) / 1000; + if (timeChange > 0) { + const feetPerSecond = altChange / timeChange; + landingRate = -Math.round(feetPerSecond * 60); + } + } + + // 6. Landing score + let landingScore: number | null = null; + if (landingRate !== null) { + const absLandingRate = Math.abs(landingRate); + if (absLandingRate <= 100) landingScore = 100; + else if (absLandingRate <= 200) landingScore = 90; + else if (absLandingRate <= 300) landingScore = 80; + else if (absLandingRate <= 400) landingScore = 70; + else if (absLandingRate <= 500) landingScore = 60; + else if (absLandingRate <= 600) landingScore = 50; + else if (absLandingRate <= 700) landingScore = 40; + else if (absLandingRate <= 800) landingScore = 30; + else landingScore = 20; + } + + // 7. Smoothness score + let smoothnessScore: number | null = null; + const telemetryData = await trx + .selectFrom('logbook_telemetry') + .select(['speed_kts', 'vertical_speed_fpm', 'heading']) + .where('flight_id', '=', flightResult.id) + .orderBy('timestamp', 'asc') + .execute(); + + if (telemetryData.length > 2) { + let score = 100; + let speedPenalty = 0; + let verticalSpeedPenalty = 0; + let headingPenalty = 0; + let validComparisons = 0; + + for (let i = 1; i < telemetryData.length; i++) { + const prev = telemetryData[i - 1]; + const curr = telemetryData[i]; + validComparisons++; + + if (prev.speed_kts != null && curr.speed_kts != null) { + const speedChange = Math.abs(curr.speed_kts - prev.speed_kts); + if (speedChange > 30) speedPenalty += 3; + else if (speedChange > 20) speedPenalty += 2; + else if (speedChange > 10) speedPenalty += 1; + } + + if (prev.vertical_speed_fpm != null && curr.vertical_speed_fpm != null) { + const vsChange = Math.abs(curr.vertical_speed_fpm - prev.vertical_speed_fpm); + if (vsChange > 500) verticalSpeedPenalty += 3; + else if (vsChange > 300) verticalSpeedPenalty += 2; + else if (vsChange > 150) verticalSpeedPenalty += 1; + } + + if (prev.heading != null && curr.heading != null) { + let headingChange = Math.abs(curr.heading - prev.heading); + if (headingChange > 180) headingChange = 360 - headingChange; + + if (headingChange > 30) headingPenalty += 2; + else if (headingChange > 20) headingPenalty += 1; + } + } + + if (validComparisons > 0) { + const totalPenalty = (speedPenalty * 0.4) + (verticalSpeedPenalty * 0.4) + (headingPenalty * 0.2); + const avgPenalty = totalPenalty / validComparisons; + score = 100 - Math.min(avgPenalty * 10, 100); + } + + smoothnessScore = Math.max(0, Math.min(100, Math.round(score))); + } + + // 8. Update logbook_flights + await trx + .updateTable('logbook_flights') + .set({ + flight_end: sql`NOW()`, + duration_minutes: durationMinutes, + total_distance_nm: Math.round(totalDistance * 100) / 100, + max_altitude_ft: stats?.max_altitude ?? 0, + max_speed_kts: stats?.max_speed ?? 0, + average_speed_kts: stats?.avg_speed ? Math.round(Number(stats.avg_speed)) : 0, + landing_rate_fpm: landingRate ?? undefined, + smoothness_score: smoothnessScore ?? undefined, + landing_score: landingScore ?? undefined, + flight_status: 'completed', + controller_managed: true + }) + .where('id', '=', flightResult.id) + .execute(); + + // 9. Remove from active flights + await trx + .deleteFrom('logbook_active_flights') + .where('callsign', '=', callsign) + .execute(); + + // 10. Update stats cache (non-blocking) + setTimeout(() => { + updateUserStatsCache(flightResult.user_id).catch(console.error); + }, 100); + + return flightResult.id; + }); +} + +export async function getUserFlights( + userId: string, + page: number = 1, + limit: number = 20, + status: string = 'completed' +) { + const offset = (page - 1) * limit; + const useCreatedAt = (status === 'pending' || status === 'active'); + const orderByColumn = useCreatedAt ? 'lf.created_at' : sql`COALESCE(lf.flight_start, lf.created_at)`; + + const flights = await mainDb + .selectFrom('logbook_flights as lf') + .leftJoin('logbook_active_flights as laf', 'laf.flight_id', 'lf.id') + .selectAll('lf') + .select('laf.current_phase') + .where('lf.user_id', '=', userId) + .where('lf.flight_status', '=', status) + .orderBy(orderByColumn, 'desc') + .limit(limit) + .offset(offset) + .execute(); + + const countResult = await mainDb + .selectFrom('logbook_flights') + .select(({ fn }) => [fn.countAll().as('count')]) + .where('user_id', '=', userId) + .where('flight_status', '=', status) + .executeTakeFirst(); + + const total = Number(countResult?.count ?? 0); + + return { + flights, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit), + hasMore: page < Math.ceil(total / limit) + } + }; +} + +export async function getFlightById(flightId: number) { + const result = await mainDb + .selectFrom('logbook_flights') + .selectAll() + .where('id', '=', flightId) + .executeTakeFirst(); + + return result || null; +} + +export async function getActiveFlightData(flightId: number) { + const flight = await getFlightById(flightId); + if (!flight) return null; + + if (flight.flight_status !== 'active' && flight.flight_status !== 'pending') { + return flight; + } + + const activeResult = await mainDb + .selectFrom('logbook_active_flights') + .selectAll() + .where('flight_id', '=', flightId) + .executeTakeFirst(); + + const statsResult = await mainDb + .selectFrom('logbook_telemetry') + .select(({ fn }) => [ + fn.countAll().as('telemetry_count'), + fn.max('altitude_ft').as('max_altitude_ft'), + fn.max('speed_kts').as('max_speed_kts'), + fn.avg(sql`CASE WHEN altitude_ft > 100 THEN speed_kts ELSE NULL END`).as('avg_speed_kts') + ]) + .where('flight_id', '=', flightId) + .executeTakeFirst(); + + // Calculate distance using window function + const distanceResult = await mainDb + .selectFrom((eb) => + eb.selectFrom('logbook_telemetry') + .select([ + 'x', 'y', 'timestamp', + sql`LAG(x) OVER (ORDER BY timestamp)`.as('prev_x'), + sql`LAG(y) OVER (ORDER BY timestamp)`.as('prev_y') + ]) + .where('flight_id', '=', flightId) + .orderBy('timestamp', 'asc') + .as('telemetry_points') + ) + .select( + sql`SUM( + CASE + WHEN prev_x IS NOT NULL AND prev_y IS NOT NULL + THEN SQRT(POWER(x - prev_x, 2) + POWER(y - prev_y, 2)) / 1852 + ELSE 0 + END + )`.as('total_distance') + ) + .executeTakeFirst(); + + const totalDistanceNm = distanceResult?.total_distance ? + Math.round(distanceResult.total_distance) : null; + + // Calculate smoothness score + const telemetryResult = await mainDb + .selectFrom('logbook_telemetry') + .select(['speed_kts', 'altitude_ft']) + .where('flight_id', '=', flightId) + .orderBy('timestamp', 'asc') + .execute(); + + let smoothnessScore = null; + if (telemetryResult.length > 1) { + let score = 100; + for (let i = 1; i < telemetryResult.length; i++) { + const speedDelta = Math.abs((telemetryResult[i].speed_kts || 0) - (telemetryResult[i - 1].speed_kts || 0)); + const altDelta = Math.abs((telemetryResult[i].altitude_ft || 0) - (telemetryResult[i - 1].altitude_ft || 0)); + + if (speedDelta > 20) score -= 2; + if (altDelta > 500) score -= 3; + } + smoothnessScore = Math.max(0, Math.min(100, score)); + } + + // Get landing rate if landed + let landingRate = null; + if (activeResult?.landing_detected && activeResult?.roblox_username) { + landingRate = await calculateLandingRate(activeResult.roblox_username); + } + + const durationMs = new Date().getTime() - new Date(flight.created_at!).getTime(); + const durationMinutes = Math.round(durationMs / 60000); + + return { + ...flight, + current_altitude: activeResult?.last_altitude || null, + current_speed: activeResult?.last_speed || null, + current_heading: activeResult?.last_heading || null, + current_phase: activeResult?.current_phase || null, + last_update: activeResult?.last_update || null, + landing_detected: activeResult?.landing_detected || false, + stationary_notification_sent: activeResult?.stationary_notification_sent || false, + duration_minutes: durationMinutes, + max_altitude_ft: statsResult?.max_altitude_ft || null, + max_speed_kts: statsResult?.max_speed_kts || null, + average_speed_kts: statsResult?.avg_speed_kts ? Math.round(Number(statsResult.avg_speed_kts)) : null, + total_distance_nm: totalDistanceNm, + smoothness_score: smoothnessScore, + landing_rate_fpm: landingRate, + telemetry_count: Number(statsResult?.telemetry_count) || 0, + is_active: true + }; +} + +export async function getFlightTelemetry(flightId: number) { + const result = await mainDb + .selectFrom('logbook_telemetry') + .selectAll() + .where('flight_id', '=', flightId) + .orderBy('timestamp', 'asc') + .execute(); + + return result; +} + +export async function getUserStats(userId: string) { + let result = await mainDb + .selectFrom('logbook_stats_cache') + .selectAll() + .where('user_id', '=', userId) + .executeTakeFirst(); + + if (!result) { + await mainDb + .insertInto('logbook_stats_cache') + .values({ user_id: userId }) + .execute(); + + result = await mainDb + .selectFrom('logbook_stats_cache') + .selectAll() + .where('user_id', '=', userId) + .executeTakeFirst(); + } + + return result; +} + +export async function generateShareToken(flightId: number, userId: string): Promise { + const flight = await mainDb + .selectFrom('logbook_flights') + .select(['share_token', 'user_id']) + .where('id', '=', flightId) + .executeTakeFirst(); + + if (!flight) { + throw new Error('Flight not found'); + } + + if (flight.user_id !== userId) { + throw new Error('Not authorized'); + } + + if (flight.share_token) { + return flight.share_token; + } + + const crypto = await import('crypto'); + const shareToken = crypto.randomBytes(4).toString('hex'); + + await mainDb + .updateTable('logbook_flights') + .set({ share_token: shareToken }) + .where('id', '=', flightId) + .execute(); + + return shareToken; +} + +export async function getFlightByShareToken(shareToken: string) { + const result = await mainDb + .selectFrom('logbook_flights as f') + .leftJoin('users as u', 'f.user_id', 'u.id') + .selectAll('f') + .select(['u.username as discord_username', 'u.discriminator as discord_discriminator']) + .where('f.share_token', '=', shareToken) + .executeTakeFirst(); + + if (!result) { + return null; + } + + if (result.flight_status === 'active' || result.flight_status === 'pending') { + const activeData = await getActiveFlightData(result.id); + return { + ...activeData, + discord_username: result.discord_username, + discord_discriminator: result.discord_discriminator + }; + } + + return result; +} + +export async function getPublicPilotProfile(username: string) { + const userResult = await mainDb + .selectFrom('users as u') + .select([ + 'u.id', 'u.username', 'u.discriminator', 'u.avatar', + 'u.roblox_username', 'u.roblox_user_id', 'u.vatsim_cid', + 'u.vatsim_rating_id', 'u.vatsim_rating_short', 'u.vatsim_rating_long', + 'u.created_at' + ]) + .where(sql`LOWER(u.username)`, '=', username.toLowerCase()) + .executeTakeFirst(); + + if (!userResult) { + return null; + } + + // Handle VATSIM rating fallback + let vatsimShort = userResult.vatsim_rating_short; + if (!vatsimShort && userResult.vatsim_rating_id) { + const ratingId = Number(userResult.vatsim_rating_id); + const ratingMap: Record = { + 0: 'OBS', 1: 'S1', 2: 'S2', 3: 'S3', 4: 'C1', 5: 'C2', + 6: 'C3', 7: 'I1', 8: 'I2', 9: 'I3', 10: 'SUP', 11: 'ADM' + }; + vatsimShort = ratingMap[ratingId] || undefined; + } + + const rolesResult = await mainDb + .selectFrom('roles as r') + .innerJoin('user_roles as ur', 'ur.role_id', 'r.id') + .select(['r.id', 'r.name', 'r.description', 'r.color', 'r.icon', 'r.priority']) + .where('ur.user_id', '=', userResult.id) + .orderBy('r.priority', 'desc') + .orderBy('r.created_at', 'desc') + .execute(); + + const stats = await getUserStats(userResult.id); + + const recentFlights = await mainDb + .selectFrom('logbook_flights') + .select([ + 'id', 'callsign', 'aircraft_model', 'aircraft_icao', + 'departure_icao', 'arrival_icao', 'duration_minutes', + 'total_distance_nm', 'landing_rate_fpm', 'created_at', 'flight_end' + ]) + .where('user_id', '=', userResult.id) + .where('flight_status', '=', 'completed') + .orderBy('flight_end', 'desc') + .limit(10) + .execute(); + + const activityData = await mainDb + .selectFrom('logbook_flights') + .select([ + sql`DATE_TRUNC('month', flight_end)`.as('month'), + mainDb.fn.countAll().as('flight_count'), + mainDb.fn.sum('duration_minutes').as('total_minutes') + ]) + .where('user_id', '=', userResult.id) + .where('flight_status', '=', 'completed') + .where('flight_end', '>=', new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) + .groupBy(sql`DATE_TRUNC('month', flight_end)`) + .orderBy('month', 'desc') + .execute(); + + return { + user: { + ...userResult, + vatsim_rating_short: vatsimShort, + member_since: userResult.created_at, + roles: rolesResult, + role_name: rolesResult[0]?.name || null, + role_description: rolesResult[0]?.description || null + }, + stats, + recentFlights, + activityData + }; +} + +export async function updateUserStatsCache(userId: string) { + const totals = await mainDb + .selectFrom('logbook_flights') + .select(({ fn }) => [ + fn.countAll().as('total_flights'), + fn.sum(sql`CASE WHEN duration_minutes > 0 THEN duration_minutes ELSE 0 END`).as('total_minutes'), + sql`COALESCE(SUM(CASE WHEN duration_minutes > 0 THEN duration_minutes ELSE 0 END) / 60.0, 0)`.as('total_hours'), + fn.sum('total_distance_nm').as('total_distance') + ]) + .where('user_id', '=', userId) + .where('flight_status', '=', 'completed') + .executeTakeFirst(); + + const favAircraft = await mainDb + .selectFrom('logbook_flights') + .select(['aircraft_model', ({ fn }) => fn.countAll().as('count')]) + .where('user_id', '=', userId) + .where('flight_status', '=', 'completed') + .where('aircraft_model', 'is not', null) + .groupBy('aircraft_model') + .orderBy('count', 'desc') + .limit(1) + .executeTakeFirst(); + + const favDeparture = await mainDb + .selectFrom('logbook_flights') + .select(['departure_icao', ({ fn }) => fn.countAll().as('count')]) + .where('user_id', '=', userId) + .where('flight_status', '=', 'completed') + .where('departure_icao', 'is not', null) + .groupBy('departure_icao') + .orderBy('count', 'desc') + .limit(1) + .executeTakeFirst(); + + const smoothestLanding = await mainDb + .selectFrom('logbook_flights') + .select(['id', 'landing_rate_fpm']) + .where('user_id', '=', userId) + .where('flight_status', '=', 'completed') + .where('landing_rate_fpm', 'is not', null) + .orderBy(sql`ABS(landing_rate_fpm)`, 'asc') + .limit(1) + .executeTakeFirst(); + + const avgLandingScore = await mainDb + .selectFrom('logbook_flights') + .select(({ fn }) => fn.avg('landing_score').as('avg_score')) + .where('user_id', '=', userId) + .where('flight_status', '=', 'completed') + .where('landing_score', 'is not', null) + .executeTakeFirst(); + + const highestAlt = await mainDb + .selectFrom('logbook_flights') + .select(({ fn }) => fn.max('max_altitude_ft').as('highest_altitude')) + .where('user_id', '=', userId) + .where('flight_status', '=', 'completed') + .executeTakeFirst(); + + const longestFlight = await mainDb + .selectFrom('logbook_flights') + .select(['id', 'total_distance_nm']) + .where('user_id', '=', userId) + .where('flight_status', '=', 'completed') + .orderBy('total_distance_nm', 'desc') + .limit(1) + .executeTakeFirst(); + + await mainDb + .updateTable('logbook_stats_cache') + .set({ + total_flights: Number(totals?.total_flights) || 0, + total_hours: Number(totals?.total_hours) || 0, + total_flight_time_minutes: Number(totals?.total_minutes) || 0, + total_distance_nm: Number(totals?.total_distance) || 0, + favorite_aircraft: favAircraft?.aircraft_model ?? undefined, + favorite_aircraft_count: Number(favAircraft?.count) || 0, + favorite_departure: favDeparture?.departure_icao ?? undefined, + favorite_departure_count: Number(favDeparture?.count) || 0, + smoothest_landing_rate: smoothestLanding?.landing_rate_fpm ?? undefined, + smoothest_landing_flight_id: smoothestLanding?.id ?? undefined, + best_landing_rate: smoothestLanding?.landing_rate_fpm ?? undefined, + average_landing_score: avgLandingScore?.avg_score ? Number(avgLandingScore.avg_score) : undefined, + highest_altitude: highestAlt?.highest_altitude ?? undefined, + longest_flight_distance: longestFlight?.total_distance_nm ? Number(longestFlight.total_distance_nm) : undefined, + longest_flight_id: longestFlight?.id ?? undefined, + last_updated: sql`NOW()` + }) + .where('user_id', '=', userId) + .execute(); +} + +export async function deleteFlightById(flightId: number, userId: string, isAdmin: boolean = false) { + return await mainDb.transaction().execute(async (trx) => { + const flightCheck = await trx + .selectFrom('logbook_flights') + .select(['id', 'user_id', 'flight_status']) + .where('id', '=', flightId) + .executeTakeFirst(); + + if (!flightCheck) { + return { success: false, error: 'Flight not found' }; + } + + if (flightCheck.user_id !== userId && !isAdmin) { + return { success: false, error: 'Unauthorized' }; + } + + if (!isAdmin && flightCheck.flight_status !== 'pending') { + return { success: false, error: 'Can only delete pending flights' }; + } + + // Delete related data + await trx + .deleteFrom('logbook_active_flights') + .where('flight_id', '=', flightId) + .execute(); + + await trx + .deleteFrom('logbook_telemetry') + .where('flight_id', '=', flightId) + .execute(); + + await trx + .deleteFrom('logbook_flights') + .where('id', '=', flightId) + .execute(); + + // Update stats cache if completed flight + if (flightCheck.flight_status === 'completed') { + setTimeout(() => { + updateUserStatsCache(flightCheck.user_id).catch(console.error); + }, 100); + } + + return { success: true }; + }); +} + +type Waypoint = { + timestamp: number; + landing_speed: number; + runway?: string; + airport?: string; + [key: string]: unknown; +}; + +export async function storeWaypoint(robloxUsername: string, waypointData: Waypoint) { + const result = await mainDb + .selectFrom('logbook_active_flights') + .select('collected_waypoints') + .where('roblox_username', '=', robloxUsername) + .executeTakeFirst(); + + if (!result) { + console.warn(`[Waypoint] No active flight found for ${robloxUsername}`); + return; + } + + const existingWaypoints: Waypoint[] = Array.isArray(result.collected_waypoints) ? result.collected_waypoints : []; + const updatedWaypoints = [...existingWaypoints, waypointData]; + + await mainDb + .updateTable('logbook_active_flights') + .set({ collected_waypoints: updatedWaypoints }) + .where('roblox_username', '=', robloxUsername) + .execute(); +} + +export async function finalizeLandingFromWaypoints(robloxUsername: string) { + const result = await mainDb + .selectFrom('logbook_active_flights') + .select(['collected_waypoints', 'flight_id']) + .where('roblox_username', '=', robloxUsername) + .executeTakeFirst(); + + if (!result || !result.collected_waypoints) { + console.log(`[Waypoint] No waypoints collected for ${robloxUsername}`); + return null; + } + + const waypoints = result.collected_waypoints as Waypoint[]; + if (waypoints.length === 0) { + return null; + } + + const timestamps = waypoints.map(w => w.timestamp); + const maxTimestamp = Math.max(...timestamps); + + const recentCluster = waypoints.filter(w => { + const timeDiff = maxTimestamp - w.timestamp; + return timeDiff <= 90; + }); + + if (recentCluster.length === 0) { + return null; + } + + const selectedWaypoint = recentCluster.reduce((hardest, current) => { + const hardestRate = Math.abs(hardest.landing_speed); + const currentRate = Math.abs(current.landing_speed); + return currentRate > hardestRate ? current : hardest; + }); + + await mainDb + .updateTable('logbook_flights') + .set({ + waypoint_landing_rate: Math.round(selectedWaypoint.landing_speed), + landed_runway: selectedWaypoint.runway, + landed_airport: selectedWaypoint.airport + }) + .where('id', '=', result.flight_id!) + .execute(); + + return selectedWaypoint; +} + +createLogbookIndexes().catch(console.error); \ No newline at end of file diff --git a/server/db/notifications.js b/server/db/notifications.js deleted file mode 100644 index 58cd0dc..0000000 --- a/server/db/notifications.js +++ /dev/null @@ -1,130 +0,0 @@ -import pool from './connections/connection.js'; - -async function initializeNotificationsTable() { - try { - const result = await pool.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'notifications' - ) - `); - const exists = result.rows[0].exists; - - if (!exists) { - await pool.query(` - CREATE TABLE notifications ( - id SERIAL PRIMARY KEY, - type VARCHAR(20) NOT NULL CHECK (type IN ('info', 'warning', 'success', 'error')), - text TEXT NOT NULL, - show BOOLEAN DEFAULT false, - custom_color VARCHAR(7), -- Hex color like #FFFFFF - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() - ) - `); - } - } catch (error) { - console.error('Error initializing notifications table:', error); - } -} - -export async function getAllNotifications() { - try { - const result = await pool.query(` - SELECT * FROM notifications ORDER BY created_at DESC - `); - return result.rows; - } catch (error) { - console.error('Error fetching notifications:', error); - throw error; - } -} - -export async function getActiveNotifications() { - try { - const result = await pool.query(` - SELECT * FROM notifications WHERE show = true ORDER BY created_at DESC - `); - return result.rows; - } catch (error) { - console.error('Error fetching active notifications:', error); - throw error; - } -} - -export async function addNotification({ type, text, show = false, customColor = null }) { - try { - const result = await pool.query(` - INSERT INTO notifications (type, text, show, custom_color) - VALUES ($1, $2, $3, $4) - RETURNING * - `, [type, text, show, customColor]); - return result.rows[0]; - } catch (error) { - console.error('Error adding notification:', error); - throw error; - } -} - -export async function updateNotification(id, { type, text, show, customColor }) { - try { - let setClause = []; - let values = []; - let paramIndex = 1; - - if (type !== undefined) { - setClause.push(`type = $${paramIndex++}`); - values.push(type); - } - if (text !== undefined) { - setClause.push(`text = $${paramIndex++}`); - values.push(text); - } - if (show !== undefined) { - setClause.push(`show = $${paramIndex++}`); - values.push(show); - } - if (customColor !== undefined) { - setClause.push(`custom_color = $${paramIndex++}`); - values.push(customColor); - } - - if (setClause.length === 0) { - throw new Error('No fields provided for update'); - } - - setClause.push(`updated_at = NOW()`); - const query = ` - UPDATE notifications - SET ${setClause.join(', ')} - WHERE id = $${paramIndex} - RETURNING * - `; - values.push(id); - - const result = await pool.query(query, values); - if (result.rows.length === 0) { - throw new Error('Notification not found'); - } - return result.rows[0]; - } catch (error) { - console.error('Error updating notification:', error); - throw error; - } -} - -export async function deleteNotification(id) { - try { - const result = await pool.query(` - DELETE FROM notifications WHERE id = $1 RETURNING * - `, [id]); - return result.rows[0]; - } catch (error) { - console.error('Error deleting notification:', error); - throw error; - } -} - -initializeNotificationsTable(); - -export { initializeNotificationsTable }; \ No newline at end of file diff --git a/server/db/notifications.ts b/server/db/notifications.ts new file mode 100644 index 0000000..d768398 --- /dev/null +++ b/server/db/notifications.ts @@ -0,0 +1,111 @@ +import { mainDb } from "./connection.js"; +import { sql } from "kysely"; + +export async function getAllNotifications() { + try { + const notifications = await mainDb + .selectFrom('notifications') + .selectAll() + .orderBy('created_at', 'desc') + .execute(); + return notifications; + } catch (error) { + console.error('Error fetching notifications:', error); + throw error; + } +} + +export async function getActiveNotifications() { + try { + const notifications = await mainDb + .selectFrom('notifications') + .selectAll() + .where('show', '=', true) + .orderBy('created_at', 'desc') + .execute(); + return notifications; + } catch (error) { + console.error('Error fetching active notifications:', error); + throw error; + } +} + +export async function addNotification({ + type, + text, + show = false, + customColor = undefined, +}: { + type: string; + text: string; + show?: boolean; + customColor?: string; +}) { + try { + const [notification] = await mainDb + .insertInto('notifications') + .values({ + id: sql`DEFAULT`, + type, + text, + show, + custom_color: customColor, + }) + .returningAll() + .execute(); + return notification; + } catch (error) { + console.error('Error adding notification:', error); + throw error; + } +} + +export async function updateNotification(id: number, { type, text, show, customColor }: { type?: string, text?: string, show?: boolean, customColor?: string | null }) { + try { + const updateData: { type?: string; text?: string; show?: boolean; custom_color?: string; updated_at?: Date } = {}; + if (type !== undefined) updateData.type = type; + if (text !== undefined) updateData.text = text; + if (show !== undefined) updateData.show = show; + if (customColor !== undefined) { + if (customColor === null) { + updateData.custom_color = undefined; + } else { + updateData.custom_color = customColor; + } + } + updateData.updated_at = new Date(); + + if (Object.keys(updateData).length === 0) { + throw new Error('No fields provided for update'); + } + + const [notification] = await mainDb + .updateTable('notifications') + .set(updateData) + .where('id', '=', id) + .returningAll() + .execute(); + + if (!notification) { + throw new Error('Notification not found'); + } + return notification; + } catch (error) { + console.error('Error updating notification:', error); + throw error; + } +} + +export async function deleteNotification(id: number) { + try { + const [notification] = await mainDb + .deleteFrom('notifications') + .where('id', '=', id) + .returningAll() + .execute(); + return notification; + } catch (error) { + console.error('Error deleting notification:', error); + throw error; + } +} diff --git a/server/db/roles.js b/server/db/roles.js deleted file mode 100644 index 2ecd9fc..0000000 --- a/server/db/roles.js +++ /dev/null @@ -1,394 +0,0 @@ -import pool from './connections/connection.js'; -import { isAdmin } from '../middleware/isAdmin.js'; - -async function initializeRolesTable() { - try { - const result = await pool.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'roles' - ) - `); - const exists = result.rows[0].exists; - - if (!exists) { - await pool.query(` - CREATE TABLE roles ( - id SERIAL PRIMARY KEY, - name VARCHAR(50) NOT NULL UNIQUE, - description TEXT, - permissions JSONB NOT NULL DEFAULT '{}', - color VARCHAR(7) DEFAULT '#6366F1', - icon VARCHAR(50) DEFAULT 'Star', - priority INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() - ) - `); - - await pool.query(` - INSERT INTO roles (name, description, permissions, color, icon, priority) - VALUES ( - 'Support', - 'Support team with limited admin access', - '{"admin": true, "users": true, "sessions": true, "audit": false, "bans": false, "testers": false, "notifications": false, "roles": false}', - '#3B82F6', - 'Wrench', - 1 - ) - `); - } else { - // Add new columns if they don't exist (migration) - const colorColumn = await pool.query(` - SELECT column_name - FROM information_schema.columns - WHERE table_name = 'roles' AND column_name = 'color' - `); - if (colorColumn.rows.length === 0) { - await pool.query(`ALTER TABLE roles ADD COLUMN color VARCHAR(7) DEFAULT '#6366F1'`); - } - - const iconColumn = await pool.query(` - SELECT column_name - FROM information_schema.columns - WHERE table_name = 'roles' AND column_name = 'icon' - `); - if (iconColumn.rows.length === 0) { - await pool.query(`ALTER TABLE roles ADD COLUMN icon VARCHAR(50) DEFAULT 'Star'`); - } - - const priorityColumn = await pool.query(` - SELECT column_name - FROM information_schema.columns - WHERE table_name = 'roles' AND column_name = 'priority' - `); - if (priorityColumn.rows.length === 0) { - await pool.query(`ALTER TABLE roles ADD COLUMN priority INTEGER DEFAULT 0`); - } - } - - // Create user_roles junction table for many-to-many relationship - const userRolesTable = await pool.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'user_roles' - ) - `); - - if (!userRolesTable.rows[0].exists) { - await pool.query(` - CREATE TABLE user_roles ( - user_id VARCHAR(20) REFERENCES users(id) ON DELETE CASCADE, - role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE, - assigned_at TIMESTAMP DEFAULT NOW(), - PRIMARY KEY (user_id, role_id) - ) - `); - - // Migrate existing single role_id to junction table - await pool.query(` - INSERT INTO user_roles (user_id, role_id) - SELECT id, role_id FROM users WHERE role_id IS NOT NULL - `); - } - - // Keep role_id for backward compatibility (will be deprecated) - const userTableResult = await pool.query(` - SELECT column_name - FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'role_id' - `); - - if (userTableResult.rows.length === 0) { - await pool.query(` - ALTER TABLE users ADD COLUMN role_id INTEGER REFERENCES roles(id) - `); - } - } catch (error) { - console.error('Error initializing roles table:', error); - } -} - -export async function getAllRoles() { - try { - const result = await pool.query(` - SELECT r.*, COUNT(DISTINCT ur.user_id) as user_count - FROM roles r - LEFT JOIN user_roles ur ON ur.role_id = r.id - GROUP BY r.id - ORDER BY r.priority DESC, r.created_at DESC - `); - return result.rows; - } catch (error) { - console.error('Error fetching roles:', error); - throw error; - } -} - -export async function getRoleById(id) { - try { - const result = await pool.query(` - SELECT * FROM roles WHERE id = $1 - `, [id]); - return result.rows[0] || null; - } catch (error) { - console.error('Error fetching role by ID:', error); - throw error; - } -} - -export async function createRole({ name, description, permissions, color, icon, priority }) { - try { - const result = await pool.query(` - INSERT INTO roles (name, description, permissions, color, icon, priority) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING * - `, [ - name, - description, - JSON.stringify(permissions), - color || '#6366F1', - icon || 'Star', - priority || 0 - ]); - return result.rows[0]; - } catch (error) { - console.error('Error creating role:', error); - throw error; - } -} - -export async function updateRole(id, { name, description, permissions, color, icon, priority }) { - try { - let setClause = []; - let values = []; - let paramIndex = 1; - - if (name !== undefined) { - setClause.push(`name = $${paramIndex++}`); - values.push(name); - } - if (description !== undefined) { - setClause.push(`description = $${paramIndex++}`); - values.push(description); - } - if (permissions !== undefined) { - setClause.push(`permissions = $${paramIndex++}`); - values.push(JSON.stringify(permissions)); - } - if (color !== undefined) { - setClause.push(`color = $${paramIndex++}`); - values.push(color); - } - if (icon !== undefined) { - setClause.push(`icon = $${paramIndex++}`); - values.push(icon); - } - if (priority !== undefined) { - setClause.push(`priority = $${paramIndex++}`); - values.push(priority); - } - - setClause.push(`updated_at = NOW()`); - const query = ` - UPDATE roles - SET ${setClause.join(', ')} - WHERE id = $${paramIndex} - RETURNING * - `; - values.push(id); - - const result = await pool.query(query, values); - return result.rows[0]; - } catch (error) { - console.error('Error updating role:', error); - throw error; - } -} - -export async function deleteRole(id) { - try { - // Delete from junction table - await pool.query(`DELETE FROM user_roles WHERE role_id = $1`, [id]); - - // Keep backward compatibility - await pool.query(`UPDATE users SET role_id = NULL WHERE role_id = $1`, [id]); - - const result = await pool.query(` - DELETE FROM roles WHERE id = $1 RETURNING * - `, [id]); - return result.rows[0]; - } catch (error) { - console.error('Error deleting role:', error); - throw error; - } -} - -export async function assignRoleToUser(userId, roleId) { - try { - // Insert into junction table (ON CONFLICT DO NOTHING to avoid duplicates) - await pool.query(` - INSERT INTO user_roles (user_id, role_id) - VALUES ($1, $2) - ON CONFLICT (user_id, role_id) DO NOTHING - `, [userId, roleId]); - - // Keep backward compatibility - set as primary role if user has no role_id - const user = await pool.query(`SELECT role_id FROM users WHERE id = $1`, [userId]); - if (!user.rows[0]?.role_id) { - await pool.query(` - UPDATE users SET role_id = $2, updated_at = NOW() - WHERE id = $1 - `, [userId, roleId]); - } - - return { userId, roleId }; - } catch (error) { - console.error('Error assigning role to user:', error); - throw error; - } -} - -export async function removeRoleFromUser(userId, roleId) { - try { - // Remove from junction table - await pool.query(` - DELETE FROM user_roles - WHERE user_id = $1 AND role_id = $2 - `, [userId, roleId]); - - // If removing the primary role, clear it - const user = await pool.query(`SELECT role_id FROM users WHERE id = $1`, [userId]); - if (user.rows[0]?.role_id === roleId) { - await pool.query(` - UPDATE users SET role_id = NULL, updated_at = NOW() - WHERE id = $1 - `, [userId]); - } - - return { userId, roleId }; - } catch (error) { - console.error('Error removing role from user:', error); - throw error; - } -} - -export async function getUserRoles(userId) { - try { - const result = await pool.query(` - SELECT r.* - FROM roles r - JOIN user_roles ur ON ur.role_id = r.id - WHERE ur.user_id = $1 - ORDER BY r.priority DESC, r.created_at DESC - `, [userId]); - return result.rows; - } catch (error) { - console.error('Error fetching user roles:', error); - throw error; - } -} - -export async function updateRolePriorities(rolePriorities) { - try { - // rolePriorities is an array of { id, priority } - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - for (const { id, priority } of rolePriorities) { - await client.query(` - UPDATE roles - SET priority = $1, updated_at = NOW() - WHERE id = $2 - `, [priority, id]); - } - - await client.query('COMMIT'); - } catch (e) { - await client.query('ROLLBACK'); - throw e; - } finally { - client.release(); - } - return true; - } catch (error) { - console.error('Error updating role priorities:', error); - throw error; - } -} - -export async function getUsersWithRoles() { - try { - // Get all users with at least one role or are admin - const usersResult = await pool.query(` - SELECT DISTINCT u.id, u.username, u.avatar, u.created_at, u.role_id - FROM users u - LEFT JOIN user_roles ur ON ur.user_id = u.id - WHERE ur.role_id IS NOT NULL OR u.role_id IS NOT NULL - ORDER BY u.username - `); - - // Get all roles for each user - const usersWithRoles = await Promise.all( - usersResult.rows.map(async (user) => { - const rolesResult = await pool.query(` - SELECT r.id, r.name, r.color, r.icon, r.priority, r.permissions - FROM roles r - JOIN user_roles ur ON ur.role_id = r.id - WHERE ur.user_id = $1 - ORDER BY r.priority DESC, r.created_at DESC - `, [user.id]); - - return { - ...user, - is_admin: isAdmin(user.id), - roles: rolesResult.rows, - // Legacy support - keep first role as primary - role_name: rolesResult.rows[0]?.name || null, - role_permissions: rolesResult.rows[0]?.permissions || null - }; - }) - ); - - // Also include pure admins - const allUsersResult = await pool.query(` - SELECT u.id, u.username, u.avatar, u.created_at, u.role_id - FROM users u - ORDER BY u.username - `); - - const allRelevantUsers = await Promise.all( - allUsersResult.rows - .filter(user => isAdmin(user.id) || usersWithRoles.find(u => u.id === user.id)) - .map(async (user) => { - const existing = usersWithRoles.find(u => u.id === user.id); - if (existing) return existing; - - return { - ...user, - is_admin: isAdmin(user.id), - roles: [], - role_name: null, - role_permissions: null - }; - }) - ); - - const uniqueUsers = allRelevantUsers.reduce((acc, user) => { - if (!acc.find(u => u.id === user.id)) { - acc.push(user); - } - return acc; - }, []); - - return uniqueUsers.sort((a, b) => a.username.localeCompare(b.username)); - } catch (error) { - console.error('Error fetching users with roles:', error); - throw error; - } -} - -initializeRolesTable(); - -export { initializeRolesTable }; \ No newline at end of file diff --git a/server/db/roles.ts b/server/db/roles.ts new file mode 100644 index 0000000..51169cf --- /dev/null +++ b/server/db/roles.ts @@ -0,0 +1,363 @@ +import { mainDb } from "./connection.js"; +import { sql } from "kysely"; +import { isAdmin } from "../middleware/admin.js"; + +export async function getAllRoles() { + try { + const result = await mainDb + .selectFrom('roles as r') + .leftJoin('user_roles as ur', 'ur.role_id', 'r.id') + .select([ + 'r.id', + 'r.name', + 'r.description', + 'r.permissions', + 'r.color', + 'r.icon', + 'r.priority', + 'r.created_at', + 'r.updated_at', + sql`COUNT(DISTINCT ur.user_id)`.as('user_count') + ]) + .groupBy('r.id') + .orderBy('r.priority', 'desc') + .orderBy('r.created_at', 'desc') + .execute(); + + return result; + } catch (error) { + console.error('Error fetching roles:', error); + throw error; + } +} + +export async function getRoleById(id: number) { + try { + const result = await mainDb + .selectFrom('roles') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return result || null; + } catch (error) { + console.error('Error fetching role by ID:', error); + throw error; + } +} + +export async function createRole({ name, description, permissions, color, icon, priority }: { + name: string; + description?: string; + permissions: Record; + color?: string; + icon?: string; + priority?: number; +}) { + try { + const result = await mainDb + .insertInto('roles') + .values({ + id: sql`DEFAULT`, + name, + description, + permissions: sql`CAST(${JSON.stringify(permissions)} AS jsonb)`, + color: color || '#6366F1', + icon: icon || 'Star', + priority: priority ?? 0, + }) + .returningAll() + .executeTakeFirst(); + + return result || null; + } catch (error) { + console.error('Error creating role:', error); + throw error; + } +} +export async function updateRole( + id: number, + { name, description, permissions, color, icon, priority }: { + name?: string; + description?: string; + permissions?: Record; + color?: string; + icon?: string; + priority?: number; + } +) { + try { + const updateData: Record = {}; + if (name !== undefined) updateData.name = name; + if (description !== undefined) updateData.description = description; + if (permissions !== undefined) updateData.permissions = sql`CAST(${JSON.stringify(permissions)} AS jsonb)`; + if (color !== undefined) updateData.color = color; + if (icon !== undefined) updateData.icon = icon; + if (priority !== undefined) updateData.priority = priority; + updateData.updated_at = sql`NOW()`; + + const result = await mainDb + .updateTable('roles') + .set(updateData) + .where('id', '=', id) + .returningAll() + .executeTakeFirst(); + + return result || null; + } catch (error) { + console.error('Error updating role:', error); + throw error; + } +} + +export async function deleteRole(id: number) { + try { + await mainDb + .deleteFrom('user_roles') + .where('role_id', '=', id) + .execute(); + + await mainDb + .updateTable('users') + .set({ role_id: undefined }) + .where('role_id', '=', id) + .execute(); + + const result = await mainDb + .deleteFrom('roles') + .where('id', '=', id) + .returningAll() + .executeTakeFirst(); + + return result || null; + } catch (error) { + console.error('Error deleting role:', error); + throw error; + } +} + +export async function assignRoleToUser(userId: string, roleId: number) { + try { + await mainDb + .insertInto('user_roles') + .values({ user_id: userId, role_id: roleId }) + .onConflict(oc => oc.columns(['user_id', 'role_id']).doNothing()) + .execute(); + + const user = await mainDb + .selectFrom('users') + .select('role_id') + .where('id', '=', userId) + .executeTakeFirst(); + + if (!user?.role_id) { + await mainDb + .updateTable('users') + .set({ role_id: roleId, updated_at: sql`NOW()` }) + .where('id', '=', userId) + .execute(); + } + + return { userId, roleId }; + } catch (error) { + console.error('Error assigning role to user:', error); + throw error; + } +} +export async function removeRoleFromUser(userId: string, roleId: number) { + try { + await mainDb + .deleteFrom('user_roles') + .where('user_id', '=', userId) + .where('role_id', '=', roleId) + .execute(); + + const user = await mainDb + .selectFrom('users') + .select('role_id') + .where('id', '=', userId) + .executeTakeFirst(); + + if (user?.role_id === roleId) { + await mainDb + .updateTable('users') + .set({ role_id: undefined, updated_at: sql`NOW()` }) + .where('id', '=', userId) + .execute(); + } + + return { userId, roleId }; + } catch (error) { + console.error('Error removing role from user:', error); + throw error; + } +} + +export async function getUserRoles(userId: string) { + try { + const result = await mainDb + .selectFrom('roles as r') + .innerJoin('user_roles as ur', 'ur.role_id', 'r.id') + .select([ + 'r.id', + 'r.name', + 'r.description', + 'r.permissions', + 'r.color', + 'r.icon', + 'r.priority', + 'r.created_at', + 'r.updated_at' + ]) + .where('ur.user_id', '=', userId) + .orderBy('r.priority', 'desc') + .orderBy('r.created_at', 'desc') + .execute(); + + return result; + } catch (error) { + console.error('Error fetching user roles:', error); + throw error; + } +} + +export async function updateRolePriorities(rolePriorities: { id: number; priority: number }[]) { + try { + await mainDb.transaction().execute(async (trx) => { + for (const { id, priority } of rolePriorities) { + await trx + .updateTable('roles') + .set({ priority, updated_at: sql`NOW()` }) + .where('id', '=', id) + .execute(); + } + }); + return true; + } catch (error) { + console.error('Error updating role priorities:', error); + throw error; + } +} + +export async function getUsersWithRoles() { + try { + const users = await mainDb + .selectFrom('users as u') + .leftJoin('user_roles as ur', 'ur.user_id', 'u.id') + .select([ + 'u.id', + 'u.username', + 'u.avatar', + 'u.created_at', + 'u.role_id' + ]) + .where(qb => + qb.or([ + qb('ur.role_id', 'is not', null), + qb('u.role_id', 'is not', null) + ]) + ) + .distinct() + .orderBy('u.username') + .execute(); + + const userIds = users.map(u => u.id); + const userRoles = await mainDb + .selectFrom('user_roles as ur') + .innerJoin('roles as r', 'ur.role_id', 'r.id') + .select([ + 'ur.user_id', + 'r.id as role_id', + 'r.name', + 'r.color', + 'r.icon', + 'r.priority', + 'r.permissions', + 'r.created_at' + ]) + .where('ur.user_id', 'in', userIds.length ? userIds : ['']) + .orderBy('r.priority', 'desc') + .orderBy('r.created_at', 'desc') + .execute(); + + type UserRole = { + id: number; + name: string; + color: string; + icon: string; + priority: number; + permissions: unknown; + }; + + const rolesByUser: Record = {}; + for (const role of userRoles) { + if (!rolesByUser[role.user_id]) rolesByUser[role.user_id] = []; + rolesByUser[role.user_id].push({ + id: role.role_id, + name: role.name, + color: role.color ?? '#6366F1', + icon: role.icon ?? 'Star', + priority: role.priority ?? 0, + permissions: role.permissions + }); + } + + const usersWithRoles = users.map(user => { + const roles = rolesByUser[user.id] || []; + return { + ...user, + is_admin: isAdmin(user.id), + roles, + role_name: roles[0]?.name || null, + role_permissions: roles[0]?.permissions || null + }; + }); + + const allUsers = await mainDb + .selectFrom('users') + .select([ + 'id', + 'username', + 'avatar', + 'created_at', + 'role_id' + ]) + .orderBy('username') + .execute(); + + const allRelevantUsers = allUsers + .filter(user => isAdmin(user.id) || usersWithRoles.find(u => u.id === user.id)) + .map(user => { + const existing = usersWithRoles.find(u => u.id === user.id); + if (existing) return existing; + return { + ...user, + is_admin: isAdmin(user.id), + roles: [], + role_name: null, + role_permissions: null + }; + }); + + type UserWithRoles = { + id: string; + username: string; + avatar: string | null; + created_at: Date; + role_id: number | null; + is_admin: boolean; + roles: UserRole[]; + role_name: string | null; + role_permissions: unknown; + }; + + const uniqueUsers = allRelevantUsers.reduce((acc: UserWithRoles[], user) => { + if (!acc.find(u => u.id === user.id)) acc.push(user as UserWithRoles); + return acc; + }, []); + + return uniqueUsers.sort((a, b) => a.username.localeCompare(b.username)); + } catch (error) { + console.error('Error fetching users with roles:', error); + throw error; + } +} \ No newline at end of file diff --git a/server/db/schemas.ts b/server/db/schemas.ts new file mode 100644 index 0000000..1a30e95 --- /dev/null +++ b/server/db/schemas.ts @@ -0,0 +1,220 @@ +import { mainDb, flightsDb, chatsDb } from './connection.js'; + +export async function createMainTables() { + // app_settings + await mainDb.schema + .createTable('app_settings') + .ifNotExists() + .addColumn('key', 'varchar(255)', (col) => col.primaryKey()) + .addColumn('value', 'text') + .execute(); + + // users (assuming based on typical UsersTable interface) + await mainDb.schema + .createTable('users') + .ifNotExists() + .addColumn('id', 'varchar(255)', (col) => col.primaryKey()) + .addColumn('username', 'varchar(255)', (col) => col.unique().notNull()) + .addColumn('email', 'varchar(255)', (col) => col.unique().notNull()) + .addColumn('password_hash', 'varchar(255)', (col) => col.notNull()) + .addColumn('created_at', 'timestamp', (col) => col.defaultTo('now()')) + .addColumn('updated_at', 'timestamp', (col) => col.defaultTo('now()')) + .execute(); + + // sessions + await mainDb.schema + .createTable('sessions') + .ifNotExists() + .addColumn('id', 'varchar(255)', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => col.references('users.id').onDelete('cascade')) + .addColumn('created_at', 'timestamp', (col) => col.defaultTo('now()')) + .addColumn('expires_at', 'timestamp') + .execute(); + + // roles + await mainDb.schema + .createTable('roles') + .ifNotExists() + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('name', 'varchar(255)', (col) => col.unique().notNull()) + .addColumn('description', 'text') + .execute(); + + // user_roles + await mainDb.schema + .createTable('user_roles') + .ifNotExists() + .addColumn('user_id', 'varchar(255)', (col) => col.references('users.id').onDelete('cascade')) + .addColumn('role_id', 'integer', (col) => col.references('roles.id').onDelete('cascade')) + .addPrimaryKeyConstraint('user_roles_pkey', ['user_id', 'role_id']) + .execute(); + + // audit_log + await mainDb.schema + .createTable('audit_log') + .ifNotExists() + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => col.references('users.id').onDelete('set null')) + .addColumn('action', 'varchar(255)', (col) => col.notNull()) + .addColumn('details', 'jsonb') + .addColumn('timestamp', 'timestamp', (col) => col.defaultTo('now()')) + .execute(); + + // bans + await mainDb.schema + .createTable('bans') + .ifNotExists() + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => col.references('users.id').onDelete('cascade').notNull()) + .addColumn('reason', 'text') + .addColumn('banned_at', 'timestamp', (col) => col.defaultTo('now()')) + .addColumn('expires_at', 'timestamp') + .execute(); + + // notifications + await mainDb.schema + .createTable('notifications') + .ifNotExists() + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('title', 'varchar(255)', (col) => col.notNull()) + .addColumn('message', 'text', (col) => col.notNull()) + .addColumn('created_at', 'timestamp', (col) => col.defaultTo('now()')) + .execute(); + + // user_notifications + await mainDb.schema + .createTable('user_notifications') + .ifNotExists() + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => col.references('users.id').onDelete('cascade')) + .addColumn('notification_id', 'integer', (col) => col.references('notifications.id').onDelete('cascade')) + .addColumn('read', 'boolean', (col) => col.defaultTo(false)) + .addColumn('read_at', 'timestamp') + .execute(); + + // testers + await mainDb.schema + .createTable('testers') + .ifNotExists() + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => col.references('users.id').onDelete('cascade').unique().notNull()) + .addColumn('approved', 'boolean', (col) => col.defaultTo(false)) + .execute(); + + // tester_settings + await mainDb.schema + .createTable('tester_settings') + .ifNotExists() + .addColumn('tester_id', 'integer', (col) => col.references('testers.id').onDelete('cascade').primaryKey()) + .addColumn('settings', 'jsonb') + .execute(); + + // daily_statistics + await mainDb.schema + .createTable('daily_statistics') + .ifNotExists() + .addColumn('date', 'date', (col) => col.primaryKey()) + .addColumn('flights_count', 'integer', (col) => col.defaultTo(0)) + .addColumn('users_count', 'integer', (col) => col.defaultTo(0)) + .execute(); + + // logbook_flights + await mainDb.schema + .createTable('logbook_flights') + .ifNotExists() + .addColumn('id', 'varchar(255)', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => col.references('users.id').onDelete('cascade')) + .addColumn('callsign', 'varchar(255)') + .addColumn('departure', 'varchar(10)') + .addColumn('arrival', 'varchar(10)') + .addColumn('aircraft', 'varchar(255)') + .addColumn('duration', 'varchar(32)') + .addColumn('created_at', 'timestamp', (col) => col.defaultTo('now()')) + .execute(); + + // logbook_telemetry + await mainDb.schema + .createTable('logbook_telemetry') + .ifNotExists() + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('flight_id', 'varchar(255)', (col) => col.references('logbook_flights.id').onDelete('cascade')) + .addColumn('timestamp', 'timestamp', (col) => col.notNull()) + .addColumn('position', 'jsonb') + .addColumn('altitude', 'integer') + .addColumn('speed', 'integer') + .execute(); + + // logbook_active_flights + await mainDb.schema + .createTable('logbook_active_flights') + .ifNotExists() + .addColumn('id', 'varchar(255)', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => col.references('users.id').onDelete('cascade')) + .addColumn('callsign', 'varchar(255)') + .addColumn('started_at', 'timestamp', (col) => col.defaultTo('now()')) + .execute(); + + // logbook_stats_cache + await mainDb.schema + .createTable('logbook_stats_cache') + .ifNotExists() + .addColumn('user_id', 'varchar(255)', (col) => col.references('users.id').onDelete('cascade').primaryKey()) + .addColumn('total_flights', 'integer', (col) => col.defaultTo(0)) + .addColumn('total_hours', 'varchar(32)', (col) => col.defaultTo('0')) + .addColumn('last_updated', 'timestamp', (col) => col.defaultTo('now()')) + .execute(); +} + +// Helper to create a dynamic flights table for a session +export async function createFlightsTable(sessionId: string) { + const tableName = `flights_${sessionId}`; + await flightsDb.schema + .createTable(tableName) + .ifNotExists() + .addColumn('id', 'varchar(255)', (col) => col.primaryKey()) + .addColumn('session_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('user_id', 'varchar(255)') + .addColumn('ip_address', 'varchar(45)') + .addColumn('callsign', 'varchar(255)') + .addColumn('aircraft', 'varchar(255)') + .addColumn('flight_type', 'varchar(50)') + .addColumn('departure', 'varchar(10)') + .addColumn('arrival', 'varchar(10)') + .addColumn('alternate', 'varchar(10)') + .addColumn('route', 'text') + .addColumn('sid', 'varchar(50)') + .addColumn('star', 'varchar(50)') + .addColumn('runway', 'varchar(10)') + .addColumn('clearedfl', 'varchar(10)') + .addColumn('cruisingfl', 'varchar(10)') + .addColumn('stand', 'varchar(10)') + .addColumn('gate', 'varchar(10)') + .addColumn('remark', 'text') + .addColumn('timestamp', 'varchar(255)') + .addColumn('created_at', 'timestamp', (col) => col.defaultTo('now()')) + .addColumn('updated_at', 'timestamp', (col) => col.defaultTo('now()')) + .addColumn('status', 'varchar(50)') + .addColumn('clearance', 'text') + .addColumn('position', 'jsonb') + .addColumn('squawk', 'varchar(10)') + .addColumn('wtc', 'varchar(5)') + .addColumn('hidden', 'boolean', (col) => col.defaultTo(false)) + .addColumn('acars_token', 'varchar(255)') + .addColumn('pdc_remarks', 'text') + .execute(); +} + +export async function createChatsTable(sessionId: string) { + const tableName = `chat_${sessionId}`; + await chatsDb.schema + .createTable(tableName) + .ifNotExists() + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)', (col) => col.notNull()) + .addColumn('username', 'varchar(255)') + .addColumn('avatar', 'varchar(255)') + .addColumn('message', 'text', (col) => col.notNull()) + .addColumn('mentions', 'jsonb') + .addColumn('sent_at', 'timestamp', (col) => col.defaultTo('now()')) + .execute(); +} \ No newline at end of file diff --git a/server/db/sessions.js b/server/db/sessions.js deleted file mode 100644 index 5600f7a..0000000 --- a/server/db/sessions.js +++ /dev/null @@ -1,204 +0,0 @@ -import pool from './connections/connection.js'; -import { encrypt, decrypt } from '../tools/encryption.js'; -import flightsPool from './connections/flightsConnection.js'; -import { validateSessionId } from '../utils/validation.js'; - -export async function initializeSessionsTable() { - try { - const result = await pool.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'sessions' - ) - `); - const exists = result.rows[0].exists; - if (!exists) { - await pool.query(` - CREATE TABLE sessions ( - session_id VARCHAR(8) PRIMARY KEY, - access_id VARCHAR(64) UNIQUE NOT NULL, - active_runway VARCHAR(10), - airport_icao VARCHAR(4) NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - created_by VARCHAR(20) NOT NULL, - is_pfatc BOOLEAN DEFAULT false, - flight_strips TEXT, - atis TEXT - ) - `); - } - } catch (error) { - console.error('Error initializing sessions table:', error); - } -} - -export async function createSession({ sessionId, accessId, activeRunway, airportIcao, createdBy, isPFATC }) { - const validSessionId = validateSessionId(sessionId); - - const encryptedAtis = encrypt({ - letter: 'A', - text: '', - timestamp: new Date().toISOString() - }); - - await pool.query(` - INSERT INTO sessions ( - session_id, access_id, active_runway, airport_icao, - created_by, is_pfatc, atis - ) VALUES ($1, $2, $3, $4, $5, $6, $7) - `, [ - validSessionId, - accessId, - activeRunway, - airportIcao.toUpperCase(), - createdBy, - isPFATC, - JSON.stringify(encryptedAtis) - ]); - - await flightsPool.query(` - CREATE TABLE IF NOT EXISTS flights_${validSessionId} ( - id VARCHAR(36) PRIMARY KEY, - session_id VARCHAR(8) NOT NULL, - user_id VARCHAR(36), - ip_address VARCHAR(45), - callsign VARCHAR(16), - aircraft VARCHAR(16), - flight_type VARCHAR(16), - departure VARCHAR(4), - arrival VARCHAR(4), - alternate VARCHAR(4), - route TEXT, - sid VARCHAR(16), - star VARCHAR(16), - runway VARCHAR(10), - clearedfl VARCHAR(8), - cruisingfl VARCHAR(8), - stand VARCHAR(8), - gate VARCHAR(8), - remark TEXT, - timestamp VARCHAR(32), - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - status VARCHAR(16), - clearance VARCHAR(16), - position JSONB, - squawk VARCHAR(8), - wtc VARCHAR(4), - hidden BOOLEAN DEFAULT false, - acars_token VARCHAR(16) - ) - `); -} - -export async function getSessionById(sessionId) { - const result = await pool.query( - 'SELECT * FROM sessions WHERE session_id = $1', - [sessionId] - ); - return result.rows[0] || null; -} - -export async function getSessionsByUser(userId) { - const result = await pool.query( - 'SELECT session_id, airport_icao, created_at, created_by, is_pfatc, active_runway FROM sessions WHERE created_by = $1 ORDER BY created_at DESC', - [userId] - ); - return result.rows; -} - -export async function updateSession(sessionId, updates) { - const fields = []; - const values = []; - let paramCounter = 1; - - if (updates.activeRunway !== undefined) { - fields.push(`active_runway = $${paramCounter++}`); - values.push(updates.activeRunway); - } - if (updates.atis !== undefined) { - fields.push(`atis = $${paramCounter++}`); - values.push(JSON.stringify(encrypt(updates.atis))); - } - if (fields.length === 0) return null; - - values.push(sessionId); - const query = `UPDATE sessions SET ${fields.join(', ')} WHERE session_id = $${paramCounter} RETURNING *`; - const result = await pool.query(query, values); - return result.rows[0] || null; -} - -export async function migrateSessionsTable() { - try { - await pool.query(`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS custom_name VARCHAR(50);`); - await pool.query(`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS refreshed_at TIMESTAMP;`); - } catch (error) { - console.error('Error migrating sessions table:', error); - } -} -migrateSessionsTable(); - -export async function updateSessionName(sessionId, customName) { - const result = await pool.query( - `UPDATE sessions SET custom_name = $2 WHERE session_id = $1 RETURNING custom_name`, - [sessionId, customName] - ); - return result.rows[0]?.custom_name || null; -} - -export async function getSessionsByUserDetailed(userId) { - const result = await pool.query( - `SELECT session_id, access_id, airport_icao, active_runway, created_at, refreshed_at, custom_name, is_pfatc - FROM sessions WHERE created_by = $1 ORDER BY created_at DESC`, - [userId] - ); - const sessions = []; - for (const row of result.rows) { - const validSessionId = validateSessionId(row.session_id); - const flightCountResult = await flightsPool.query( - `SELECT COUNT(*) as count FROM flights_${validSessionId}` - ); - const flightCount = parseInt(flightCountResult.rows[0].count, 10); - sessions.push({ - sessionId: row.session_id, - accessId: row.access_id, - airportIcao: row.airport_icao, - activeRunway: row.active_runway, - createdAt: row.created_at, - refreshedAt: row.refreshed_at, - customName: row.custom_name, - flightCount, - isLegacy: false, - isPFATC: row.is_pfatc - }); - } - return sessions; -} - -export async function deleteSession(sessionId) { - const validSessionId = validateSessionId(sessionId); - - const result = await pool.query( - 'DELETE FROM sessions WHERE session_id = $1 RETURNING session_id', - [validSessionId] - ); - - if (result.rows[0]) { - const flightsTable = `flights_${validSessionId}`; - try { - await flightsPool.query(`DROP TABLE IF EXISTS ${flightsTable}`); - } catch (err) { - console.error(`Error dropping flights table for session ${validSessionId}:`, err); - } - } - return result.rows[0] || null; -} - -export async function getAllSessions() { - const result = await pool.query( - 'SELECT session_id, airport_icao, created_at, created_by, is_pfatc, active_runway, atis FROM sessions ORDER BY created_at DESC' - ); - return result.rows; -} - -export { encrypt, decrypt }; \ No newline at end of file diff --git a/server/db/sessions.ts b/server/db/sessions.ts new file mode 100644 index 0000000..d2a6a98 --- /dev/null +++ b/server/db/sessions.ts @@ -0,0 +1,142 @@ +import { mainDb, flightsDb } from "./connection.js"; +import { validateSessionId } from "../utils/validation.js"; +import { encrypt } from "../utils/encryption.js"; +import { sql } from "kysely"; + +interface CreateSessionParams { + sessionId: string; + accessId: string; + activeRunway?: string; + airportIcao: string; + createdBy: string; + isPFATC?: boolean; +} + +export async function createSession({ sessionId, accessId, activeRunway, airportIcao, createdBy, isPFATC }: CreateSessionParams) { + const validSessionId = validateSessionId(sessionId); + + const encryptedAtis = encrypt({ + letter: 'A', + text: '', + timestamp: new Date().toISOString() + }); + + await mainDb + .insertInto('sessions') + .values({ + session_id: validSessionId, + access_id: accessId, + active_runway: activeRunway, + airport_icao: airportIcao.toUpperCase(), + created_by: createdBy, + is_pfatc: isPFATC, + atis: JSON.stringify(encryptedAtis) + }) + .execute(); + + await flightsDb.schema + .createTable(`flights_${validSessionId}`) + .ifNotExists() + .addColumn('id', 'varchar(36)', (col) => col.primaryKey()) + .addColumn('session_id', 'varchar(8)', (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('timestamp', 'varchar(32)') + .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`)) + .addColumn('updated_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`)) + .addColumn('status', 'varchar(16)') + .addColumn('clearance', 'varchar(16)') + .addColumn('position', 'jsonb') + .addColumn('squawk', 'varchar(8)') + .addColumn('wtc', 'varchar(4)') + .addColumn('hidden', 'boolean', (col) => col.defaultTo(false)) + .addColumn('acars_token', 'varchar(16)') + .execute(); +} + +export async function getSessionById(sessionId: string) { + return await mainDb + .selectFrom('sessions') + .selectAll() + .where('session_id', '=', sessionId) + .executeTakeFirst() || null; +} + +export async function getSessionsByUser(userId: string) { + return await mainDb + .selectFrom('sessions') + .select(['session_id', 'access_id', 'active_runway', 'airport_icao', 'created_at', 'created_by', 'is_pfatc', 'custom_name', 'refreshed_at']) + .where('created_by', '=', userId) + .orderBy('created_at', 'desc') + .execute(); +} + +export async function getSessionsByUserDetailed(userId: string) { + return await mainDb + .selectFrom('sessions') + .selectAll() + .where('created_by', '=', userId) + .orderBy('created_at', 'desc') + .execute(); +} + +export async function updateSession(sessionId: string, updates: Partial<{ active_runway: string; airport_icao: string; flight_strips: string; atis: string; custom_name: string; refreshed_at: Date; is_pfatc: boolean; }>) { + return await mainDb + .updateTable('sessions') + .set({ + ...updates, + airport_icao: updates.airport_icao?.toUpperCase(), + refreshed_at: updates.refreshed_at ? new Date(updates.refreshed_at) : undefined + }) + .where('session_id', '=', sessionId) + .returningAll() + .executeTakeFirst(); +} + +export async function updateSessionName(sessionId: string, customName: string) { + return await mainDb + .updateTable('sessions') + .set({ + custom_name: customName + }) + .where('session_id', '=', sessionId) + .returningAll() + .executeTakeFirst(); +} + +export async function deleteSession(sessionId: string) { + const validSessionId = validateSessionId(sessionId); + + await mainDb + .deleteFrom('sessions') + .where('session_id', '=', validSessionId) + .execute(); + + await flightsDb.schema + .dropTable(`flights_${validSessionId}`) + .ifExists() + .execute(); +} + +export async function getAllSessions() { + return await mainDb + .selectFrom('sessions') + .selectAll() + .orderBy('created_at', 'desc') + .execute(); +} \ No newline at end of file diff --git a/server/db/statistics.js b/server/db/statistics.js deleted file mode 100644 index b9071dc..0000000 --- a/server/db/statistics.js +++ /dev/null @@ -1,121 +0,0 @@ -import pool from './connections/connection.js'; - -let lastCleanupTime = 0; - -async function initializeStatisticsTable() { - try { - const result = await pool.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'daily_statistics' - ) - `); - const exists = result.rows[0].exists; - - if (!exists) { - await pool.query(` - CREATE TABLE daily_statistics ( - id SERIAL PRIMARY KEY, - date DATE UNIQUE NOT NULL, - logins_count INTEGER DEFAULT 0, - new_sessions_count INTEGER DEFAULT 0, - new_flights_count INTEGER DEFAULT 0, - new_users_count INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() - ) - `); - - await pool.query(` - CREATE INDEX IF NOT EXISTS idx_daily_statistics_date ON daily_statistics(date) - `); - } - } catch (error) { - console.error('Error initializing statistics table:', error); - } -} - -export async function recordLogin() { - try { - const today = new Date().toISOString().split('T')[0]; - await pool.query(` - INSERT INTO daily_statistics (date, logins_count) - VALUES ($1, 1) - ON CONFLICT (date) - DO UPDATE SET - logins_count = daily_statistics.logins_count + 1, - updated_at = NOW() - `, [today]); - } catch (error) { - console.error('Error recording login:', error); - } -} - -export async function recordNewSession() { - try { - const today = new Date().toISOString().split('T')[0]; - await pool.query(` - INSERT INTO daily_statistics (date, new_sessions_count) - VALUES ($1, 1) - ON CONFLICT (date) - DO UPDATE SET - new_sessions_count = daily_statistics.new_sessions_count + 1, - updated_at = NOW() - `, [today]); - } catch (error) { - console.error('Error recording new session:', error); - } -} - -export async function recordNewFlight() { - try { - const today = new Date().toISOString().split('T')[0]; - await pool.query(` - INSERT INTO daily_statistics (date, new_flights_count) - VALUES ($1, 1) - ON CONFLICT (date) - DO UPDATE SET - new_flights_count = daily_statistics.new_flights_count + 1, - updated_at = NOW() - `, [today]); - } catch (error) { - console.error('Error recording new flight:', error); - } -} - -export async function recordNewUser() { - try { - const today = new Date().toISOString().split('T')[0]; - await pool.query(` - INSERT INTO daily_statistics (date, new_users_count) - VALUES ($1, 1) - ON CONFLICT (date) - DO UPDATE SET - new_users_count = daily_statistics.new_users_count + 1, - updated_at = NOW() - `, [today]); - } catch (error) { - console.error('Error recording new user:', error); - } -} - -export async function cleanupOldStatistics() { - const now = Date.now(); - const twelveHoursInMs = 12 * 60 * 60 * 1000; - - if (now - lastCleanupTime < twelveHoursInMs) { - return; - } - - try { - const result = await pool.query(` - DELETE FROM daily_statistics - WHERE date < CURRENT_DATE - INTERVAL '90 days' - `); - lastCleanupTime = now; - } catch (error) { - console.error('Error cleaning up old statistics:', error); - } -} - -initializeStatisticsTable(); \ No newline at end of file diff --git a/server/db/statistics.ts b/server/db/statistics.ts new file mode 100644 index 0000000..23ebd93 --- /dev/null +++ b/server/db/statistics.ts @@ -0,0 +1,96 @@ +import { mainDb } from "./connection.js"; +import { sql } from 'kysely'; + +export async function recordLogin() { + try { + const today = new Date(); + await mainDb + .insertInto('daily_statistics') + .values({ id: sql`DEFAULT`, date: today, logins_count: 1 }) + .onConflict((oc) => + 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); + } +} + +export async function recordNewSession() { + try { + const today = new Date(); + await mainDb + .insertInto('daily_statistics') + .values({ id: sql`DEFAULT`, date: today, new_sessions_count: 1 }) + .onConflict((oc) => + 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); + } +} + +export async function recordNewFlight() { + try { + const today = new Date(); + await mainDb + .insertInto('daily_statistics') + .values({ id: sql`DEFAULT`, date: today, new_flights_count: 1 }) + .onConflict((oc) => + 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); + } +} + +export async function recordNewUser() { + try { + const today = new Date(); + await mainDb + .insertInto('daily_statistics') + .values({ id: sql`DEFAULT`, date: today, new_users_count: 1 }) + .onConflict((oc) => + 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); + } +} + +let lastCleanupTime = 0; + +export async function cleanupOldStatistics() { + const now = Date.now(); + const twelveHoursInMs = 12 * 60 * 60 * 1000; + + if (now - lastCleanupTime < twelveHoursInMs) { + return; + } + + try { + const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + await mainDb + .deleteFrom('daily_statistics') + .where('date', '<', ninetyDaysAgo) + .execute(); + lastCleanupTime = now; + } catch (error) { + console.error('Error cleaning up old statistics:', error); + } +} \ No newline at end of file diff --git a/server/db/testers.js b/server/db/testers.js deleted file mode 100644 index c33a473..0000000 --- a/server/db/testers.js +++ /dev/null @@ -1,183 +0,0 @@ -import pool from './connections/connection.js'; - -async function initializeTestersTable() { - try { - const result = await pool.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'testers' - ) - `); - const exists = result.rows[0].exists; - - if (!exists) { - await pool.query(` - CREATE TABLE testers ( - id SERIAL PRIMARY KEY, - user_id VARCHAR(20) NOT NULL UNIQUE, - username VARCHAR(32) NOT NULL, - added_by VARCHAR(20) NOT NULL, - added_by_username VARCHAR(32) NOT NULL, - notes TEXT, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() - ) - `); - console.log('Testers table created successfully'); - } - } catch (error) { - console.error('Error initializing testers table:', error); - } -} - -export async function addTester(userId, username, addedBy, addedByUsername, notes = '') { - try { - const result = await pool.query(` - INSERT INTO testers (user_id, username, added_by, added_by_username, notes) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id) DO UPDATE SET - username = EXCLUDED.username, - notes = EXCLUDED.notes, - updated_at = NOW() - RETURNING * - `, [userId, username, addedBy, addedByUsername, notes]); - - return result.rows[0]; - } catch (error) { - console.error('Error adding tester:', error); - throw error; - } -} - -export async function removeTester(userId) { - try { - const result = await pool.query(` - DELETE FROM testers WHERE user_id = $1 RETURNING * - `, [userId]); - - return result.rows[0]; - } catch (error) { - console.error('Error removing tester:', error); - throw error; - } -} - -export async function isTester(userId) { - try { - const result = await pool.query(` - SELECT 1 FROM testers WHERE user_id = $1 - `, [userId]); - - return result.rows.length > 0; - } catch (error) { - console.error('Error checking tester status:', error); - return false; - } -} - -export async function getAllTesters(page = 1, limit = 50, search = '') { - try { - const offset = (page - 1) * limit; - - let whereClause = ''; - let queryParams = []; - - if (search && search.trim()) { - whereClause = 'WHERE t.username ILIKE $1 OR t.user_id = $2'; - queryParams = [`%${search.trim()}%`, search.trim()]; - } - - const result = await pool.query(` - SELECT t.*, u.avatar - FROM testers t - LEFT JOIN users u ON t.user_id = u.id - ${whereClause} - ORDER BY t.created_at DESC - LIMIT $${queryParams.length + 1} OFFSET $${queryParams.length + 2} - `, [...queryParams, limit, offset]); - - const countResult = await pool.query(` - SELECT COUNT(*) FROM testers t ${whereClause.replace('t.username', 'username').replace('t.user_id', 'user_id')} - `, queryParams); - - const total = parseInt(countResult.rows[0].count); - - return { - testers: result.rows, - pagination: { - page, - limit, - total, - pages: Math.ceil(total / limit) - } - }; - } catch (error) { - console.error('Error fetching testers:', error); - throw error; - } -} - -export async function getTesterSettings() { - try { - // Check if a settings table exists or use a simple approach - const result = await pool.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'tester_settings' - ) - `); - - if (!result.rows[0].exists) { - await pool.query(` - CREATE TABLE tester_settings ( - id SERIAL PRIMARY KEY, - setting_key VARCHAR(50) UNIQUE NOT NULL, - setting_value BOOLEAN DEFAULT true, - updated_at TIMESTAMP DEFAULT NOW() - ) - `); - - // Insert default setting - await pool.query(` - INSERT INTO tester_settings (setting_key, setting_value) - VALUES ('tester_gate_enabled', true) - ON CONFLICT (setting_key) DO NOTHING - `); - } - - const settingsResult = await pool.query(` - SELECT setting_key, setting_value FROM tester_settings - `); - - const settings = {}; - settingsResult.rows.forEach(row => { - settings[row.setting_key] = row.setting_value; - }); - - return settings; - } catch (error) { - console.error('Error fetching tester settings:', error); - return { tester_gate_enabled: true }; - } -} - -export async function updateTesterSetting(key, value) { - try { - await pool.query(` - INSERT INTO tester_settings (setting_key, setting_value, updated_at) - VALUES ($1, $2, NOW()) - ON CONFLICT (setting_key) DO UPDATE SET - setting_value = EXCLUDED.setting_value, - updated_at = NOW() - `, [key, value]); - - return { [key]: value }; - } catch (error) { - console.error('Error updating tester setting:', error); - throw error; - } -} - -initializeTestersTable(); - -export { initializeTestersTable }; \ No newline at end of file diff --git a/server/db/testers.ts b/server/db/testers.ts new file mode 100644 index 0000000..f9dea87 --- /dev/null +++ b/server/db/testers.ts @@ -0,0 +1,147 @@ +import { mainDb } from "./connection.js"; +import { sql } from "kysely"; + +export async function addTester( + userId: string, + username: string, + addedBy: string, + addedByUsername: string, + notes: string = "" +) { + const result = await mainDb + .insertInto("testers") + .values({ + id: sql`NOW()`, + user_id: userId, + username, + added_by: addedBy, + added_by_username: addedByUsername, + notes, + updated_at: new Date(), + }) + .onConflict((oc) => + oc.column("user_id").doUpdateSet({ + username, + notes, + updated_at: new Date(), + }) + ) + .returningAll() + .executeTakeFirst(); + + return result; +} + +export async function removeTester(userId: string) { + const result = await mainDb + .deleteFrom("testers") + .where("user_id", "=", userId) + .returningAll() + .executeTakeFirst(); + + return result; +} + +export async function isTester(userId: string) { + const result = await mainDb + .selectFrom("testers") + .select("id") + .where("user_id", "=", userId) + .executeTakeFirst(); + + return !!result; +} + +export async function getAllTesters( + page: number = 1, + limit: number = 50, + search: string = "" +) { + const offset = (page - 1) * limit; + let query = mainDb + .selectFrom("testers as t") + .leftJoin("users as u", "t.user_id", "u.id") + .select([ + "t.id", + "t.user_id", + "t.username", + "t.added_by", + "t.added_by_username", + "t.notes", + "t.created_at", + "t.updated_at", + "u.avatar as avatar", + ]) + .orderBy("t.created_at", "desc") + .limit(limit) + .offset(offset); + + if (search && search.trim()) { + query = query.where((eb) => + eb.or([ + eb("t.username", "ilike", `%${search.trim()}%`), + eb("t.user_id", "=", search.trim()), + ]) + ); + } + + const testers = await query.execute(); + + let countQuery = mainDb.selectFrom("testers as t").select(({ fn }) => [fn.countAll().as("count")]); + if (search && search.trim()) { + countQuery = countQuery.where((eb) => + eb.or([ + eb("t.username", "ilike", `%${search.trim()}%`), + eb("t.user_id", "=", search.trim()), + ]) + ); + } + const countResult = await countQuery.executeTakeFirst(); + const total = Number(countResult?.count ?? 0); + + return { + testers, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit), + }, + }; +} + +export async function getTesterSettings() { + const rows = await mainDb + .selectFrom("tester_settings") + .select(["setting_key", "setting_value"]) + .execute(); + + const settings: Record = {}; + for (const row of rows) { + settings[row.setting_key] = row.setting_value; + } + if (!("tester_gate_enabled" in settings)) { + settings["tester_gate_enabled"] = true; + } + return settings; +} + +export async function updateTesterSetting(key: string, value: boolean) { + await mainDb + .insertInto("tester_settings") + .values({ + id: sql`DEFAULT`, + setting_key: key, + setting_value: value, + updated_at: new Date(), + }) + .onConflict((oc) => + oc.column("setting_key").doUpdateSet({ + setting_value: value, + updated_at: new Date(), + }) + ) + .execute(); + + return { [key]: value }; +} \ No newline at end of file diff --git a/server/db/types/Settings.ts b/server/db/types/Settings.ts new file mode 100644 index 0000000..e01f3e8 --- /dev/null +++ b/server/db/types/Settings.ts @@ -0,0 +1,29 @@ +export interface Settings { + Settings: { + sounds?: { + startupSound?: { enabled: boolean; volume: number }; + chatNotificationSound?: { enabled: boolean; volume: number }; + newStripSound?: { enabled: boolean; volume: number }; + acarsBeep?: { enabled: boolean; volume: number }; + acarsChatPop?: { enabled: boolean; volume: number }; + }; + backgroundImage?: { + selectedImage?: string | null; + useCustomBackground?: boolean; + favorites?: string[]; + }; + layout?: { + showCombinedView?: boolean; + flightRowOpacity?: number; + }; + departureTableColumns?: Record; + arrivalsTableColumns?: Record; + acars?: { + notesEnabled?: boolean; + chartsEnabled?: boolean; + terminalWidth?: number; + notesWidth?: number; + }; + [key: string]: unknown; + } +} \ No newline at end of file diff --git a/server/db/types/connection/ChatsDatabase.ts b/server/db/types/connection/ChatsDatabase.ts new file mode 100644 index 0000000..bac0deb --- /dev/null +++ b/server/db/types/connection/ChatsDatabase.ts @@ -0,0 +1,12 @@ +export interface ChatsDatabase { + // Dynamic schema: each session gets its own chat_{sessionId} table + [tableName: string]: { + id: number; + user_id: string; + username?: string; + avatar?: string; + message: string; + mentions?: string[]; + sent_at?: Date; + }; +} \ No newline at end of file diff --git a/server/db/types/connection/FlightsDatabase.ts b/server/db/types/connection/FlightsDatabase.ts new file mode 100644 index 0000000..e777a23 --- /dev/null +++ b/server/db/types/connection/FlightsDatabase.ts @@ -0,0 +1,35 @@ +export interface FlightsDatabase { + // Dynamic schema: each session gets its own flights_{sessionId} table + [tableName: string]: { + id: string; + session_id: string; + user_id?: string; + ip_address?: string; + callsign?: string; + aircraft?: string; + flight_type?: string; + departure?: string; + arrival?: string; + alternate?: string; + route?: string; + sid?: string; + star?: string; + runway?: string; + clearedfl?: string; + cruisingfl?: string; + stand?: string; + gate?: string; + remark?: string; + timestamp?: string; + created_at?: Date; + updated_at?: Date; + status?: string; + clearance?: string; + position?: object; + squawk?: string; + wtc?: string; + hidden?: boolean; + acars_token?: string; + pdc_remarks?: string; + }; +} \ No newline at end of file diff --git a/server/db/types/connection/MainDatabase.ts b/server/db/types/connection/MainDatabase.ts new file mode 100644 index 0000000..4c38b6e --- /dev/null +++ b/server/db/types/connection/MainDatabase.ts @@ -0,0 +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 { LogbookFlightsTable } from "./main/LogbookFlightsTable"; +import { LogbookTelemetryTable } from "./main/LogbookTelemetryTable"; +import { LogbookActiveFlightsTable } from "./main/LogbookActiveFlightsTable"; +import { LogbookStatsCacheTable } from "./main/LogbookStatsCacheTable"; + +export interface MainDatabase { + app_settings: AppSettingsTable; + users: UsersTable; + sessions: SessionsTable; + roles: RolesTable; + user_roles: UserRolesTable; + audit_log: AuditLogTable; + bans: BansTable; + notifications: NotificationsTable; + user_notifications: UserNotificationsTable; + testers: TestersTable; + tester_settings: TesterSettingsTable; + daily_statistics: DailyStatisticsTable; + logbook_flights: LogbookFlightsTable; + logbook_telemetry: LogbookTelemetryTable; + logbook_active_flights: LogbookActiveFlightsTable; + logbook_stats_cache: LogbookStatsCacheTable; +} \ No newline at end of file diff --git a/server/db/types/connection/main/AppSettingsTable.ts b/server/db/types/connection/main/AppSettingsTable.ts new file mode 100644 index 0000000..01de176 --- /dev/null +++ b/server/db/types/connection/main/AppSettingsTable.ts @@ -0,0 +1,6 @@ +export interface AppSettingsTable { + id: number; + version: string; + updated_at: Date; + updated_by: string; +} \ No newline at end of file diff --git a/server/db/types/connection/main/AuditLogTable.ts b/server/db/types/connection/main/AuditLogTable.ts new file mode 100644 index 0000000..230ae2d --- /dev/null +++ b/server/db/types/connection/main/AuditLogTable.ts @@ -0,0 +1,13 @@ +export interface AuditLogTable { + id: number; + admin_id: string; + admin_username: string; + action_type: string; + target_user_id?: string; + target_username?: string; + details?: object; + ip_address?: string; + user_agent?: string; + timestamp?: Date; + created_at?: Date; +} \ No newline at end of file diff --git a/server/db/types/connection/main/BansTable.ts b/server/db/types/connection/main/BansTable.ts new file mode 100644 index 0000000..8a8acda --- /dev/null +++ b/server/db/types/connection/main/BansTable.ts @@ -0,0 +1,11 @@ +export interface BansTable { + id: number; + user_id?: string; + ip_address?: string; + username?: string; + reason?: string; + banned_by: string; + banned_at?: Date; + expires_at?: Date; + active?: boolean; +} diff --git a/server/db/types/connection/main/DailyStatisticsTable.ts b/server/db/types/connection/main/DailyStatisticsTable.ts new file mode 100644 index 0000000..3076a01 --- /dev/null +++ b/server/db/types/connection/main/DailyStatisticsTable.ts @@ -0,0 +1,10 @@ +export interface DailyStatisticsTable { + id: number; + date: Date; + logins_count?: number; + new_sessions_count?: number; + new_flights_count?: number; + new_users_count?: number; + created_at?: Date; + updated_at?: Date; +} \ No newline at end of file diff --git a/server/db/types/connection/main/LogbookActiveFlightsTable.ts b/server/db/types/connection/main/LogbookActiveFlightsTable.ts new file mode 100644 index 0000000..29aac4d --- /dev/null +++ b/server/db/types/connection/main/LogbookActiveFlightsTable.ts @@ -0,0 +1,41 @@ +export interface LogbookActiveFlightsTable { + id: number; + roblox_username: string; + callsign?: string; + flight_id?: number; + + // Current state + last_update?: Date; + last_altitude?: number; + last_speed?: number; + last_heading?: number; + last_x?: number; + last_y?: number; + + // Flight phase tracking + current_phase?: string; + takeoff_detected?: boolean; + landing_detected?: boolean; + + // Departure detection + initial_position_x?: number; + initial_position_y?: number; + initial_position_time?: Date; + movement_started?: boolean; + movement_start_time?: Date; + + // Arrival detection + stationary_since?: Date; + stationary_position_x?: number; + stationary_position_y?: number; + stationary_notification_sent?: boolean; + + // For landing rate calculation + approach_altitudes?: number[]; + approach_timestamps?: Date[]; + + // Waypoint data collection + collected_waypoints?: object; + + created_at?: Date; +} \ No newline at end of file diff --git a/server/db/types/connection/main/LogbookFlightsTable.ts b/server/db/types/connection/main/LogbookFlightsTable.ts new file mode 100644 index 0000000..c96fd5e --- /dev/null +++ b/server/db/types/connection/main/LogbookFlightsTable.ts @@ -0,0 +1,61 @@ +export interface LogbookFlightsTable { + id: number; + user_id: string; + roblox_user_id?: string; + roblox_username: string; + + // Flight Info + callsign: string; + aircraft_model?: string; + aircraft_icao?: string; + livery?: string; + + // Route + departure_icao?: string; + arrival_icao?: string; + route?: string; + + // Timestamps + flight_start?: Date; + flight_end?: Date; + duration_minutes?: number; + + // Stats + total_distance_nm?: number; + max_altitude_ft?: number; + max_speed_kts?: number; + average_speed_kts?: number; + landing_rate_fpm?: number; + landing_g_force?: number; + + // Quality Scores (0-100) + smoothness_score?: number; + landing_score?: number; + route_adherence_score?: number; + + // State + flight_status?: string; + controller_status?: string; + logged_from_submit?: boolean; + controller_managed?: boolean; + + // Parking/Gate Detection + departure_position_x?: number; + departure_position_y?: number; + arrival_position_x?: number; + arrival_position_y?: number; + + // State change timestamps + activated_at?: Date; + landed_at?: Date; + + // Landing waypoint data + landed_runway?: string; + landed_airport?: string; + waypoint_landing_rate?: number; + + // Sharing + share_token?: string; + + created_at?: Date; +} \ No newline at end of file diff --git a/server/db/types/connection/main/LogbookStatsCacheTable.ts b/server/db/types/connection/main/LogbookStatsCacheTable.ts new file mode 100644 index 0000000..e08b9aa --- /dev/null +++ b/server/db/types/connection/main/LogbookStatsCacheTable.ts @@ -0,0 +1,28 @@ +export interface LogbookStatsCacheTable { + user_id: string; + + // Totals + total_flights?: number; + total_hours?: number; + total_flight_time_minutes?: number; + total_distance_nm?: number; + + // Favorites + favorite_aircraft?: string; + favorite_aircraft_count?: number; + favorite_airline?: string; + favorite_airline_count?: number; + favorite_departure?: string; + favorite_departure_count?: number; + + // Records + smoothest_landing_rate?: number; + smoothest_landing_flight_id?: number; + best_landing_rate?: number; + average_landing_score?: number; + highest_altitude?: number; + longest_flight_distance?: number; + longest_flight_id?: number; + + last_updated?: Date; +} \ No newline at end of file diff --git a/server/db/types/connection/main/LogbookTelemetryTable.ts b/server/db/types/connection/main/LogbookTelemetryTable.ts new file mode 100644 index 0000000..0523454 --- /dev/null +++ b/server/db/types/connection/main/LogbookTelemetryTable.ts @@ -0,0 +1,20 @@ +export interface LogbookTelemetryTable { + id: number; + flight_id: number; + + // Position + timestamp: Date; + x?: number; + y?: number; + latitude?: number; + longitude?: number; + + // Flight Data + altitude_ft?: number; + speed_kts?: number; + heading?: number; + vertical_speed_fpm?: number; + + // Phase + flight_phase?: string; +} \ No newline at end of file diff --git a/server/db/types/connection/main/NotificationsTable.ts b/server/db/types/connection/main/NotificationsTable.ts new file mode 100644 index 0000000..b135dd5 --- /dev/null +++ b/server/db/types/connection/main/NotificationsTable.ts @@ -0,0 +1,9 @@ +export interface NotificationsTable { + id: number; + type: string; + text: string; + show?: boolean; + custom_color?: string; + created_at?: Date; + updated_at?: Date; +} \ No newline at end of file diff --git a/server/db/types/connection/main/RolesTable.ts b/server/db/types/connection/main/RolesTable.ts new file mode 100644 index 0000000..c666327 --- /dev/null +++ b/server/db/types/connection/main/RolesTable.ts @@ -0,0 +1,11 @@ +export interface RolesTable { + id: number; + name: string; + description?: string; + permissions: object; + color?: string; + icon?: string; + priority?: number; + created_at?: Date; + updated_at?: Date; +} \ No newline at end of file diff --git a/server/db/types/connection/main/SessionsTable.ts b/server/db/types/connection/main/SessionsTable.ts new file mode 100644 index 0000000..8d765fa --- /dev/null +++ b/server/db/types/connection/main/SessionsTable.ts @@ -0,0 +1,13 @@ +export interface SessionsTable { + session_id: string; + access_id: string; + active_runway?: string; + airport_icao: string; + created_at?: Date; + created_by: string; + is_pfatc?: boolean; + flight_strips?: string; + atis?: string; + custom_name?: string; + refreshed_at?: Date; +} \ No newline at end of file diff --git a/server/db/types/connection/main/TesterSettingsTable.ts b/server/db/types/connection/main/TesterSettingsTable.ts new file mode 100644 index 0000000..af51362 --- /dev/null +++ b/server/db/types/connection/main/TesterSettingsTable.ts @@ -0,0 +1,6 @@ +export interface TesterSettingsTable { + id: number; + setting_key: string; + setting_value: boolean; + updated_at?: Date; +} \ No newline at end of file diff --git a/server/db/types/connection/main/TestersTable.ts b/server/db/types/connection/main/TestersTable.ts new file mode 100644 index 0000000..d7f93a7 --- /dev/null +++ b/server/db/types/connection/main/TestersTable.ts @@ -0,0 +1,10 @@ +export interface TestersTable { + id: number; + user_id: string; + username: string; + added_by: string; + added_by_username: string; + notes?: string; + created_at?: Date; + updated_at?: Date; +} \ No newline at end of file diff --git a/server/db/types/connection/main/UserNotificationsTable.ts b/server/db/types/connection/main/UserNotificationsTable.ts new file mode 100644 index 0000000..6bcbb75 --- /dev/null +++ b/server/db/types/connection/main/UserNotificationsTable.ts @@ -0,0 +1,9 @@ +export interface UserNotificationsTable { + id: number; + user_id: string; + type: string; + title: string; + message: string; + read?: boolean; + created_at?: Date; +} \ No newline at end of file diff --git a/server/db/types/connection/main/UserRolesTable.ts b/server/db/types/connection/main/UserRolesTable.ts new file mode 100644 index 0000000..296e254 --- /dev/null +++ b/server/db/types/connection/main/UserRolesTable.ts @@ -0,0 +1,5 @@ +export interface UserRolesTable { + user_id: string; + role_id: number; + assigned_at?: Date; +} \ No newline at end of file diff --git a/server/db/types/connection/main/UsersTable.ts b/server/db/types/connection/main/UsersTable.ts new file mode 100644 index 0000000..e3d34f2 --- /dev/null +++ b/server/db/types/connection/main/UsersTable.ts @@ -0,0 +1,29 @@ +export interface UsersTable { + id: string; + username: string; + discriminator: string; + avatar?: string; + access_token?: string; + refresh_token?: string; + last_login?: Date; + ip_address?: string; + is_vpn?: boolean; + sessions?: string; + last_session_created?: Date; + last_session_deleted?: Date; + settings?: string; + settings_updated_at?: Date; + total_sessions_created?: number; + total_minutes?: number; + vatsim_cid?: string; + vatsim_rating_id?: number; + vatsim_rating_short?: string; + vatsim_rating_long?: string; + created_at?: Date; + updated_at?: Date; + roblox_user_id?: string; + roblox_username?: string; + roblox_access_token?: string | null; + roblox_refresh_token?: string | null; + role_id?: number; +} \ No newline at end of file diff --git a/server/db/userNotifications.js b/server/db/userNotifications.js deleted file mode 100644 index 0da1292..0000000 --- a/server/db/userNotifications.js +++ /dev/null @@ -1,96 +0,0 @@ -import pool from './connections/connection.js'; - -async function initializeUserNotificationsTable() { - try { - const result = await pool.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'user_notifications' - ) - `); - const exists = result.rows[0].exists; - - if (!exists) { - await pool.query(` - CREATE TABLE user_notifications ( - id SERIAL PRIMARY KEY, - user_id VARCHAR(20) NOT NULL, - type VARCHAR(20) NOT NULL CHECK (type IN ('info', 'warning', 'success', 'error')), - title TEXT NOT NULL, - message TEXT NOT NULL, - read BOOLEAN DEFAULT false, - created_at TIMESTAMP DEFAULT NOW() - ) - `); - - await pool.query(` - CREATE INDEX idx_user_notifications_user_id ON user_notifications(user_id) - `); - } - } catch (error) { - console.error('Error initializing user_notifications table:', error); - } -} - -export async function getUserNotifications(userId, unreadOnly = false) { - try { - let query = ` - SELECT * FROM user_notifications - WHERE user_id = $1 - `; - - if (unreadOnly) { - query += ` AND read = false`; - } - - query += ` ORDER BY created_at DESC LIMIT 20`; - - const result = await pool.query(query, [userId]); - return result.rows; - } catch (error) { - console.error('Error fetching user notifications:', error); - throw error; - } -} - -export async function markNotificationAsRead(notificationId, userId) { - try { - await pool.query(` - UPDATE user_notifications - SET read = true - WHERE id = $1 AND user_id = $2 - `, [notificationId, userId]); - } catch (error) { - console.error('Error marking notification as read:', error); - throw error; - } -} - -export async function markAllNotificationsAsRead(userId) { - try { - await pool.query(` - UPDATE user_notifications - SET read = true - WHERE user_id = $1 AND read = false - `, [userId]); - } catch (error) { - console.error('Error marking all notifications as read:', error); - throw error; - } -} - -export async function deleteNotification(notificationId, userId) { - try { - await pool.query(` - DELETE FROM user_notifications - WHERE id = $1 AND user_id = $2 - `, [notificationId, userId]); - } catch (error) { - console.error('Error deleting notification:', error); - throw error; - } -} - -initializeUserNotificationsTable(); - -export { initializeUserNotificationsTable }; diff --git a/server/db/userNotifications.ts b/server/db/userNotifications.ts new file mode 100644 index 0000000..625e099 --- /dev/null +++ b/server/db/userNotifications.ts @@ -0,0 +1,45 @@ +import { mainDb } from "./connection.js"; + +export async function getUserNotifications(userId: string, unreadOnly = false, limit = 20) { + let query = mainDb + .selectFrom('user_notifications') + .selectAll() + .where('user_id', '=', userId); + + if (unreadOnly) { + query = query.where('read', '=', false); + } + + return await query + .orderBy('created_at', 'desc') + .limit(limit) + .execute(); +} + +export async function markNotificationAsRead(notificationId: number, userId: string) { + const result = await mainDb + .updateTable('user_notifications') + .set({ read: true }) + .where('id', '=', notificationId) + .where('user_id', '=', userId) + .returningAll() + .executeTakeFirst(); + + return result; +} + +export async function markAllNotificationsAsRead(userId: string) { + await mainDb + .updateTable('user_notifications') + .set({ read: true }) + .where('user_id', '=', userId) + .execute(); +} + +export async function deleteNotification(notificationId: number, userId: string) { + await mainDb + .deleteFrom('user_notifications') + .where('id', '=', notificationId) + .where('user_id', '=', userId) + .execute(); +} \ No newline at end of file diff --git a/server/db/users.js b/server/db/users.js deleted file mode 100644 index 643952a..0000000 --- a/server/db/users.js +++ /dev/null @@ -1,465 +0,0 @@ -import pool from './connections/connection.js'; -import { encrypt, decrypt } from '../tools/encryption.js'; - -async function initializeUsersTable() { - try { - const result = await pool.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'users' - ) - `); - const exists = result.rows[0].exists; - - if (!exists) { - await pool.query(` - CREATE TABLE users ( - id VARCHAR(20) PRIMARY KEY, - username VARCHAR(32) NOT NULL, - discriminator VARCHAR(4) DEFAULT '0', - avatar VARCHAR(32), - access_token TEXT, - refresh_token TEXT, - last_login TIMESTAMP DEFAULT NOW(), - ip_address TEXT, - is_vpn BOOLEAN DEFAULT false, - sessions TEXT DEFAULT '[]', - last_session_created TIMESTAMP, - last_session_deleted TIMESTAMP, - settings TEXT, - settings_updated_at TIMESTAMP DEFAULT NOW(), - total_sessions_created INTEGER DEFAULT 0, - total_minutes INTEGER DEFAULT 0, - -- VATSIM linkage - vatsim_cid VARCHAR(16), - vatsim_rating_id INTEGER, - vatsim_rating_short VARCHAR(8), - vatsim_rating_long VARCHAR(32), - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() - ) - `); - } else { - // Ensure VATSIM columns exist on existing installations - await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS vatsim_cid VARCHAR(16)"); - await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS vatsim_rating_id INTEGER"); - await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS vatsim_rating_short VARCHAR(8)"); - await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS vatsim_rating_long VARCHAR(32)"); - } - } catch (error) { - console.error('Error initializing users table:', error); - } -} - -export async function createOrUpdateUser(userData) { - const { - id, - username, - discriminator = '0', - avatar, - accessToken, - refreshToken, - ipAddress, - isVpn = false - } = userData; - - try { - const defaultSettings = { - sounds: { - startupSound: { enabled: true, volume: 100 }, - chatNotificationSound: { enabled: true, volume: 100 }, - newStripSound: { enabled: true, volume: 100 }, - acarsBeep: { enabled: true, volume: 100 }, - acarsChatPop: { enabled: true, volume: 100 } - }, - backgroundImage: { - selectedImage: null, - useCustomBackground: false, - favorites: [] - }, - layout: { - showCombinedView: false, - flightRowOpacity: 100 - }, - departureTableColumns: { - time: true, // cannot be disabled - callsign: true, - stand: true, - aircraft: true, - wakeTurbulence: true, - flightType: true, - arrival: true, - runway: true, - sid: true, - rfl: true, - cfl: true, - squawk: true, - clearance: true, - status: true, - remark: true, - pdc: true, - hide: true, - delete: true - }, - arrivalsTableColumns: { - time: true, // cannot be disabled - callsign: true, - gate: true, - aircraft: true, - wakeTurbulence: true, - flightType: true, - departure: true, - runway: true, - star: true, - rfl: true, - cfl: true, - squawk: true, - status: true, - remark: true, - hide: true - }, - acars: { - notesEnabled: true, - chartsEnabled: true, - terminalWidth: 50, - notesWidth: 20 - } - }; - - const existingUser = await pool.query('SELECT * FROM users WHERE id = $1', [id]); - - const encryptedIP = encrypt(ipAddress); - - if (existingUser.rows.length > 0) { - const encryptedAccessToken = encrypt(accessToken); - const encryptedRefreshToken = encrypt(refreshToken); - - await pool.query(` - UPDATE users SET - username = $2, - discriminator = $3, - avatar = $4, - access_token = $5, - refresh_token = $6, - last_login = NOW(), - ip_address = $7, - is_vpn = $8, - updated_at = NOW() - WHERE id = $1 - `, [ - id, - username, - discriminator, - avatar, - JSON.stringify(encryptedAccessToken), - JSON.stringify(encryptedRefreshToken), - JSON.stringify(encryptedIP), - isVpn - ]); - - return await getUserById(id); - } else { - // Create new user - const encryptedAccessToken = encrypt(accessToken); - const encryptedRefreshToken = encrypt(refreshToken); - const encryptedSettings = encrypt(defaultSettings); - const encryptedSessions = encrypt([]); - - await pool.query(` - INSERT INTO users ( - id, username, discriminator, avatar, access_token, refresh_token, - ip_address, is_vpn, sessions, settings - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - `, [ - id, - username, - discriminator, - avatar, - JSON.stringify(encryptedAccessToken), - JSON.stringify(encryptedRefreshToken), - JSON.stringify(encryptedIP), - isVpn, - JSON.stringify(encryptedSessions), - JSON.stringify(encryptedSettings) - ]); - - return await getUserById(id); - } - } catch (error) { - console.error('Error creating/updating user:', error); - throw error; - } -} - -export async function getUserById(id) { - try { - const result = await pool.query(` - SELECT u.*, r.name as role_name, r.permissions as role_permissions - FROM users u - LEFT JOIN roles r ON u.role_id = r.id - WHERE u.id = $1 - `, [id]); - - if (result.rows.length === 0) { - return null; - } - - const user = result.rows[0]; - - let decryptedAccessToken; - if (user.access_token) { - try { - const parsed = JSON.parse(user.access_token); - decryptedAccessToken = decrypt(parsed); - } catch (error) { - console.warn(`Failed to parse access_token for user ${id}, attempting direct decryption:`, error.message); - decryptedAccessToken = decrypt(user.access_token); - } - } else { - decryptedAccessToken = null; - } - - let decryptedRefreshToken; - if (user.refresh_token) { - try { - const parsed = JSON.parse(user.refresh_token); - decryptedRefreshToken = decrypt(parsed); - } catch (error) { - console.warn(`Failed to parse refresh_token for user ${id}, attempting direct decryption:`, error.message); - decryptedRefreshToken = decrypt(user.refresh_token); - } - } else { - decryptedRefreshToken = null; - } - - let decryptedSessions; - if (user.sessions) { - try { - const parsed = JSON.parse(user.sessions); - decryptedSessions = decrypt(parsed) || []; - } catch (error) { - console.warn(`Failed to parse sessions for user ${id}, attempting direct decryption:`, error.message); - decryptedSessions = decrypt(user.sessions) || []; - } - } else { - decryptedSessions = []; - } - - let decryptedSettings; - if (user.settings) { - try { - const parsed = JSON.parse(user.settings); - decryptedSettings = decrypt(parsed) || {}; - } catch (error) { - console.warn(`Failed to parse settings for user ${id}, attempting direct decryption:`, error.message); - decryptedSettings = decrypt(user.settings) || {}; - } - } else { - decryptedSettings = {}; - } - - let rolePermissions = null; - if (user.role_permissions) { - if (typeof user.role_permissions === 'string') { - try { - rolePermissions = JSON.parse(user.role_permissions); - } catch (error) { - console.warn(`Failed to parse role_permissions for user ${id}:`, error.message); - rolePermissions = null; - } - } else { - rolePermissions = user.role_permissions; - } - } - - let decryptedIP = null; - if (user.ip_address) { - try { - if (typeof user.ip_address === 'string' && user.ip_address.trim().startsWith('{')) { - const parsed = JSON.parse(user.ip_address); - decryptedIP = decrypt(parsed); - } else if ( - typeof user.ip_address === 'object' && - user.ip_address.iv && user.ip_address.data && user.ip_address.authTag - ) { - decryptedIP = decrypt(user.ip_address); - } else if ( - typeof user.ip_address === 'string' && - user.ip_address.split('.').length === 4 - ) { - decryptedIP = user.ip_address; - } else { - decryptedIP = null; - } - } catch (error) { - console.warn(`Failed to parse/decrypt ip_address for user ${user.id}:`, error.message); - decryptedIP = null; - } - } - user.ip_address = decryptedIP; - - return { - id: user.id, - username: user.username, - discriminator: user.discriminator, - avatar: user.avatar, - accessToken: decryptedAccessToken, - refreshToken: decryptedRefreshToken, - lastLogin: user.last_login, - ipAddress: decryptedIP, - isVpn: user.is_vpn, - sessions: decryptedSessions, - lastSessionCreated: user.last_session_created, - lastSessionDeleted: user.last_session_deleted, - settings: decryptedSettings, - settingsUpdatedAt: user.settings_updated_at, - totalSessionsCreated: user.total_sessions_created, - totalMinutes: user.total_minutes, - createdAt: user.created_at, - updatedAt: user.updated_at, - roleId: user.role_id, - roleName: user.role_name, - rolePermissions: rolePermissions, - robloxUserId: user.roblox_user_id, - robloxUsername: user.roblox_username, - // VATSIM linkage - vatsimCid: user.vatsim_cid, - vatsimRatingId: user.vatsim_rating_id, - vatsimRatingShort: user.vatsim_rating_short, - vatsimRatingLong: user.vatsim_rating_long - }; - } catch (error) { - console.error('Error fetching user:', error); - throw error; - } -} - -export async function updateUserSettings(id, settings) { - try { - const existingUser = await getUserById(id); - if (!existingUser) { - throw new Error('User not found'); - } - - const mergedSettings = { ...existingUser.settings, ...settings }; - - const encryptedSettings = encrypt(mergedSettings); - - await pool.query(` - UPDATE users SET - settings = $2, - settings_updated_at = NOW(), - updated_at = NOW() - WHERE id = $1 - `, [id, JSON.stringify(encryptedSettings)]); - - return await getUserById(id); - } catch (error) { - console.error('Error updating user settings:', error); - throw error; - } -} - -export async function addSessionToUser(userId, sessionId) { - try { - const user = await getUserById(userId); - if (!user) throw new Error('User not found'); - - const sessions = [...user.sessions, sessionId]; - const encryptedSessions = encrypt(sessions); - - await pool.query(` - UPDATE users SET - sessions = $2, - last_session_created = NOW(), - total_sessions_created = total_sessions_created + 1, - updated_at = NOW() - WHERE id = $1 - `, [userId, JSON.stringify(encryptedSessions)]); - - return await getUserById(userId); - } catch (error) { - console.error('Error adding session to user:', error); - throw error; - } -} - -export async function updateRobloxAccount(userId, { robloxUserId, robloxUsername, accessToken, refreshToken }) { - try { - await pool.query(` - UPDATE users SET - roblox_user_id = $2, - roblox_username = $3, - roblox_access_token = $4, - roblox_refresh_token = $5, - updated_at = NOW() - WHERE id = $1 - `, [userId, robloxUserId, robloxUsername, accessToken, refreshToken]); - - return await getUserById(userId); - } catch (error) { - console.error('Error updating Roblox account:', error); - throw error; - } -} - -export async function unlinkRobloxAccount(userId) { - try { - await pool.query(` - UPDATE users SET - roblox_user_id = NULL, - roblox_username = NULL, - roblox_access_token = NULL, - roblox_refresh_token = NULL, - updated_at = NOW() - WHERE id = $1 - `, [userId]); - - return await getUserById(userId); - } catch (error) { - console.error('Error unlinking Roblox account:', error); - throw error; - } -} - -export async function updateVatsimAccount(userId, { vatsimCid, ratingId, ratingShort, ratingLong }) { - try { - await pool.query(` - UPDATE users SET - vatsim_cid = $2, - vatsim_rating_id = $3, - vatsim_rating_short = $4, - vatsim_rating_long = $5, - updated_at = NOW() - WHERE id = $1 - `, [userId, vatsimCid, ratingId, ratingShort, ratingLong]); - - return await getUserById(userId); - } catch (error) { - console.error('Error updating VATSIM account:', error); - throw error; - } -} - -export async function unlinkVatsimAccount(userId) { - try { - await pool.query(` - UPDATE users SET - vatsim_cid = NULL, - vatsim_rating_id = NULL, - vatsim_rating_short = NULL, - vatsim_rating_long = NULL, - updated_at = NOW() - WHERE id = $1 - `, [userId]); - - return await getUserById(userId); - } catch (error) { - console.error('Error unlinking VATSIM account:', error); - throw error; - } -} - -initializeUsersTable(); - -export { initializeUsersTable }; diff --git a/server/db/users.ts b/server/db/users.ts new file mode 100644 index 0000000..aa0207f --- /dev/null +++ b/server/db/users.ts @@ -0,0 +1,278 @@ +import { encrypt, decrypt } from "../utils/encryption.js"; +import type { Settings } from "./types/Settings.js"; +import { mainDb } from "./connection.js"; +import { sql } from "kysely"; +import { redisConnection } from "./connection.js"; + +async function invalidateUserCache(userId: string) { + await redisConnection.del(`user:${userId}`); +} + +export async function getUserById(userId: string) { + const cacheKey = `user:${userId}`; + const cached = await redisConnection.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + + const user = await mainDb + .selectFrom("users") + .leftJoin("roles", "users.role_id", "roles.id") + .selectAll("users") + .select("roles.permissions as role_permissions") + .where("users.id", "=", userId) + .executeTakeFirst(); + + if (!user) return null; + + const result = { + ...user, + access_token: user.access_token ? decrypt(JSON.parse(user.access_token)) : null, + refresh_token: user.refresh_token ? decrypt(JSON.parse(user.refresh_token)) : null, + sessions: user.sessions ? decrypt(JSON.parse(user.sessions)) : null, + settings: user.settings ? decrypt(JSON.parse(user.settings)) : null, + ip_address: user.ip_address ? decrypt(JSON.parse(user.ip_address)) : null, + role_permissions: user.role_permissions || null + }; + + await redisConnection.set(cacheKey, JSON.stringify(result), "EX", 60 * 15); // cache for 15 minutes + return result; +} + +export async function createOrUpdateUser(userData: { + id: string; + username: string; + discriminator?: string; + avatar?: string; + accessToken: string; + refreshToken: string; + ipAddress: string; + isVpn?: boolean; +}) { + const { + id, + username, + discriminator = '0', + avatar, + accessToken, + refreshToken, + ipAddress, + isVpn = false + } = userData; + + const defaultSettings = { + sounds: { + startupSound: { enabled: true, volume: 100 }, + chatNotificationSound: { enabled: true, volume: 100 }, + newStripSound: { enabled: true, volume: 100 }, + acarsBeep: { enabled: true, volume: 100 }, + acarsChatPop: { enabled: true, volume: 100 } + }, + backgroundImage: { + selectedImage: null, + useCustomBackground: false, + favorites: [] + }, + layout: { + showCombinedView: false, + flightRowOpacity: 100 + }, + departureTableColumns: { + time: true, + callsign: true, + stand: true, + aircraft: true, + wakeTurbulence: true, + flightType: true, + arrival: true, + runway: true, + sid: true, + rfl: true, + cfl: true, + squawk: true, + clearance: true, + status: true, + remark: true, + pdc: true, + hide: true, + delete: true + }, + arrivalsTableColumns: { + time: true, + callsign: true, + gate: true, + aircraft: true, + wakeTurbulence: true, + flightType: true, + departure: true, + runway: true, + star: true, + rfl: true, + cfl: true, + squawk: true, + status: true, + remark: true, + hide: true + }, + acars: { + notesEnabled: true, + chartsEnabled: true, + terminalWidth: 50, + notesWidth: 20 + } + }; + + const encryptedAccessToken = encrypt(accessToken); + const encryptedRefreshToken = encrypt(refreshToken); + const encryptedIP = encrypt(ipAddress); + const encryptedSettings = encrypt(defaultSettings); + const encryptedSessions = encrypt([]); + + await mainDb + .insertInto('users') + .values({ + id, + username, + discriminator, + avatar, + access_token: JSON.stringify(encryptedAccessToken), + refresh_token: JSON.stringify(encryptedRefreshToken), + ip_address: JSON.stringify(encryptedIP), + is_vpn: isVpn, + sessions: JSON.stringify(encryptedSessions), + settings: JSON.stringify(encryptedSettings), + last_login: sql`NOW()`, + updated_at: sql`NOW()`, + created_at: sql`NOW()` + }) + .onConflict((oc) => + oc.column('id').doUpdateSet({ + username, + discriminator, + avatar, + access_token: JSON.stringify(encryptedAccessToken), + refresh_token: JSON.stringify(encryptedRefreshToken), + last_login: sql`NOW()`, + ip_address: JSON.stringify(encryptedIP), + is_vpn: isVpn, + updated_at: sql`NOW()` + }) + ) + .execute(); + + await invalidateUserCache(id); + return await getUserById(id); +} + +export async function updateUserSettings(id: string, settings: Settings) { + const existingUser = await getUserById(id); + if (!existingUser) { + throw new Error('User not found'); + } + + const mergedSettings = { ...existingUser.settings, ...settings }; + const encryptedSettings = encrypt(mergedSettings); + + await mainDb + .updateTable('users') + .set({ + settings: JSON.stringify(encryptedSettings), + settings_updated_at: sql`NOW()`, + updated_at: sql`NOW()` + }) + .where('id', '=', id) + .execute(); + + await invalidateUserCache(id); + return await getUserById(id); +} + +export async function addSessionToUser(userId: string, sessionId: string) { + const user = await getUserById(userId); + if (!user) throw new Error('User not found'); + + const sessions = [...user.sessions, sessionId]; + const encryptedSessions = encrypt(sessions); + + await mainDb + .updateTable('users') + .set({ + sessions: JSON.stringify(encryptedSessions), + last_session_created: sql`NOW()`, + total_sessions_created: sql`total_sessions_created + 1`, + updated_at: sql`NOW()` + }) + .where('id', '=', userId) + .execute(); + + await invalidateUserCache(userId); + return await getUserById(userId); +} + +export async function updateRobloxAccount(userId: string, { robloxUserId, robloxUsername, accessToken, refreshToken }: { robloxUserId: string; robloxUsername: string; accessToken: string; refreshToken: string }) { + await mainDb + .updateTable('users') + .set({ + roblox_user_id: robloxUserId, + roblox_username: robloxUsername, + roblox_access_token: accessToken, + roblox_refresh_token: refreshToken, + updated_at: sql`NOW()` + }) + .where('id', '=', userId) + .execute(); + + await invalidateUserCache(userId); + return await getUserById(userId); +} + +export async function unlinkRobloxAccount(userId: string) { + await mainDb + .updateTable('users') + .set({ + roblox_user_id: undefined, + roblox_username: undefined, + roblox_access_token: undefined, + roblox_refresh_token: undefined, + updated_at: sql`NOW()` + }) + .where('id', '=', userId) + .execute(); + + await invalidateUserCache(userId); + return await getUserById(userId); +} + +export async function updateVatsimAccount(userId: string, { vatsimCid, ratingId, ratingShort, ratingLong }: { vatsimCid: string; ratingId: number; ratingShort: string; ratingLong: string }) { + await mainDb + .updateTable('users') + .set({ + vatsim_cid: vatsimCid, + vatsim_rating_id: ratingId, + vatsim_rating_short: ratingShort, + vatsim_rating_long: ratingLong, + updated_at: sql`NOW()` + }) + .where('id', '=', userId) + .execute(); + + await invalidateUserCache(userId); + return await getUserById(userId); +} + +export async function unlinkVatsimAccount(userId: string) { + await mainDb + .updateTable('users') + .set({ + vatsim_cid: undefined, + vatsim_rating_id: undefined, + vatsim_rating_short: undefined, + vatsim_rating_long: undefined, + updated_at: sql`NOW()` + }) + .where('id', '=', userId) + .execute(); + + await invalidateUserCache(userId); + return await getUserById(userId); +} diff --git a/server/db/version.js b/server/db/version.js deleted file mode 100644 index 7d11d63..0000000 --- a/server/db/version.js +++ /dev/null @@ -1,49 +0,0 @@ -import pool from './connections/connection.js'; - -export async function getAppVersion() { - try { - const result = await pool.query(` - SELECT version, updated_at, updated_by - FROM app_settings - WHERE id = 1 - `); - - if (result.rows.length === 0) { - await pool.query(` - INSERT INTO app_settings (id, version, updated_at, updated_by) - VALUES (1, '2.0.0.3', NOW(), 'system') - ON CONFLICT (id) DO NOTHING - `); - - return { - version: '2.0.0.3', - updated_at: new Date().toISOString(), - updated_by: 'system' - }; - } - - return result.rows[0]; - } catch (error) { - console.error('Error getting app version:', error); - throw error; - } -} - -export async function updateAppVersion(version, updatedBy) { - try { - const result = await pool.query(` - INSERT INTO app_settings (id, version, updated_at, updated_by) - VALUES (1, $1, NOW(), $2) - ON CONFLICT (id) DO UPDATE SET - version = EXCLUDED.version, - updated_at = EXCLUDED.updated_at, - updated_by = EXCLUDED.updated_by - RETURNING version, updated_at, updated_by - `, [version, updatedBy]); - - return result.rows[0]; - } catch (error) { - console.error('Error updating app version:', error); - throw error; - } -} \ No newline at end of file diff --git a/server/db/version.ts b/server/db/version.ts new file mode 100644 index 0000000..e791a37 --- /dev/null +++ b/server/db/version.ts @@ -0,0 +1,58 @@ +import { mainDb } from './connection.js' + +export async function getAppVersion() { + const result = await mainDb + .selectFrom('app_settings') + .select(['version', 'updated_at', 'updated_by']) + .where('id', '=', 1) + .executeTakeFirst(); + + if (!result) { + await mainDb + .insertInto('app_settings') + .values({ + id: 1, + version: '2.0.0.3', + updated_at: new Date(), + updated_by: 'system' + }) + .onConflict((oc) => oc.column('id').doNothing()) + .execute(); + + return { + version: '2.0.0.3', + updated_at: new Date().toISOString(), + updated_by: 'system' + }; + } + + return { + ...result, + updated_at: result.updated_at?.toISOString() ?? null + }; +} + +export async function updateAppVersion(version: string, updatedBy: string) { + const result = await mainDb + .insertInto('app_settings') + .values({ + id: 1, + version, + updated_at: new Date(), + updated_by: updatedBy + }) + .onConflict((oc) => + oc.column('id').doUpdateSet({ + version: version, + updated_at: new Date(), + updated_by: updatedBy + }) + ) + .returning(['version', 'updated_at', 'updated_by']) + .executeTakeFirst(); + + return { + ...result, + updated_at: result?.updated_at?.toISOString() ?? null + }; +} \ No newline at end of file diff --git a/server/main.ts b/server/main.ts new file mode 100644 index 0000000..1d14a88 --- /dev/null +++ b/server/main.ts @@ -0,0 +1,68 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import express 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 { setupSessionUsersWebsocket } from './websockets/sessionUsersWebsocket.js'; +import { setupChatWebsocket } from './websockets/chatWebsocket.js'; +import { setupFlightsWebsocket } from './websockets/flightsWebsocket.js'; +import { setupOverviewWebsocket } from './websockets/overviewWebsocket.js'; +import { setupArrivalsWebsocket } from './websockets/arrivalsWebsocket.js'; + +dotenv.config({ path: process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development' }); +console.log(chalk.bgBlue('NODE_ENV:'), process.env.NODE_ENV); + +const PORT = process.env.PORT ? Number(process.env.PORT) : 9901; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); + +app.set('trust proxy', 1); +app.use(cors({ + origin: process.env.NODE_ENV === 'production' + ? ['https://control.pfconnect.online', 'https://test.pfconnect.online'] + : [ + 'http://localhost:9901', + 'http://localhost:5173', + ], + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'Origin', 'Access-Control-Allow-Credentials'] +})); +app.use(cookieParser()); +app.use(express.json()); + +app.get('/health', (_req, res) => { + res.json({ status: 'ok', environment: process.env.NODE_ENV, timestamp: new Date().toISOString() }); +}); + +app.use('/api', apiRoutes); + +app.use(express.static(path.join(__dirname, '../public'))); +app.use(express.static(path.join(__dirname, '..', '..', 'dist'), { + setHeaders: (res, filePath) => { + if (filePath.endsWith('.js')) res.setHeader('Content-Type', 'application/javascript'); + } +})); + +app.get(/.*/, (req, res) => { + res.sendFile(path.join(__dirname, '..', "..", "dist", "index.html")); +}); + +const server = http.createServer(app); +const sessionUsersIO = setupSessionUsersWebsocket(server); +setupChatWebsocket(server, sessionUsersIO); +setupFlightsWebsocket(server); +setupOverviewWebsocket(server, sessionUsersIO); +setupArrivalsWebsocket(server); + +server.listen(PORT, () => { + console.log(chalk.green(`Server running on http://localhost:${PORT}`)); +}); \ No newline at end of file diff --git a/server/middleware/admin.ts b/server/middleware/admin.ts new file mode 100644 index 0000000..7df50e5 --- /dev/null +++ b/server/middleware/admin.ts @@ -0,0 +1,55 @@ +import jwt from "jsonwebtoken"; +import { fileURLToPath } from 'url'; +import fs from "fs"; +import path from "path"; +import { Request, Response, NextFunction } from "express"; +import { JwtPayload } from "../types/JwtPayload.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const JWT_SECRET = process.env.JWT_SECRET; + +function getAdminIds() { + try { + const adminsPath = path.join(__dirname, '..', 'data', 'admins.json'); + const adminIds = JSON.parse(fs.readFileSync(adminsPath, 'utf8')); + return adminIds; + } catch (error) { + console.error('Error reading admin IDs:', error); + return []; + } +} + +function isAdmin(userId: string) { + const adminIds = getAdminIds(); + return adminIds.includes(userId); +} + + +function requireAdmin(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.auth_token; + if (!token) { + return res.status(401).json({ error: "Not authenticated" }); + } + + try { + if (!JWT_SECRET) { + return res.status(500).json({ error: "Internal server error" }); + } + const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload; + const adminIds = getAdminIds(); + + if (!adminIds.includes(decoded.userId)) { + return res.status(403).json({ error: "Admin access required" }); + } + + req.user = decoded; + next(); + } catch (err) { + console.error('Admin auth error:', err); + return res.status(401).json({ error: "Invalid token" }); + } +} + +export { requireAdmin, isAdmin, getAdminIds }; \ No newline at end of file diff --git a/server/middleware/auditLogger.js b/server/middleware/auditLogger.ts similarity index 73% rename from server/middleware/auditLogger.js rename to server/middleware/auditLogger.ts index 4fe0665..0a51286 100644 --- a/server/middleware/auditLogger.js +++ b/server/middleware/auditLogger.ts @@ -1,12 +1,14 @@ import { logAdminAction } from '../db/audit.js'; -import { getClientIp } from '../tools/getIpAddress.js'; -import { encrypt } from '../tools/encryption.js'; +import { getClientIp } from '../utils/getIpAddress.js'; +import { encrypt } from '../utils/encryption.js'; +import { Request, Response, NextFunction } from 'express'; +import type { AdminActionData } from '../db/audit.js'; -export function createAuditLogger(actionType) { - return async (req, res, next) => { +export function createAuditLogger(actionType: string) { + return async (req: Request, res: Response, next: NextFunction) => { const originalSend = res.send; - res.send = function (data) { + res.send = function (data: unknown) { if (res.statusCode >= 200 && res.statusCode < 400 && req.user?.userId) { const clientIP = getClientIp(req); const encryptedIP = encrypt(clientIP); @@ -36,7 +38,7 @@ export function createAuditLogger(actionType) { }; } -export async function logIPAccess(actionData) { +export async function logIPAccess(actionData: AdminActionData) { try { await logAdminAction(actionData); } catch (error) { diff --git a/server/middleware/isAuthenticated.js b/server/middleware/auth.ts similarity index 62% rename from server/middleware/isAuthenticated.js rename to server/middleware/auth.ts index 3a16917..6b266e3 100644 --- a/server/middleware/isAuthenticated.js +++ b/server/middleware/auth.ts @@ -1,17 +1,22 @@ import jwt from "jsonwebtoken"; import { getUserById } from "../db/users.js"; -import { isAdmin } from "./isAdmin.js"; +import { isAdmin } from "./admin.js"; +import { Request, Response, NextFunction } from "express"; +import { JwtPayload } from "../types/JwtPayload.js"; const JWT_SECRET = process.env.JWT_SECRET; -export default async function requireAuth(req, res, next) { +export default async function requireAuth(req: Request, res: Response, next: NextFunction) { const token = req.cookies.auth_token; if (!token) { return res.status(401).json({ error: "Not authenticated" }); } try { - const decoded = jwt.verify(token, JWT_SECRET); + if (!JWT_SECRET) { + return res.status(500).json({ error: "JWT secret not configured" }); + } + const decoded = jwt.verify(token, JWT_SECRET as string) as JwtPayload; const user = await getUserById(decoded.userId); if (!user) { @@ -24,8 +29,8 @@ export default async function requireAuth(req, res, next) { discriminator: decoded.discriminator, avatar: decoded.avatar, isAdmin: isAdmin(decoded.userId), - rolePermissions: user.rolePermissions, - id: user.id + iat: decoded.iat, + exp: decoded.exp, }; next(); diff --git a/server/middleware/isAdmin.js b/server/middleware/isAdmin.js deleted file mode 100644 index 005f413..0000000 --- a/server/middleware/isAdmin.js +++ /dev/null @@ -1,49 +0,0 @@ -import jwt from "jsonwebtoken"; -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const JWT_SECRET = process.env.JWT_SECRET; - -function getAdminIds() { - try { - const adminsPath = path.join(__dirname, '..', 'data', 'admins.json'); - const adminIds = JSON.parse(fs.readFileSync(adminsPath, 'utf8')); - return adminIds; - } catch (error) { - console.error('Error reading admin IDs:', error); - return []; - } -} - -function isAdmin(userId) { - const adminIds = getAdminIds(); - return adminIds.includes(userId); -} - -function requireAdmin(req, res, next) { - const token = req.cookies.auth_token; - if (!token) { - return res.status(401).json({ error: "Not authenticated" }); - } - - try { - const decoded = jwt.verify(token, JWT_SECRET); - const adminIds = getAdminIds(); - - if (!adminIds.includes(decoded.userId)) { - return res.status(403).json({ error: "Admin access required" }); - } - - req.user = decoded; - next(); - } catch (err) { - console.error('Admin auth error:', err); - return res.status(401).json({ error: "Invalid token" }); - } -} - -export { requireAdmin, isAdmin, getAdminIds }; \ No newline at end of file diff --git a/server/middleware/optionalAuth.js b/server/middleware/optionalAuth.ts similarity index 56% rename from server/middleware/optionalAuth.js rename to server/middleware/optionalAuth.ts index 716e6df..ba9e3bf 100644 --- a/server/middleware/optionalAuth.js +++ b/server/middleware/optionalAuth.ts @@ -1,18 +1,23 @@ import jwt from "jsonwebtoken"; import { getUserById } from "../db/users.js"; -import { isAdmin } from "./isAdmin.js"; +import { isAdmin } from "./admin.js"; +import { Request, Response, NextFunction } from "express"; +import type { JwtPayloadClient } from "../types/JwtPayload.js"; const JWT_SECRET = process.env.JWT_SECRET; -export default async function optionalAuth(req, res, next) { +export default async function optionalAuth( + req: Request, + res: Response, + next: NextFunction +) { const token = req.cookies.auth_token; - - if (!token) { - return next(); - } - try { - const decoded = jwt.verify(token, JWT_SECRET); + if (!JWT_SECRET) { + console.log('JWT_SECRET is not defined'); + return next(); + } + const decoded = jwt.verify(token, JWT_SECRET) as JwtPayloadClient; const user = await getUserById(decoded.userId); if (user) { @@ -23,10 +28,11 @@ export default async function optionalAuth(req, res, next) { avatar: decoded.avatar, isAdmin: isAdmin(decoded.userId), rolePermissions: user.rolePermissions, - id: user.id + iat: decoded.iat, + exp: decoded.exp }; } - } catch (err) { + } catch { console.log('Optional auth: Invalid token, continuing without authentication'); } diff --git a/server/middleware/rateLimiting.js b/server/middleware/rateLimiting.ts similarity index 100% rename from server/middleware/rateLimiting.js rename to server/middleware/rateLimiting.ts diff --git a/server/middleware/rolePermissions.js b/server/middleware/rolePermissions.js deleted file mode 100644 index 1d83de7..0000000 --- a/server/middleware/rolePermissions.js +++ /dev/null @@ -1,28 +0,0 @@ -import { getUserById } from '../db/users.js'; -import { getRoleById } from '../db/roles.js'; -import { isAdmin } from './isAdmin.js'; - -export function requirePermission(permission) { - return async (req, res, next) => { - try { - if (isAdmin(req.user?.userId)) { - return next(); - } - - const user = await getUserById(req.user.userId); - if (!user || !user.roleId) { - return res.status(403).json({ error: 'Access denied - insufficient permissions' }); - } - - const role = await getRoleById(user.roleId); - if (!role || !role.permissions[permission]) { - return res.status(403).json({ error: 'Access denied - insufficient permissions' }); - } - - next(); - } catch (error) { - console.error('Error checking permissions:', error); - res.status(500).json({ error: 'Failed to verify permissions' }); - } - }; -} \ No newline at end of file diff --git a/server/middleware/rolePermissions.ts b/server/middleware/rolePermissions.ts new file mode 100644 index 0000000..eb338d7 --- /dev/null +++ b/server/middleware/rolePermissions.ts @@ -0,0 +1,48 @@ +import { getUserById } from '../db/users.js'; +import { getRoleById } from '../db/roles.js'; +import { isAdmin } from './admin.js'; + +import { Request, Response, NextFunction } from 'express'; + +type PermissionKey = string; + +interface Role { + permissions: { [key: string]: boolean }; +} + +export function requirePermission(permission: PermissionKey) { + return async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.user || typeof req.user.userId !== 'string') { + return res.status(403).json({ error: 'Access denied - insufficient permissions' }); + } + + if (isAdmin(req.user.userId)) { + return next(); + } + + const user = await getUserById(req.user.userId); + if (!user || !user.roleId) { + return res.status(403).json({ error: 'Access denied - insufficient permissions' }); + } + + const dbRole = await getRoleById(user.roleId); + const role: Role | null = dbRole + ? { + permissions: (typeof dbRole.permissions === 'object' && dbRole.permissions !== null + ? dbRole.permissions + : {}) as { [key: string]: boolean } + } + : null; + + if (!role || !role.permissions[permission]) { + return res.status(403).json({ error: 'Access denied - insufficient permissions' }); + } + + next(); + } catch (error) { + console.error('Error checking permissions:', error); + res.status(500).json({ error: 'Failed to verify permissions' }); + } + }; +} \ No newline at end of file diff --git a/server/middleware/security.js b/server/middleware/security.ts similarity index 100% rename from server/middleware/security.js rename to server/middleware/security.ts diff --git a/server/middleware/sessionAccess.js b/server/middleware/sessionAccess.ts similarity index 55% rename from server/middleware/sessionAccess.js rename to server/middleware/sessionAccess.ts index e8a33b9..dad06e3 100644 --- a/server/middleware/sessionAccess.js +++ b/server/middleware/sessionAccess.ts @@ -1,39 +1,45 @@ -import pool from '../db/connections/connection.js'; +import { mainDb } from "../db/connection.js"; +import { Request, Response, NextFunction } from 'express'; -export async function validateSessionAccess(sessionId, accessId) { - if (!sessionId || !accessId) { - return false; - } +export async function validateSessionAccess(sessionId: string, accessId: string) { + if (!sessionId || !accessId) { + return false; + } - try { - const result = await pool.query( - 'SELECT session_id, access_id FROM sessions WHERE session_id = $1 AND access_id = $2', - [sessionId, accessId] - ); + try { + const result = await mainDb + .selectFrom('sessions') + .select(['session_id', 'access_id']) + .where('session_id', '=', sessionId) + .where('access_id', '=', accessId) + .execute(); - return result.rowCount > 0; - } catch (error) { - console.error('Session access validation error:', error); - return false; - } + return result.length > 0; + } catch (error) { + console.error('Session access validation error:', error); + return false; + } } -export async function validateSessionOwnership(sessionId, userId) { - if (!sessionId || !userId) return false; +export async function validateSessionOwnership(sessionId: string, userId: string) { + if (!sessionId || !userId) return false; - try { - const result = await pool.query( - 'SELECT 1 FROM sessions WHERE session_id = $1 AND created_by = $2', - [sessionId, userId] - ); - return result.rowCount > 0; - } catch (error) { - console.error('Session ownership validation error:', error); - return false; - } + try { + const result = await mainDb + .selectFrom('sessions') + .select('session_id') + .where('session_id', '=', sessionId) + .where('created_by', '=', userId) + .execute(); + + return result.length > 0; + } catch (error) { + console.error('Session ownership validation error:', error); + return false; + } } -export function requireSessionAccess(req, res, next) { +export function requireSessionAccess(req: Request, res: Response, next: NextFunction) { const { sessionId } = req.params; const accessId = (req.query && req.query.accessId) || (req.body && req.body.accessId); @@ -60,8 +66,7 @@ export function requireSessionAccess(req, res, next) { }); } -// New middleware for operations that require ownership -export function requireSessionOwnership(req, res, next) { +export function requireSessionOwnership(req: Request, res: Response, next: NextFunction) { const { sessionId } = req.params || req.body; const userId = req.user?.userId; diff --git a/server/middleware/isTester.js b/server/middleware/tester.ts similarity index 71% rename from server/middleware/isTester.js rename to server/middleware/tester.ts index c80a8b4..f4494a3 100644 --- a/server/middleware/isTester.js +++ b/server/middleware/tester.ts @@ -1,9 +1,11 @@ import jwt from "jsonwebtoken"; import { isTester as checkIsTester, getTesterSettings } from '../db/testers.js'; +import { Request, Response, NextFunction } from "express"; +import type { JwtPayload } from "../types/JwtPayload.js"; const JWT_SECRET = process.env.JWT_SECRET; -export async function requireTester(req, res, next) { +export async function requireTester(req: Request, res: Response, next: NextFunction) { try { const settings = await getTesterSettings(); if (!settings.tester_gate_enabled) { @@ -15,7 +17,12 @@ export async function requireTester(req, res, next) { return res.status(401).json({ error: "Authentication required" }); } - const decoded = jwt.verify(token, JWT_SECRET); + if (!JWT_SECRET) { + console.error('JWT_SECRET is not defined'); + return res.status(500).json({ error: "Server configuration error" }); + } + + const decoded = jwt.verify(token, JWT_SECRET as string) as JwtPayload; const userIsTester = await checkIsTester(decoded.userId); if (!userIsTester) { @@ -30,7 +37,7 @@ export async function requireTester(req, res, next) { } } -export async function isTester(userId) { +export async function isTester(userId: string) { try { return await checkIsTester(userId); } catch (error) { diff --git a/server/routes/admin/audit-logs.js b/server/routes/admin/audit-logs.js deleted file mode 100644 index fcc2260..0000000 --- a/server/routes/admin/audit-logs.js +++ /dev/null @@ -1,77 +0,0 @@ -import express from 'express'; -import { createAuditLogger, logIPAccess } from '../../middleware/auditLogger.js'; -import { requirePermission } from '../../middleware/rolePermissions.js'; -import { getAuditLogs, getAuditLogById } from '../../db/audit.js'; -import { getClientIp } from '../../tools/getIpAddress.js'; - -const router = express.Router(); - -router.use(requirePermission('audit')); - -// GET: /api/admin/audit-logs - Get audit logs -router.get('/', createAuditLogger('ADMIN_AUDIT_LOGS_ACCESSED'), async (req, res) => { - try { - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 50; - const filters = { - adminId: req.query.adminId, - actionType: req.query.actionType, - targetUserId: req.query.targetUserId, - dateFrom: req.query.dateFrom, - dateTo: req.query.dateTo - }; - - const result = await getAuditLogs(page, limit, filters); - res.json(result); - } catch (error) { - console.error('Error fetching audit logs:', error); - res.status(500).json({ error: 'Failed to fetch audit logs' }); - } -}); - -// POST: /api/admin/audit-logs/:logId/reveal-ip - Reveal audit log IP address -router.post('/:logId/reveal-ip', async (req, res) => { - try { - const { logId } = req.params; - const log = await getAuditLogById(parseInt(logId)); - - if (!log) { - return res.status(404).json({ error: 'Audit log not found' }); - } - - if (req.user?.userId) { - try { - const auditData = { - adminId: req.user.userId, - adminUsername: req.user.username || 'Unknown', - actionType: 'AUDIT_LOG_IP_VIEWED', - targetUserId: log.admin_id, - targetUsername: log.admin_username, - ipAddress: getClientIp(req), - userAgent: req.get('User-Agent'), - details: { - method: req.method, - url: req.originalUrl, - revealedIP: log.ip_address, - auditLogId: log.id, - timestamp: new Date().toISOString() - } - }; - - await logIPAccess(auditData); - } catch (auditError) { - console.error('Failed to log audit IP access:', auditError); - } - } - - res.json({ - logId: log.id, - ip_address: log.ip_address - }); - } catch (error) { - console.error('Error revealing audit log IP address:', error); - res.status(500).json({ error: 'Failed to reveal IP address' }); - } -}); - -export default router; \ No newline at end of file diff --git a/server/routes/admin/audit-logs.ts b/server/routes/admin/audit-logs.ts new file mode 100644 index 0000000..dd8a7b9 --- /dev/null +++ b/server/routes/admin/audit-logs.ts @@ -0,0 +1,125 @@ +import express from 'express'; +import { createAuditLogger, logIPAccess } from '../../middleware/auditLogger.js'; +import { requirePermission } from '../../middleware/rolePermissions.js'; +import { getAuditLogs, getAuditLogById } from '../../db/audit.js'; +import { getClientIp } from '../../utils/getIpAddress.js'; + +const router = express.Router(); + +router.use(requirePermission('audit')); + +// GET: /api/admin/audit-logs - Get audit logs +router.get('/', createAuditLogger('ADMIN_AUDIT_LOGS_ACCESSED'), async (req, res) => { + try { + const pageParam = req.query.page; + const limitParam = req.query.limit; + const adminIdParam = req.query.adminId; + const actionTypeParam = req.query.actionType; + const targetUserIdParam = req.query.targetUserId; + const dateFromParam = req.query.dateFrom; + const dateToParam = req.query.dateTo; + + const page = + typeof pageParam === 'string' + ? parseInt(pageParam) + : Array.isArray(pageParam) && typeof pageParam[0] === 'string' + ? parseInt(pageParam[0]) + : 1; + const limit = + typeof limitParam === 'string' + ? parseInt(limitParam) + : Array.isArray(limitParam) && typeof limitParam[0] === 'string' + ? parseInt(limitParam[0]) + : 50; + + const filters = { + adminId: + typeof adminIdParam === 'string' + ? adminIdParam + : Array.isArray(adminIdParam) && typeof adminIdParam[0] === 'string' + ? adminIdParam[0] + : undefined, + actionType: + typeof actionTypeParam === 'string' + ? actionTypeParam + : Array.isArray(actionTypeParam) && typeof actionTypeParam[0] === 'string' + ? actionTypeParam[0] + : undefined, + targetUserId: + typeof targetUserIdParam === 'string' + ? targetUserIdParam + : Array.isArray(targetUserIdParam) && typeof targetUserIdParam[0] === 'string' + ? targetUserIdParam[0] + : undefined, + dateFrom: + typeof dateFromParam === 'string' + ? dateFromParam + : Array.isArray(dateFromParam) && typeof dateFromParam[0] === 'string' + ? dateFromParam[0] + : undefined, + dateTo: + typeof dateToParam === 'string' + ? dateToParam + : Array.isArray(dateToParam) && typeof dateToParam[0] === 'string' + ? dateToParam[0] + : undefined + }; + + const result = await getAuditLogs(page, limit, filters); + res.json(result); + } catch (error) { + console.error('Error fetching audit logs:', error); + res.status(500).json({ error: 'Failed to fetch audit logs' }); + } +}); + +// POST: /api/admin/audit-logs/:logId/reveal-ip - Reveal audit log IP address +router.post('/:logId/reveal-ip', async (req, res) => { + try { + const { logId } = req.params; + const log = await getAuditLogById(parseInt(logId)); + + if (!log) { + return res.status(404).json({ error: 'Audit log not found' }); + } + + if (req.user?.userId) { + try { + const auditData = { + adminId: req.user.userId, + adminUsername: req.user.username || 'Unknown', + actionType: 'AUDIT_LOG_IP_VIEWED', + targetUserId: log.admin_id, + targetUsername: log.admin_username, + ipAddress: (() => { + const ip = getClientIp(req); + if (Array.isArray(ip)) return ip[0] ?? null; + return ip ?? null; + })(), + userAgent: req.get('User-Agent'), + details: { + method: req.method, + url: req.originalUrl, + revealedIP: log.ip_address, + auditLogId: log.id, + timestamp: new Date().toISOString() + } + }; + + await logIPAccess(auditData); + } catch (auditError) { + console.error('Failed to log audit IP access:', auditError); + } + } + + res.json({ + logId: log.id, + ip_address: log.ip_address + }); + } catch (error) { + console.error('Error revealing audit log IP address:', error); + res.status(500).json({ error: 'Failed to reveal IP address' }); + } +}); + +export default router; \ No newline at end of file diff --git a/server/routes/admin/ban.js b/server/routes/admin/ban.ts similarity index 57% rename from server/routes/admin/ban.js rename to server/routes/admin/ban.ts index 54b43af..5a00b8d 100644 --- a/server/routes/admin/ban.js +++ b/server/routes/admin/ban.ts @@ -3,17 +3,29 @@ import { createAuditLogger } from '../../middleware/auditLogger.js'; import { requirePermission } from '../../middleware/rolePermissions.js'; import { banUser, unbanUser, getAllBans } from '../../db/ban.js'; import { logAdminAction } from '../../db/audit.js'; -import { isAdmin } from '../../middleware/isAdmin.js'; -import pool from '../../db/connections/connection.js'; -import { getClientIp } from '../../tools/getIpAddress.js'; +import { isAdmin } from '../../middleware/admin.js'; +import { getClientIp } from '../../utils/getIpAddress.js'; +import { mainDb } from '../../db/connection.js'; const router = express.Router(); router.use(requirePermission('bans')); router.get('/', createAuditLogger('ADMIN_BANS_ACCESSED'), async (req, res) => { - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 50; + const pageParam = req.query.page; + const limitParam = req.query.limit; + const page = + typeof pageParam === 'string' + ? parseInt(pageParam) + : Array.isArray(pageParam) && typeof pageParam[0] === 'string' + ? parseInt(pageParam[0]) + : 1; + const limit = + typeof limitParam === 'string' + ? parseInt(limitParam) + : Array.isArray(limitParam) && typeof limitParam[0] === 'string' + ? parseInt(limitParam[0]) + : 50; const result = await getAllBans(page, limit); res.json(result); }); @@ -26,6 +38,9 @@ router.post('/ban', async (req, res) => { if (userId && isAdmin(userId)) { return res.status(403).json({ error: 'Cannot ban a super admin' }); } + if (!req.user) { + return res.status(401).json({ error: 'Unauthorized: user not found in request' }); + } await banUser({ userId, ip, @@ -41,7 +56,10 @@ router.post('/ban', async (req, res) => { actionType: 'USER_BANNED', targetUserId: userId || null, targetUsername: ip || username || null, - ipAddress: getClientIp(req), + ipAddress: (() => { + const ip = getClientIp(req); + return Array.isArray(ip) ? ip[0] : ip; + })(), userAgent: req.get('User-Agent'), details: { reason, @@ -58,21 +76,35 @@ router.post('/ban', async (req, res) => { router.post('/unban', async (req, res) => { const { userIdOrIp } = req.body; - const banRecord = await pool.query( - `SELECT username FROM bans WHERE (user_id = $1 OR ip_address = $1) AND active = true LIMIT 1`, - [userIdOrIp] - ); - const username = banRecord.rows[0]?.username || userIdOrIp; + const banRecord = await mainDb + .selectFrom('bans') + .select('username') + .where('active', '=', true) + .where(qb => + qb.or([ + qb('user_id', '=', userIdOrIp), + qb('ip_address', '=', userIdOrIp) + ]) + ) + .limit(1) + .execute(); + const username = banRecord[0]?.username || userIdOrIp; await unbanUser(userIdOrIp); + if (!req.user) { + return res.status(401).json({ error: 'Unauthorized: user not found in request' }); + } await logAdminAction({ adminId: req.user.userId, adminUsername: req.user.username || 'Unknown', actionType: 'USER_UNBANNED', targetUserId: null, targetUsername: username, - ipAddress: getClientIp(req), + ipAddress: (() => { + const ip = getClientIp(req); + return Array.isArray(ip) ? ip[0] : ip ?? null; + })(), userAgent: req.get('User-Agent'), details: { method: req.method, diff --git a/server/routes/admin/index.js b/server/routes/admin/index.ts similarity index 80% rename from server/routes/admin/index.js rename to server/routes/admin/index.ts index c01d3d4..5ee8cd2 100644 --- a/server/routes/admin/index.js +++ b/server/routes/admin/index.ts @@ -1,8 +1,8 @@ import express from 'express'; -import requireAuth from '../../middleware/isAuthenticated.js'; +import requireAuth from '../../middleware/auth.js'; import { createAuditLogger } from '../../middleware/auditLogger.js'; import { requirePermission } from '../../middleware/rolePermissions.js'; -import { getDailyStatistics, getTotalStatistics, getSystemInfo } from '../../db/admin.js'; +import { getDailyStatistics, getTotalStatistics } from '../../db/admin.js'; import { getAppVersion, updateAppVersion } from '../../db/version.js'; import usersRouter from './users.js'; @@ -29,7 +29,13 @@ router.use('/roles', rolesRouter); // GET: /api/admin/statistics - Get dashboard statistics router.get('/statistics', requirePermission('admin'), async (req, res) => { try { - const days = parseInt(req.query.days) || 30; + const daysParam = req.query.days; + const days = + typeof daysParam === 'string' + ? parseInt(daysParam) + : Array.isArray(daysParam) && typeof daysParam[0] === 'string' + ? parseInt(daysParam[0]) + : 30; const dailyStats = await getDailyStatistics(days); const totalStats = await getTotalStatistics(); @@ -43,17 +49,6 @@ router.get('/statistics', requirePermission('admin'), async (req, res) => { } }); -// GET: /api/admin/system-info - Get system information -router.get('/system-info', requirePermission('admin'), createAuditLogger('ADMIN_SYSTEM_INFO_ACCESSED'), async (req, res) => { - try { - const systemInfo = await getSystemInfo(); - res.json(systemInfo); - } catch (error) { - console.error('Error fetching system info:', error); - res.status(500).json({ error: 'Failed to fetch system information' }); - } -}); - // GET: /api/admin/version - Get app version (admin only) router.get('/version', requirePermission('admin'), async (req, res) => { try { @@ -80,7 +75,10 @@ router.put('/version', requirePermission('admin'), createAuditLogger('ADMIN_VERS return res.status(400).json({ error: 'Invalid version format. Use MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH.BUILD' }); } - const updatedVersion = await updateAppVersion(version.trim(), req.user.id); + if (!req.user || !req.user.userId) { + return res.status(401).json({ error: 'Unauthorized: user not found' }); + } + const updatedVersion = await updateAppVersion(version.trim(), req.user.userId); res.json(updatedVersion); } catch (error) { console.error('Error updating app version:', error); diff --git a/server/routes/admin/notifications.js b/server/routes/admin/notifications.ts similarity index 81% rename from server/routes/admin/notifications.js rename to server/routes/admin/notifications.ts index 3a09256..ee8d6ac 100644 --- a/server/routes/admin/notifications.js +++ b/server/routes/admin/notifications.ts @@ -3,12 +3,11 @@ import { createAuditLogger } from '../../middleware/auditLogger.js'; import { logAdminAction } from '../../db/audit.js'; import { getAllNotifications, - getActiveNotifications, addNotification, updateNotification, deleteNotification } from '../../db/notifications.js'; -import { getClientIp } from '../../tools/getIpAddress.js'; +import { getClientIp } from '../../utils/getIpAddress.js'; const router = express.Router(); @@ -35,11 +34,12 @@ router.post('/', async (req, res) => { // Log the action if (req.user?.userId) { + const ip = getClientIp(req); await logAdminAction({ adminId: req.user.userId, adminUsername: req.user.username || 'Unknown', actionType: 'NOTIFICATION_ADDED', - ipAddress: getClientIp(req), + ipAddress: Array.isArray(ip) ? ip.join(', ') : ip, userAgent: req.get('User-Agent'), details: { type, text, show, customColor, notificationId: notification.id } }); @@ -57,15 +57,17 @@ router.put('/:id', async (req, res) => { try { const { id } = req.params; const { type, text, show, customColor } = req.body; + const numericId = Number(id); - const notification = await updateNotification(id, { type, text, show, customColor }); + const notification = await updateNotification(numericId, { type, text, show, customColor }); if (req.user?.userId) { + const ip = getClientIp(req); await logAdminAction({ adminId: req.user.userId, adminUsername: req.user.username || 'Unknown', actionType: 'NOTIFICATION_UPDATED', - ipAddress: getClientIp(req), + ipAddress: Array.isArray(ip) ? ip.join(', ') : ip, userAgent: req.get('User-Agent'), details: { notificationId: id, type, text, show, customColor } }); @@ -82,14 +84,19 @@ router.put('/:id', async (req, res) => { router.delete('/:id', async (req, res) => { try { const { id } = req.params; - const deleted = await deleteNotification(id); + const numericId = Number(id); + if (isNaN(numericId)) { + return res.status(400).json({ error: 'Invalid notification ID' }); + } + const deleted = await deleteNotification(numericId); if (req.user?.userId) { + const ip = getClientIp(req); await logAdminAction({ adminId: req.user.userId, adminUsername: req.user.username || 'Unknown', actionType: 'NOTIFICATION_DELETED', - ipAddress: getClientIp(req), + ipAddress: Array.isArray(ip) ? ip.join(', ') : ip, userAgent: req.get('User-Agent'), details: { notificationId: id } }); diff --git a/server/routes/admin/roles.js b/server/routes/admin/roles.ts similarity index 96% rename from server/routes/admin/roles.js rename to server/routes/admin/roles.ts index e297ab7..adb3768 100644 --- a/server/routes/admin/roles.js +++ b/server/routes/admin/roles.ts @@ -50,7 +50,7 @@ router.post('/', requirePermission('roles'), createAuditLogger('ROLE_CREATED'), res.status(201).json(role); } catch (error) { console.error('Error creating role:', error); - if (error.code === '23505') { // Unique constraint violation + if (typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === '23505') { res.status(400).json({ error: 'Role name already exists' }); } else { res.status(500).json({ error: 'Failed to create role' }); @@ -59,7 +59,6 @@ router.post('/', requirePermission('roles'), createAuditLogger('ROLE_CREATED'), }); // PUT: /api/admin/roles/priorities - Update role priorities (requires roles permission) -// IMPORTANT: This must come BEFORE /:id route router.put('/priorities', requirePermission('roles'), createAuditLogger('ROLE_PRIORITIES_UPDATED'), async (req, res) => { try { const { rolePriorities } = req.body; @@ -105,7 +104,7 @@ router.put('/:id', requirePermission('roles'), createAuditLogger('ROLE_UPDATED') res.json(updatedRole); } catch (error) { console.error('Error updating role:', error); - if (error.code === '23505') { + if (typeof error === 'object' && error !== null && 'code' in error && (error).code === '23505') { res.status(400).json({ error: 'Role name already exists' }); } else { res.status(500).json({ error: 'Failed to update role' }); diff --git a/server/routes/admin/sessions.js b/server/routes/admin/sessions.ts similarity index 83% rename from server/routes/admin/sessions.js rename to server/routes/admin/sessions.ts index c00ad70..2efe46a 100644 --- a/server/routes/admin/sessions.js +++ b/server/routes/admin/sessions.ts @@ -23,14 +23,18 @@ router.get('/', createAuditLogger('ADMIN_SESSIONS_ACCESSED'), async (req, res) = router.delete('/:sessionId', createAuditLogger('SESSION_DELETED'), async (req, res) => { try { const { sessionId } = req.params; - const deleted = await deleteSession(sessionId); - - if (!deleted) { + await deleteSession(sessionId); + res.json({ message: 'Session deleted successfully', sessionId }); + } catch (error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof (error as { message?: unknown }).message === 'string' && + ((error as { message: string }).message.includes('not found')) + ) { return res.status(404).json({ error: 'Session not found' }); } - - res.json({ message: 'Session deleted successfully', sessionId }); - } catch (error) { console.error('Error deleting session:', error); res.status(500).json({ error: 'Failed to delete session' }); } diff --git a/server/routes/admin/testers.js b/server/routes/admin/testers.ts similarity index 80% rename from server/routes/admin/testers.js rename to server/routes/admin/testers.ts index 41135f0..5f8ceb6 100644 --- a/server/routes/admin/testers.js +++ b/server/routes/admin/testers.ts @@ -5,11 +5,10 @@ import { getAllTesters, addTester, removeTester, - getTesterSettings, updateTesterSetting } from '../../db/testers.js'; import { getUserById } from '../../db/users.js'; -import { getClientIp } from '../../tools/getIpAddress.js'; +import { getClientIp } from '../../utils/getIpAddress.js'; import { getAllRoles, createRole, assignRoleToUser, removeRoleFromUser } from '../../db/roles.js'; const router = express.Router(); @@ -22,7 +21,7 @@ async function ensureTesterRole() { if (!testerRole) { // Create Tester role with yellow color and flask icon - testerRole = await createRole({ + const createdRole = await createRole({ name: 'Tester', description: 'Beta tester with early access to new features', permissions: {}, @@ -30,6 +29,15 @@ async function ensureTesterRole() { icon: 'FlaskConical', priority: 5 }); + if (!createdRole) { + throw new Error('Failed to create Tester role'); + } + // Patch: add user_count if missing + if ('user_count' in createdRole && typeof createdRole.user_count === 'number') { + testerRole = { ...createdRole, user_count: Number(createdRole.user_count) }; + } else { + testerRole = { ...createdRole, user_count: 0 }; + } } return testerRole; @@ -42,11 +50,11 @@ async function ensureTesterRole() { // GET: /api/admin/testers - Get all testers with pagination and search router.get('/', createAuditLogger('ADMIN_TESTERS_ACCESSED'), async (req, res) => { try { - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 50; - const search = req.query.search || ''; + const page = typeof req.query.page === 'string' ? parseInt(req.query.page) : 1; + const limit = typeof req.query.limit === 'string' ? parseInt(req.query.limit) : 50; + const search = typeof req.query.search === 'string' ? req.query.search : ''; - const result = await getAllTesters(page, limit, search); + const result = await getAllTesters(page, limit, search); res.json(result); } catch (error) { console.error('Error fetching testers:', error); @@ -68,6 +76,9 @@ router.post('/', async (req, res) => { return res.status(404).json({ error: 'User not found' }); } + if (!req.user || typeof req.user.userId !== 'string') { + return res.status(401).json({ error: 'Unauthorized' }); + } const tester = await addTester( userId, user.username, @@ -79,7 +90,9 @@ router.post('/', async (req, res) => { // Auto-assign Tester role try { const testerRole = await ensureTesterRole(); - await assignRoleToUser(userId, testerRole.id); + if (testerRole) { + await assignRoleToUser(userId, testerRole.id); + } } catch (roleError) { console.error('Failed to assign Tester role:', roleError); // Don't fail the whole request if role assignment fails @@ -87,13 +100,15 @@ router.post('/', async (req, res) => { if (req.user?.userId) { try { + let ip = getClientIp(req); + if (Array.isArray(ip)) ip = ip[0] || ''; await logAdminAction({ adminId: req.user.userId, adminUsername: req.user.username || 'Unknown', actionType: 'TESTER_ADDED', targetUserId: userId, targetUsername: user.username, - ipAddress: getClientIp(req), + ipAddress: ip, userAgent: req.get('User-Agent'), details: { method: req.method, @@ -128,7 +143,9 @@ router.delete('/:userId', async (req, res) => { // Auto-remove Tester role try { const testerRole = await ensureTesterRole(); - await removeRoleFromUser(userId, testerRole.id); + if (testerRole) { + await removeRoleFromUser(userId, testerRole.id); + } } catch (roleError) { console.error('Failed to remove Tester role:', roleError); // Don't fail the whole request if role removal fails @@ -136,13 +153,15 @@ router.delete('/:userId', async (req, res) => { if (req.user?.userId) { try { + let ip = getClientIp(req); + if (Array.isArray(ip)) ip = ip[0] || ''; await logAdminAction({ adminId: req.user.userId, adminUsername: req.user.username || 'Unknown', actionType: 'TESTER_REMOVED', targetUserId: userId, targetUsername: user?.username || removedTester.username, - ipAddress: getClientIp(req), + ipAddress: ip, userAgent: req.get('User-Agent'), details: { method: req.method, @@ -176,11 +195,13 @@ router.put('/settings', async (req, res) => { // Log the settings update action if (req.user?.userId) { try { + let ip = getClientIp(req); + if (Array.isArray(ip)) ip = ip[0] || ''; await logAdminAction({ adminId: req.user.userId, adminUsername: req.user.username || 'Unknown', actionType: 'TESTER_SETTINGS_UPDATED', - ipAddress: getClientIp(req), + ipAddress: ip, userAgent: req.get('User-Agent'), details: { method: req.method, diff --git a/server/routes/admin/users.js b/server/routes/admin/users.ts similarity index 77% rename from server/routes/admin/users.js rename to server/routes/admin/users.ts index 36c9464..23b8e74 100644 --- a/server/routes/admin/users.js +++ b/server/routes/admin/users.ts @@ -4,8 +4,8 @@ import { requirePermission } from '../../middleware/rolePermissions.js'; import { getAllUsers, syncUserSessionCounts } from '../../db/admin.js'; import { getUserById } from '../../db/users.js'; import { logAdminAction } from '../../db/audit.js'; -import { isAdmin } from '../../middleware/isAdmin.js'; -import { getClientIp } from '../../tools/getIpAddress.js'; +import { isAdmin } from '../../middleware/admin.js'; +import { getClientIp } from '../../utils/getIpAddress.js'; const router = express.Router(); @@ -14,12 +14,12 @@ router.use(requirePermission('users')); // GET: /api/admin/users - Get all users with pagination, search, and filters router.get('/', createAuditLogger('ADMIN_USERS_ACCESSED'), async (req, res) => { try { - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 50; - const search = req.query.search || ''; - const filterAdmin = req.query.filterAdmin || 'all'; + const page = typeof req.query.page === 'string' ? parseInt(req.query.page) : 1; + const limit = typeof req.query.limit === 'string' ? parseInt(req.query.limit) : 50; + const search = typeof req.query.search === 'string' ? req.query.search : ''; + const filterAdmin = typeof req.query.filterAdmin === 'string' ? req.query.filterAdmin : 'all'; - const result = await getAllUsers(page, limit, search, filterAdmin); + const result = await getAllUsers(page, limit, search, filterAdmin); res.json(result); } catch (error) { console.error('Error fetching users:', error); @@ -30,7 +30,7 @@ router.get('/', createAuditLogger('ADMIN_USERS_ACCESSED'), async (req, res) => { // POST: /api/admin/users/:userId/reveal-ip - Reveal user's IP address router.post('/:userId/reveal-ip', async (req, res) => { try { - if (!isAdmin(req.user?.userId)) { + if (!req.user?.userId || !isAdmin(req.user.userId)) { return res.status(403).json({ error: 'Access denied - insufficient permissions' }); } @@ -42,13 +42,15 @@ router.post('/:userId/reveal-ip', async (req, res) => { } if (req.user?.userId) { + let ip = getClientIp(req); + if (Array.isArray(ip)) ip = ip[0] || ''; await logAdminAction({ adminId: req.user.userId, adminUsername: req.user.username || 'Unknown', actionType: 'IP_REVEALED', targetUserId: userId, targetUsername: user.username, - ipAddress: getClientIp(req), + ipAddress: ip, userAgent: req.get('User-Agent'), details: { revealedIP: user.ipAddress, diff --git a/server/routes/atis.js b/server/routes/atis.ts similarity index 52% rename from server/routes/atis.js rename to server/routes/atis.ts index f2080b9..1170622 100644 --- a/server/routes/atis.js +++ b/server/routes/atis.ts @@ -1,12 +1,33 @@ import express from 'express'; -import requireAuth from '../middleware/isAuthenticated.js'; +import requireAuth from '../middleware/auth.js'; import { updateSession } from '../db/sessions.js'; const router = express.Router(); -// POST: /api/atis/generate - Generate ATIS and store in session +interface ATISGenerateRequest { + sessionId: string; + ident: string; + icao: string; + remarks1?: string; + remarks2?: Record; + landing_runways: string[]; + departing_runways: string[]; + metar?: string; +} + +interface ExternalATISResponse { + status: string; + message?: string; + data?: { + text: string; + }; +} + +// POST: /api/atis/generate router.post('/generate', requireAuth, async (req, res) => { try { + const body: ATISGenerateRequest = req.body; + const { sessionId, ident, @@ -16,10 +37,13 @@ router.post('/generate', requireAuth, async (req, res) => { landing_runways, departing_runways, metar - } = req.body; + } = body; - if (!sessionId || !icao) { - return res.status(400).json({ error: 'Session ID and ICAO are required' }); + if (!sessionId || !icao || !ident) { + return res.status(400).json({ error: 'Session ID, ICAO, and Ident are required' }); + } + if (!Array.isArray(landing_runways) || !Array.isArray(departing_runways)) { + return res.status(400).json({ error: 'Landing and departing runways must be arrays' }); } const requestBody = { @@ -43,27 +67,29 @@ router.post('/generate', requireAuth, async (req, res) => { }); if (!response.ok) { - throw new Error(`External API responded with ${response.status}`); + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`External API responded with ${response.status}: ${errorText}`); } - const data = await response.json(); + const data = await response.json() as ExternalATISResponse; if (data.status !== 'success') { throw new Error(data.message || 'Failed to generate ATIS'); } - const generatedAtis = data.data.text; + const generatedAtis = data.data?.text; if (!generatedAtis) { - throw new Error('No ATIS data in response'); + throw new Error('No ATIS text in response'); } + const atisTimestamp = new Date().toISOString(); + const atisData = { - letter: ident, - text: generatedAtis, - timestamp: new Date().toISOString(), + letter: ident, + text: generatedAtis, + timestamp: atisTimestamp, }; - - const updatedSession = await updateSession(sessionId, { atis: atisData }); + const updatedSession = await updateSession(sessionId, { atis: JSON.stringify(atisData) }); if (!updatedSession) { throw new Error('Failed to update session with ATIS data'); } @@ -71,11 +97,12 @@ router.post('/generate', requireAuth, async (req, res) => { res.json({ atisText: generatedAtis, ident, - timestamp: atisData.timestamp, + timestamp: atisTimestamp, }); } catch (error) { console.error('Error generating ATIS:', error); - res.status(500).json({ error: error.message || 'Failed to generate ATIS' }); + const errorMessage = (error instanceof Error) ? error.message : 'Failed to generate ATIS'; + res.status(500).json({ error: errorMessage }); } }); diff --git a/server/routes/auth.js b/server/routes/auth.ts similarity index 73% rename from server/routes/auth.js rename to server/routes/auth.ts index 1636856..e95ddfd 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.ts @@ -3,35 +3,38 @@ import jwt from 'jsonwebtoken'; import axios from 'axios'; import { createOrUpdateUser, getUserById, updateUserSettings, updateRobloxAccount, unlinkRobloxAccount } from '../db/users.js'; import { authLimiter } from '../middleware/security.js'; -import { detectVPN } from '../tools/detectVPN.js'; -import { isAdmin } from '../middleware/isAdmin.js'; +import { detectVPN } from '../utils/detectVPN.js'; +import { isAdmin } from '../middleware/admin.js'; import { recordLogin, recordNewUser } from '../db/statistics.js'; import { isUserBanned } from '../db/ban.js'; import { isTester } from '../db/testers.js'; -import { getClientIp } from '../tools/getIpAddress.js'; -import requireAuth from '../middleware/isAuthenticated.js'; +import { getClientIp } from '../utils/getIpAddress.js'; +import requireAuth from '../middleware/auth.js'; const router = express.Router(); -const CLIENT_ID = process.env.DISCORD_CLIENT_ID; -const CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET; -const REDIRECT_URI = process.env.DISCORD_REDIRECT_URI; -const FRONTEND_URL = process.env.FRONTEND_URL; -const JWT_SECRET = process.env.JWT_SECRET; +const CLIENT_ID = process.env.DISCORD_CLIENT_ID ?? ''; +const CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET ?? ''; +const REDIRECT_URI = process.env.DISCORD_REDIRECT_URI ?? ''; +const FRONTEND_URL = process.env.FRONTEND_URL ?? ''; +const JWT_SECRET = process.env.JWT_SECRET ?? ''; -const ROBLOX_CLIENT_ID = process.env.ROBLOX_CLIENT_ID; -const ROBLOX_CLIENT_SECRET = process.env.ROBLOX_CLIENT_SECRET; -const ROBLOX_REDIRECT_URI = process.env.ROBLOX_REDIRECT_URI; +const ROBLOX_CLIENT_ID = process.env.ROBLOX_CLIENT_ID ?? ''; +const ROBLOX_CLIENT_SECRET = process.env.ROBLOX_CLIENT_SECRET ?? ''; +const ROBLOX_REDIRECT_URI = process.env.ROBLOX_REDIRECT_URI ?? ''; // VATSIM OAuth (linking) -const VATSIM_CLIENT_ID = process.env.VATSIM_CLIENT_ID; -const VATSIM_CLIENT_SECRET = process.env.VATSIM_CLIENT_SECRET; -const VATSIM_REDIRECT_URI = process.env.VATSIM_REDIRECT_URI; -const VATSIM_AUTH_BASE = process.env.VATSIM_AUTH_BASE +const VATSIM_CLIENT_ID = process.env.VATSIM_CLIENT_ID ?? ''; +const VATSIM_CLIENT_SECRET = process.env.VATSIM_CLIENT_SECRET ?? ''; +const VATSIM_REDIRECT_URI = process.env.VATSIM_REDIRECT_URI ?? ''; +const VATSIM_AUTH_BASE = process.env.VATSIM_AUTH_BASE ?? ''; // GET: /api/auth/discord - redirect to Discord for authentication -router.get('/discord', (req, res) => { - const discordAuthUrl = `https://discord.com/api/oauth2/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&response_type=code&scope=identify`; +router.get('/discord', (_req, res) => { + if (!CLIENT_ID || !REDIRECT_URI) { + return res.status(500).json({ error: 'Discord OAuth not configured' }); + } + const discordAuthUrl = `https://discord.com/api/oauth2/authorize?client_id=${encodeURIComponent(CLIENT_ID)}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&response_type=code&scope=identify`; res.redirect(discordAuthUrl); }); @@ -46,11 +49,11 @@ router.get('/discord/callback', authLimiter, async (req, res) => { try { const tokenResponse = await axios.post('https://discord.com/api/oauth2/token', new URLSearchParams({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, + client_id: String(CLIENT_ID), + client_secret: String(CLIENT_SECRET), grant_type: 'authorization_code', - code: code, - redirect_uri: REDIRECT_URI, + code: String(code), + redirect_uri: String(REDIRECT_URI), }), { headers: { @@ -69,7 +72,8 @@ router.get('/discord/callback', authLimiter, async (req, res) => { const discordUser = userResponse.data; - const ipAddress = getClientIp(req); + let ipAddress = getClientIp(req); + if (Array.isArray(ipAddress)) ipAddress = ipAddress[0] || ''; const isVpn = await detectVPN(req); const existingUser = await getUserById(discordUser.id); @@ -101,7 +105,7 @@ router.get('/discord/callback', authLimiter, async (req, res) => { exp: Math.floor(Date.now() / 1000) + (7 * 24 * 60 * 60), // 7d }; - const token = jwt.sign(payload, JWT_SECRET, { + const token = jwt.sign(payload, JWT_SECRET as string, { algorithm: 'HS256' }); @@ -123,11 +127,12 @@ router.get('/discord/callback', authLimiter, async (req, res) => { // GET: /api/auth/roblox - redirect to Roblox for authentication router.get('/roblox', requireAuth, (req, res) => { - const state = jwt.sign({ userId: req.user.userId }, JWT_SECRET, { expiresIn: '15m' }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); + const state = jwt.sign({ userId: req.user.userId }, JWT_SECRET as string, { expiresIn: '15m' }); const params = new URLSearchParams({ - client_id: ROBLOX_CLIENT_ID, - redirect_uri: ROBLOX_REDIRECT_URI, + client_id: String(ROBLOX_CLIENT_ID), + redirect_uri: String(ROBLOX_REDIRECT_URI), response_type: 'code', scope: 'openid profile', state: state @@ -139,22 +144,23 @@ router.get('/roblox', requireAuth, (req, res) => { // GET: /api/auth/roblox/callback - handle Roblox OAuth2 callback router.get('/roblox/callback', authLimiter, async (req, res) => { - const { code, state } = req.query; + const code = typeof req.query.code === 'string' ? req.query.code : ''; + const state = typeof req.query.state === 'string' ? req.query.state : ''; if (!code || !state) { return res.redirect(FRONTEND_URL + '/settings?error=roblox_auth_failed'); } try { - const decoded = jwt.verify(state, JWT_SECRET); - const userId = decoded.userId; + const decoded = jwt.verify(state, JWT_SECRET as string) as { userId: string }; + const userId = (decoded).userId; const tokenResponse = await axios.post('https://apis.roblox.com/oauth/v1/token', new URLSearchParams({ - client_id: ROBLOX_CLIENT_ID, - client_secret: ROBLOX_CLIENT_SECRET, + client_id: String(ROBLOX_CLIENT_ID), + client_secret: String(ROBLOX_CLIENT_SECRET), grant_type: 'authorization_code', - code: code, + code: String(code), }), { headers: { @@ -190,6 +196,7 @@ router.get('/roblox/callback', authLimiter, async (req, res) => { // POST: /api/auth/roblox/unlink - unlink Roblox account router.post('/roblox/unlink', requireAuth, async (req, res) => { try { + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); await unlinkRobloxAccount(req.user.userId); res.json({ success: true, message: 'Roblox account unlinked' }); } catch (error) { @@ -200,14 +207,14 @@ router.post('/roblox/unlink', requireAuth, async (req, res) => { // GET: /api/auth/vatsim - redirect to VATSIM for linking router.get('/vatsim', requireAuth, (req, res) => { - if (!VATSIM_CLIENT_ID || !VATSIM_REDIRECT_URI) { + if (!VATSIM_CLIENT_ID || !VATSIM_REDIRECT_URI || !VATSIM_AUTH_BASE) { return res.status(500).json({ error: 'VATSIM OAuth not configured' }); } - - const state = jwt.sign({ userId: req.user.userId }, JWT_SECRET, { expiresIn: '15m' }); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); + const state = jwt.sign({ userId: req.user.userId }, JWT_SECRET as string, { expiresIn: '15m' }); const params = new URLSearchParams({ client_id: String(VATSIM_CLIENT_ID), - redirect_uri: VATSIM_REDIRECT_URI, + redirect_uri: String(VATSIM_REDIRECT_URI), response_type: 'code', scope: 'vatsim_details', state @@ -226,7 +233,9 @@ router.get('/vatsim', requireAuth, (req, res) => { // GET: /api/auth/vatsim/callback - handle VATSIM OAuth2 callback (server-side) router.get('/vatsim/callback', authLimiter, async (req, res) => { - const { code, state } = req.query; + + const code = typeof req.query.code === 'string' ? req.query.code : ''; + const state = typeof req.query.state === 'string' ? req.query.state : ''; if (!code || !state) { return res.redirect(FRONTEND_URL + '/settings?error=vatsim_auth_failed'); } @@ -234,14 +243,17 @@ router.get('/vatsim/callback', authLimiter, async (req, res) => { try { let decoded; try { - decoded = jwt.verify(String(state), JWT_SECRET); + decoded = jwt.verify(String(state), JWT_SECRET as string); } catch (err) { return res.redirect(FRONTEND_URL + '/settings?error=vatsim_auth_failed'); } - const userId = decoded?.userId; - if (!userId) { - return res.redirect(FRONTEND_URL + '/settings?error=vatsim_auth_failed'); - } + let userId: string | undefined; + if (typeof decoded === 'object' && decoded !== null && 'userId' in decoded) { + userId = (decoded as { userId: string }).userId; + } + if (!userId) { + return res.redirect(FRONTEND_URL + '/settings?error=vatsim_auth_failed'); + } if (!VATSIM_CLIENT_ID || !VATSIM_CLIENT_SECRET || !VATSIM_REDIRECT_URI) { return res.redirect(FRONTEND_URL + '/settings?error=vatsim_not_configured'); @@ -253,7 +265,7 @@ router.get('/vatsim/callback', authLimiter, async (req, res) => { new URLSearchParams({ grant_type: 'authorization_code', code: String(code), - redirect_uri: VATSIM_REDIRECT_URI, + redirect_uri: String(VATSIM_REDIRECT_URI), }), { headers: { @@ -306,20 +318,25 @@ router.get('/vatsim/callback', authLimiter, async (req, res) => { } } // parsed for VATSIM callback handled - const fallbackMap = { 0: 'OBS', 1: 'S1', 2: 'S2', 3: 'S3', 4: 'C1', 5: 'C2', 6: 'C3', 7: 'I1', 8: 'I2', 9: 'I3', 10: 'SUP', 11: 'ADM' }; - const fallbackShort = ratingShort || (numeric != null && Number.isFinite(numeric) ? fallbackMap[numeric] || null : null); + const fallbackMap: Record = { 0: 'OBS', 1: 'S1', 2: 'S2', 3: 'S3', 4: 'C1', 5: 'C2', 6: 'C3', 7: 'I1', 8: 'I2', 9: 'I3', 10: 'SUP', 11: 'ADM' }; + const fallbackShort = ratingShort || (numeric != null && Number.isFinite(numeric) ? fallbackMap[numeric as number] || null : null); const { updateVatsimAccount } = await import('../db/users.js'); await updateVatsimAccount(userId, { - vatsimCid: cid || null, - ratingId: Number.isFinite(numeric) ? numeric : null, - ratingShort: fallbackShort, - ratingLong: ratingLong || null, + vatsimCid: cid || '', + ratingId: Number.isFinite(numeric) ? numeric as number : 0, + ratingShort: fallbackShort ?? undefined, + ratingLong: ratingLong ?? undefined, }); res.redirect(FRONTEND_URL + '/settings?vatsim_linked=true'); } catch (error) { - console.error('VATSIM link error (callback):', error?.response?.data || error.message); + if (error instanceof Error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + console.error('VATSIM link error (callback):', (error as any)?.response?.data || error.message); + } else { + console.error('VATSIM link error (callback):', error); + } res.redirect(FRONTEND_URL + '/settings?error=vatsim_auth_failed'); } }); @@ -327,17 +344,18 @@ router.get('/vatsim/callback', authLimiter, async (req, res) => { // POST: /api/auth/vatsim/exchange - exchange code to link account router.post('/vatsim/exchange', authLimiter, requireAuth, async (req, res) => { try { - const { code, state } = req.body || {}; + const code = typeof req.body.code === 'string' ? req.body.code : ''; + const state = typeof req.body.state === 'string' ? req.body.state : ''; if (!code || !state) { return res.status(400).json({ error: 'Missing code or state' }); } - let decoded; + let decoded: { userId?: string }; try { - decoded = jwt.verify(state, JWT_SECRET); - } catch (err) { + decoded = jwt.verify(state, JWT_SECRET as string) as { userId?: string }; + } catch { return res.status(400).json({ error: 'Invalid state' }); } - if (!decoded || decoded.userId !== req.user.userId) { + if (!decoded || (decoded).userId !== req.user?.userId) { return res.status(400).json({ error: 'State/user mismatch' }); } if (!VATSIM_CLIENT_ID || !VATSIM_CLIENT_SECRET || !VATSIM_REDIRECT_URI) { @@ -351,7 +369,7 @@ router.post('/vatsim/exchange', authLimiter, requireAuth, async (req, res) => { new URLSearchParams({ grant_type: 'authorization_code', code: String(code), - redirect_uri: VATSIM_REDIRECT_URI, + redirect_uri: String(VATSIM_REDIRECT_URI), }), { headers: { @@ -401,19 +419,25 @@ router.post('/vatsim/exchange', authLimiter, requireAuth, async (req, res) => { } } // parsed for VATSIM exchange handled - const fallbackMap2 = { 0: 'OBS', 1: 'S1', 2: 'S2', 3: 'S3', 4: 'C1', 5: 'C2', 6: 'C3', 7: 'I1', 8: 'I2', 9: 'I3', 10: 'SUP', 11: 'ADM' }; - const fallbackShort = ratingShort2 || (numeric2 != null && Number.isFinite(numeric2) ? fallbackMap2[numeric2] || null : null); + const fallbackMap2: Record = { 0: 'OBS', 1: 'S1', 2: 'S2', 3: 'S3', 4: 'C1', 5: 'C2', 6: 'C3', 7: 'I1', 8: 'I2', 9: 'I3', 10: 'SUP', 11: 'ADM' }; + const fallbackShort = ratingShort2 || (numeric2 != null && Number.isFinite(numeric2) ? fallbackMap2[numeric2 as number] || null : null); const { updateVatsimAccount } = await import('../db/users.js'); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); await updateVatsimAccount(req.user.userId, { - vatsimCid: cid || null, - ratingId: Number.isFinite(numeric) ? numeric : null, - ratingShort: fallbackShort, - ratingLong: ratingLong || null, + vatsimCid: cid || '', + ratingId: Number.isFinite(numeric2) ? numeric2 as number : 0, + ratingShort: fallbackShort ?? undefined, + ratingLong: ratingLong2 ?? undefined, }); - res.json({ success: true, vatsimCid: cid, ratingShort: fallbackShort, ratingLong }); + res.json({ success: true, vatsimCid: cid, ratingShort: fallbackShort, ratingLong: ratingLong2 }); } catch (error) { - console.error('VATSIM link error:', error?.response?.data || error.message); + if (error instanceof Error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + console.error('VATSIM link error:', (error as any)?.response?.data || error.message); + } else { + console.error('VATSIM link error:', error); + } res.status(500).json({ error: 'VATSIM link failed' }); } }); @@ -422,12 +446,13 @@ router.post('/vatsim/exchange', authLimiter, requireAuth, async (req, res) => { router.post('/vatsim/unlink', requireAuth, async (req, res) => { try { const { unlinkVatsimAccount } = await import('../db/users.js'); - await unlinkVatsimAccount(req.user.userId); + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); + await unlinkVatsimAccount(req.user.userId); res.cookie('vatsim_force', '1', { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: 5 * 60 * 1000, // 5 minutes + maxAge: 5 * 60 * 1000, path: '/', }); res.json({ success: true }); @@ -440,6 +465,7 @@ router.post('/vatsim/unlink', requireAuth, async (req, res) => { // GET: /api/auth/me - get current user info router.get('/me', requireAuth, async (req, res) => { try { + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const user = await getUserById(req.user.userId); if (!user) { return res.status(404).json({ error: 'User not found' }); @@ -461,12 +487,12 @@ router.get('/me', requireAuth, async (req, res) => { roleId: user.roleId, roleName: user.roleName, rolePermissions: user.rolePermissions, - robloxUserId: user.robloxUserId, - robloxUsername: user.robloxUsername, - vatsimCid: user.vatsimCid, - vatsimRatingId: user.vatsimRatingId, - vatsimRatingShort: user.vatsimRatingShort, - vatsimRatingLong: user.vatsimRatingLong, + robloxUserId: user.roblox_user_id, + robloxUsername: user.roblox_username, + vatsimCid: user.vatsim_cid, + vatsimRatingId: user.vatsim_rating_id, + vatsimRatingShort: user.vatsim_rating_short, + vatsimRatingLong: user.vatsim_rating_long, }); } catch (error) { console.error('Error fetching user:', error); @@ -477,6 +503,7 @@ router.get('/me', requireAuth, async (req, res) => { // PUT: /api/auth/me - update current user settings router.put('/me', requireAuth, async (req, res) => { try { + if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const { settings } = req.body; if (!settings || typeof settings !== 'object') { return res.status(400).json({ error: 'Invalid settings payload' }); diff --git a/server/routes/chats.js b/server/routes/chats.ts similarity index 65% rename from server/routes/chats.js rename to server/routes/chats.ts index d186a7b..8af01c7 100644 --- a/server/routes/chats.js +++ b/server/routes/chats.ts @@ -1,54 +1,65 @@ import express from 'express'; import { addChatMessage, getChatMessages, deleteChatMessage } from '../db/chats.js'; -import requireAuth from '../middleware/isAuthenticated.js'; import { chatMessageLimiter } from '../middleware/rateLimiting.js'; +import requireAuth from '../middleware/auth.js'; const router = express.Router(); -// GET: /api/chats/:sessionId - get recent messages +// GET: /api/chats/:sessionId router.get('/:sessionId', requireAuth, async (req, res) => { try { const messages = await getChatMessages(req.params.sessionId); res.json(messages); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch chat messages' }); } }); -// POST: /api/chats/:sessionId - add a message (for fallback, not websocket) +// POST: /api/chats/:sessionId router.post('/:sessionId', chatMessageLimiter, requireAuth, async (req, res) => { try { const { message } = req.body; - if (message.length > 500) { + 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, + avatar: user.avatar ?? '', message }); res.status(201).json(chatMsg); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to send message' }); } }); -// DELETE: /api/chats/:sessionId/:messageId - delete own message +// DELETE: /api/chats/:sessionId/:messageId router.delete('/:sessionId/:messageId', requireAuth, async (req, res) => { try { + const user = req.user; + 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' }); + } const success = await deleteChatMessage( req.params.sessionId, - req.params.messageId, - req.user.userId + messageId, + user.userId ); if (success) { res.json({ success: true }); } else { res.status(403).json({ error: 'Cannot delete this message' }); } - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to delete message' }); } }); diff --git a/server/routes/data.js b/server/routes/data.ts similarity index 74% rename from server/routes/data.js rename to server/routes/data.ts index 0185cc7..35dee68 100644 --- a/server/routes/data.js +++ b/server/routes/data.ts @@ -4,6 +4,8 @@ import fs from 'fs'; import { fileURLToPath } from 'url'; import { getTesterSettings } from '../db/testers.js'; import { getActiveNotifications } from '../db/notifications.js'; +import { mainDb, flightsDb } from '../db/connection.js'; +import { sql } from 'kysely'; import dotenv from 'dotenv'; const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development'; @@ -17,16 +19,38 @@ const aircraftPath = path.join(__dirname, '..', 'data', 'aircraftData.json'); const airlinesPath = path.join(__dirname, '..', 'data', 'airlineData.json'); const backgroundsPath = path.join(__dirname, '..', '..', 'public', 'assets', 'app', 'backgrounds'); +interface AirportFrequencies { + APP?: string; + TWR?: string; + GND?: string; + DEL?: string; + [key: string]: string | undefined; +} + +interface Airport { + icao: string; + name: string; + controlName?: string; + elevation: number; + picture: string; + allFrequencies: AirportFrequencies; + sids: string[]; + runways: string[]; + departures: Record>; + stars: string[]; + arrivals: Record>; +} + const router = express.Router(); -// GET: /api/data/airports - list of airports +// GET: /api/data/airports router.get('/airports', (req, res) => { try { if (!fs.existsSync(airportsPath)) { return res.status(404).json({ error: "Airport data not found" }); } - const data = JSON.parse(fs.readFileSync(airportsPath, "utf8")); + const data: Airport[] = JSON.parse(fs.readFileSync(airportsPath, "utf8")); res.json(data); } catch (error) { console.error("Error reading airport data:", error); @@ -34,7 +58,7 @@ router.get('/airports', (req, res) => { } }); -// GET: /api/data/aircrafts - list of aircrafts +// GET: /api/data/aircrafts router.get('/aircrafts', (req, res) => { try { if (!fs.existsSync(aircraftPath)) { @@ -49,7 +73,7 @@ router.get('/aircrafts', (req, res) => { } }); -// GET: /api/data/airlines - list of airlines +// GET: /api/data/airlines router.get('/airlines', (req, res) => { try { if (!fs.existsSync(airlinesPath)) { @@ -64,7 +88,7 @@ router.get('/airlines', (req, res) => { } }); -// GET: /api/data/frequencies - list of airport frequencies +// GET: /api/data/frequencies router.get('/frequencies', (req, res) => { try { if (!fs.existsSync(airportsPath)) { @@ -80,9 +104,9 @@ router.get('/frequencies', (req, res) => { approach: 'APP' }; - const data = JSON.parse(fs.readFileSync(airportsPath, "utf8")); - const frequencies = data.map(airport => { - const allFreqs = airport.allFrequencies || airport.frequencies || {}; + const data: Airport[] = JSON.parse(fs.readFileSync(airportsPath, "utf8")); + const frequencies = data.map((airport: Airport) => { + const allFreqs = airport.allFrequencies || {}; const displayFreqs = freqOrder .map(type => { let freq = allFreqs[type]; @@ -98,17 +122,17 @@ router.get('/frequencies', (req, res) => { }) .filter(Boolean); - const usedTypes = new Set(displayFreqs.map(f => f.type)); + const usedTypes = new Set(displayFreqs.map(f => f!.type)); const remainingFreqs = Object.entries(allFreqs) .filter(([key, value]) => !usedTypes.has(key) && !Object.keys(freqMapping).includes(key) && - value.toLowerCase() !== 'n/a' + value && value.toLowerCase() !== 'n/a' ) .slice(0, 4 - displayFreqs.length) .map(([type, freq]) => ({ type: type.toUpperCase(), freq })); - const allDisplayFreqs = [...displayFreqs, ...remainingFreqs].slice(0, 4); + const allDisplayFreqs = [...displayFreqs.filter(Boolean), ...remainingFreqs].slice(0, 4); return { icao: airport.icao, @@ -123,7 +147,7 @@ router.get('/frequencies', (req, res) => { } }); -// GET: /api/data/backgrounds - list of background images +// GET: /api/data/backgrounds router.get('/backgrounds', (req, res) => { try { if (!fs.existsSync(backgroundsPath)) { @@ -151,15 +175,15 @@ router.get('/backgrounds', (req, res) => { } }); -// GET: /api/data/airports/:icao/runways - runways for specific airport +// GET: /api/data/airports/:icao/runways router.get('/airports/:icao/runways', (req, res) => { try { if (!fs.existsSync(airportsPath)) { return res.status(404).json({ error: "Airport data not found" }); } - const data = JSON.parse(fs.readFileSync(airportsPath, "utf8")); - const airport = data.find((a) => a.icao === req.params.icao); + 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" }); } @@ -170,15 +194,15 @@ router.get('/airports/:icao/runways', (req, res) => { } }); -// GET: /api/data/airports/:icao/sids - sids for specific airport +// GET: /api/data/airports/:icao/sids router.get('/airports/:icao/sids', (req, res) => { try { if (!fs.existsSync(airportsPath)) { return res.status(404).json({ error: "Airport data not found" }); } - const data = JSON.parse(fs.readFileSync(airportsPath, "utf8")); - const airport = data.find((a) => a.icao === req.params.icao); + 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" }); } @@ -189,15 +213,15 @@ router.get('/airports/:icao/sids', (req, res) => { } }); -// GET: /api/data/airports/:icao/stars - stars for specific airport +// GET: /api/data/airports/:icao/stars router.get('/airports/:icao/stars', (req, res) => { try { if (!fs.existsSync(airportsPath)) { return res.status(404).json({ error: "Airport data not found" }); } - const data = JSON.parse(fs.readFileSync(airportsPath, "utf8")); - const airport = data.find((a) => a.icao === req.params.icao); + 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" }); } @@ -208,27 +232,27 @@ router.get('/airports/:icao/stars', (req, res) => { } }); -// GET: /api/data/statistics - get application statistics +// GET: /api/data/statistics router.get('/statistics', async (req, res) => { try { - const pool = (await import('../db/connections/connection.js')).default; - const flightsPool = (await import('../db/connections/flightsConnection.js')).default; const { getAllSessions } = await import('../db/sessions.js'); - const sessionsResult = await pool.query('SELECT COUNT(*) FROM sessions'); - const sessionsCreated = parseInt(sessionsResult.rows[0].count, 10); + const sessionsResult = await mainDb.selectFrom('sessions').select(sql`count(*)`.as('count')).executeTakeFirst(); + const sessionsCreated = parseInt(sessionsResult!.count as string, 10); - const usersResult = await pool.query('SELECT COUNT(*) FROM users'); - const registeredUsers = parseInt(usersResult.rows[0].count, 10); + const usersResult = await mainDb.selectFrom('users').select(sql`count(*)`.as('count')).executeTakeFirst(); + const registeredUsers = parseInt(usersResult!.count as string, 10); const sessions = await getAllSessions(); let flightsLogged = 0; for (const session of sessions) { try { - const flightResult = await flightsPool.query(`SELECT COUNT(*) FROM flights_${session.session_id}`); - flightsLogged += parseInt(flightResult.rows[0].count, 10); + const tableName = `flights_${session.session_id}` as keyof typeof flightsDb.schema; + const flightResult = await flightsDb.selectFrom(tableName).select(sql`count(*)`.as('count')).executeTakeFirst(); + flightsLogged += parseInt(flightResult!.count as string, 10); } catch (error) { - console.warn(`Could not count flights for session ${session.session_id}:`, error.message); + const errMsg = error instanceof Error ? error.message : String(error); + console.warn(`Could not count flights for session ${session.session_id}:`, errMsg); } } @@ -245,7 +269,7 @@ router.get('/statistics', async (req, res) => { } }); -// GET: /api/data/settings - Get tester gate settings +// GET: /api/data/settings router.get('/settings', async (req, res) => { try { const settings = await getTesterSettings(); @@ -256,7 +280,7 @@ router.get('/settings', async (req, res) => { } }); -// GET: /api/data/notifications/active - Get active notifications (for Navbar) +// GET: /api/data/notifications/active router.get('/notifications/active', async (req, res) => { try { const notifications = await getActiveNotifications(); diff --git a/server/routes/flights.js b/server/routes/flights.ts similarity index 75% rename from server/routes/flights.js rename to server/routes/flights.ts index 8290e97..e981a05 100644 --- a/server/routes/flights.js +++ b/server/routes/flights.ts @@ -1,12 +1,11 @@ import express from 'express'; -import requireAuth from '../middleware/isAuthenticated.js'; +import requireAuth from '../middleware/auth.js'; import optionalAuth from '../middleware/optionalAuth.js'; import { getFlightsBySession, addFlight, updateFlight, deleteFlight, validateAcarsAccess } from '../db/flights.js'; import { broadcastFlightEvent } from '../websockets/flightsWebsocket.js'; import { recordNewFlight } from '../db/statistics.js'; -import { getClientIp } from '../tools/getIpAddress.js'; -import flightsPool from '../db/connections/flightsConnection.js'; -import pool from '../db/connections/connection.js'; +import { getClientIp } from '../utils/getIpAddress.js'; +import { mainDb, flightsDb } from '../db/connection.js'; import { flightCreationLimiter, acarsValidationLimiter } from '../middleware/rateLimiting.js'; const router = express.Router(); @@ -18,7 +17,7 @@ router.get('/:sessionId', requireAuth, async (req, res) => { try { const flights = await getFlightsBySession(req.params.sessionId); res.json(flights); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch flights' }); } }); @@ -36,10 +35,11 @@ router.post('/:sessionId', optionalAuth, flightCreationLimiter, async (req, res) await recordNewFlight(); - const { acars_token, user_id, ip_address, ...sanitizedFlight } = flight; + const sanitizedFlight = flight ? Object.fromEntries(Object.entries(flight).filter(([k]) => !['acars_token', 'user_id', 'ip_address'].includes(k))) : {}; broadcastFlightEvent(req.params.sessionId, 'flightAdded', sanitizedFlight); res.status(201).json(flight); - } catch (error) { + } catch (err) { + console.error('Failed to add flight:', err); res.status(500).json({ error: 'Failed to add flight' }); } }); @@ -61,7 +61,7 @@ router.put('/:sessionId/:flightId', requireAuth, async (req, res) => { broadcastFlightEvent(req.params.sessionId, 'flightUpdated', flight); res.json(flight); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to update flight' }); } }); @@ -74,7 +74,7 @@ router.delete('/:sessionId/:flightId', requireAuth, async (req, res) => { broadcastFlightEvent(req.params.sessionId, 'flightDeleted', { flightId: req.params.flightId }); res.json({ success: true }); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to delete flight' }); } }); @@ -83,7 +83,7 @@ router.delete('/:sessionId/:flightId', requireAuth, async (req, res) => { router.get('/:sessionId/:flightId/validate-acars', acarsValidationLimiter, async (req, res) => { try { const { sessionId, flightId } = req.params; - const acarsToken = req.query.accessId; + const acarsToken = typeof req.query.accessId === 'string' ? req.query.accessId : undefined; if (!acarsToken) { return res.status(400).json({ valid: false, error: 'Missing access token' }); @@ -91,7 +91,7 @@ router.get('/:sessionId/:flightId/validate-acars', acarsValidationLimiter, async const result = await validateAcarsAccess(sessionId, flightId, acarsToken); res.json(result); - } catch (error) { + } catch { res.status(500).json({ valid: false, error: 'Validation failed' }); } }); @@ -118,7 +118,7 @@ router.post('/acars/active', acarsValidationLimiter, async (req, res) => { }); res.json({ success: true }); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to mark ACARS as active' }); } }); @@ -132,7 +132,7 @@ router.delete('/acars/active/:sessionId/:flightId', async (req, res) => { activeAcarsTerminals.delete(key); res.json({ success: true }); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to mark ACARS as inactive' }); } }); @@ -140,38 +140,40 @@ router.delete('/acars/active/:sessionId/:flightId', async (req, res) => { // GET: /api/flights/acars/active - get all active ACARS terminals router.get('/acars/active', async (req, res) => { try { - const activeFlights = []; + interface ActiveFlight { + [key: string]: unknown; + } + const activeFlights: ActiveFlight[] = []; for (const [key, { sessionId, flightId }] of activeAcarsTerminals.entries()) { try { const tableName = `flights_${sessionId}`; - const result = await flightsPool.query( - `SELECT * FROM ${tableName} WHERE id = $1`, - [flightId] - ); - - if (result.rows.length > 0) { - activeFlights.push(result.rows[0]); + const result = await flightsDb + .selectFrom(tableName) + .selectAll() + .where('id', '=', flightId) + .execute(); + + if (result.length > 0) { + activeFlights.push(result[0]); } - } catch (error) { + } catch { activeAcarsTerminals.delete(key); } } - // Fetch user data for all active flights const userIds = [...new Set(activeFlights.map(f => f.user_id).filter(Boolean))]; - let usersMap = new Map(); + const usersMap = new Map(); if (userIds.length > 0) { try { - const usersResult = await pool.query( - `SELECT id, username as discord_username, avatar as discord_avatar_url - FROM users - WHERE id = ANY($1)`, - [userIds] - ); - - usersResult.rows.forEach(user => { + const users = await mainDb + .selectFrom('users') + .select(['id', 'username as discord_username', 'avatar as discord_avatar_url']) + .where('id', 'in', userIds as string[]) + .execute(); + + users.forEach(user => { usersMap.set(user.id, { discord_username: user.discord_username, discord_avatar_url: user.discord_avatar_url @@ -179,24 +181,31 @@ router.get('/acars/active', async (req, res) => { : null }); }); - } catch (userError) { - console.error('Error fetching user data for active ACARS:', userError); + } catch { + // ignore user fetch errors } } - // Enrich flights with user data - const enrichedFlights = activeFlights.map(flight => { + interface SanitizedFlight { + [key: string]: unknown; + user?: { + discord_username: string; + discord_avatar_url: string | null; + }; + } + + const enrichedFlights = activeFlights.map((flight: Record) => { const { user_id, ip_address, acars_token, ...sanitizedFlight } = flight; - if (flight.user_id && usersMap.has(flight.user_id)) { - sanitizedFlight.user = usersMap.get(flight.user_id); + if (user_id && usersMap.has(user_id)) { + (sanitizedFlight as SanitizedFlight).user = usersMap.get(user_id); } - return sanitizedFlight; + return sanitizedFlight as SanitizedFlight; }); res.json(enrichedFlights); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch active ACARS terminals' }); } }); diff --git a/server/routes/index.js b/server/routes/index.ts similarity index 73% rename from server/routes/index.js rename to server/routes/index.ts index 7c19bb8..81c18b7 100644 --- a/server/routes/index.js +++ b/server/routes/index.ts @@ -1,4 +1,5 @@ -import express from "express"; +import express from 'express'; + import { getAppVersion } from '../db/version.js'; import dataRouter from "./data.js"; @@ -25,14 +26,14 @@ router.use('/uploads', uploadsRouter); router.use('/admin', adminRouter); router.use('/logbook', logbookRouter); -router.get('/version', async (req, res) => { - try { - const version = await getAppVersion(); - res.json({ version: version.version }); - } catch (error) { - console.error('Error fetching version:', error); - res.json({ version: '2.0.0.3' }); - } +router.get('/version', async (_req, res) => { + try { + const version = await getAppVersion(); + res.json({ version: version.version }); + } catch (error) { + console.error('Error fetching version:', error); + res.json({ version: '2.0.0.3' }); + } }); export default router; \ No newline at end of file diff --git a/server/routes/logbook.js b/server/routes/logbook.ts similarity index 58% rename from server/routes/logbook.js rename to server/routes/logbook.ts index c06455d..39b8296 100644 --- a/server/routes/logbook.js +++ b/server/routes/logbook.ts @@ -1,6 +1,8 @@ +import { Request, Response } from 'express'; +import { JwtPayloadClient } from '../types/JwtPayload.js'; import express from 'express'; -import requireAuth from '../middleware/isAuthenticated.js'; -import { isAdmin, requireAdmin } from '../middleware/isAdmin.js'; +import requireAuth from '../middleware/auth.js'; +import { isAdmin, requireAdmin } from '../middleware/admin.js'; import { getUserFlights, getFlightById, @@ -23,7 +25,16 @@ import { markAllNotificationsAsRead, deleteNotification } from '../db/userNotifications.js'; -import pool from '../db/connections/connection.js'; +import { mainDb } from '../db/connection.js'; + +function isJwtPayloadClient(user: unknown): user is JwtPayloadClient { + return ( + typeof user === 'object' && + user !== null && + 'userId' in user && + typeof (user as Record).userId === 'string' + ); +} const router = express.Router(); @@ -52,9 +63,14 @@ router.get('/public/:shareToken/telemetry', async (req, res) => { return res.status(404).json({ error: 'Flight not found' }); } - const telemetry = await getFlightTelemetry(flight.id); + const hasId = typeof flight === 'object' && flight !== null && 'id' in flight && typeof (flight.id) === 'number'; + const flightId = hasId ? (flight as { id: number }).id : undefined; + if (!flightId) { + return res.status(400).json({ error: 'Invalid flight id' }); + } + const telemetry = await getFlightTelemetry(flightId); res.json(telemetry); - } catch (error) { + } catch (error: unknown) { console.error('Error fetching shared flight telemetry:', error); res.status(500).json({ error: 'Failed to fetch telemetry' }); } @@ -80,21 +96,20 @@ router.get('/pilot/:username', async (req, res) => { router.get('/check-tracking/:callsign', async (req, res) => { try { const { callsign } = req.params; - const activeFlight = await pool.query( - `SELECT id, roblox_username, share_token - FROM logbook_flights - WHERE UPPER(callsign) = UPPER($1) - AND flight_status IN ('active', 'pending') - LIMIT 1`, - [callsign] - ); - - if (activeFlight.rows.length > 0) { + const activeFlight = await mainDb + .selectFrom('logbook_flights') + .select(['id', 'roblox_username', 'share_token']) + .where(mainDb.fn('UPPER', ['callsign']), '=', callsign.toUpperCase()) + .where('flight_status', 'in', ['active', 'pending']) + .limit(1) + .execute(); + + if (activeFlight.length > 0) { res.json({ isTracked: true, - flightId: activeFlight.rows[0].id, - username: activeFlight.rows[0].roblox_username, - shareToken: activeFlight.rows[0].share_token + flightId: activeFlight[0].id, + username: activeFlight[0].roblox_username, + shareToken: activeFlight[0].share_token }); } else { res.json({ isTracked: false }); @@ -109,15 +124,22 @@ router.get('/check-tracking/:callsign', async (req, res) => { router.use(requireAuth); // GET: /api/logbook/flights - Get user's flights with pagination and filters -router.get('/flights', async (req, res) => { +router.get('/flights', async (req: Request, res: Response) => { try { + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } const { page = 1, limit = 20, status = 'completed' } = req.query; + const pageNum = typeof page === 'string' ? parseInt(page) : Array.isArray(page) && typeof page[0] === 'string' ? parseInt(page[0]) : Number(page); + const limitNum = typeof limit === 'string' ? parseInt(limit) : Array.isArray(limit) && typeof limit[0] === 'string' ? parseInt(limit[0]) : Number(limit); + const statusStr = typeof status === 'string' ? status : Array.isArray(status) && typeof status[0] === 'string' ? status[0] : undefined; + const result = await getUserFlights( req.user.userId, - parseInt(page), - parseInt(limit), - status + pageNum, + limitNum, + statusStr ); res.json(result); @@ -128,9 +150,13 @@ router.get('/flights', async (req, res) => { }); // GET: /api/logbook/flights/:id - Get single flight details (with real-time data if active) -router.get('/flights/:id', async (req, res) => { +router.get('/flights/:id', async (req: Request, res: Response) => { try { - const flight = await getActiveFlightData(req.params.id); + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const flightId = parseInt(req.params.id); + const flight = await getActiveFlightData(flightId); if (!flight) { return res.status(404).json({ error: 'Flight not found' }); @@ -149,9 +175,13 @@ router.get('/flights/:id', async (req, res) => { }); // GET: /api/logbook/flights/:id/telemetry - Get flight telemetry for graphs -router.get('/flights/:id/telemetry', async (req, res) => { +router.get('/flights/:id/telemetry', async (req: Request, res: Response) => { try { - const flight = await getFlightById(req.params.id); + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const flightId = parseInt(req.params.id); + const flight = await getFlightById(flightId); if (!flight) { return res.status(404).json({ error: 'Flight not found' }); @@ -162,7 +192,7 @@ router.get('/flights/:id/telemetry', async (req, res) => { return res.status(403).json({ error: 'Access denied' }); } - const telemetry = await getFlightTelemetry(req.params.id); + const telemetry = await getFlightTelemetry(flightId); res.json(telemetry); } catch (error) { @@ -172,8 +202,11 @@ router.get('/flights/:id/telemetry', async (req, res) => { }); // GET: /api/logbook/stats - Get user statistics -router.get('/stats', async (req, res) => { +router.get('/stats', async (req: Request, res: Response) => { try { + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } const stats = await getUserStats(req.user.userId); res.json(stats); } catch (error) { @@ -183,8 +216,11 @@ router.get('/stats', async (req, res) => { }); // POST: /api/logbook/stats/refresh - Refresh user statistics cache -router.post('/stats/refresh', async (req, res) => { +router.post('/stats/refresh', async (req: Request, res: Response) => { try { + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } await updateUserStatsCache(req.user.userId); const stats = await getUserStats(req.user.userId); res.json({ success: true, stats }); @@ -195,8 +231,11 @@ router.post('/stats/refresh', async (req, res) => { }); // POST: /api/logbook/flights/start - Start tracking a flight -router.post('/flights/start', async (req, res) => { +router.post('/flights/start', async (req: Request, res: Response) => { try { + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } const { robloxUsername, callsign, departureIcao, arrivalIcao, route, aircraftIcao } = req.body; // Validate required fields @@ -231,7 +270,9 @@ router.post('/flights/start', async (req, res) => { }); // Start active tracking - await startActiveFlightTracking(robloxUsername, callsign, flightId); + if (typeof flightId === 'number') { + await startActiveFlightTracking(robloxUsername, callsign, flightId); + } res.json({ success: true, @@ -245,8 +286,11 @@ router.post('/flights/start', async (req, res) => { }); // DELETE: /api/logbook/flights/:id - Delete a flight (users can only delete pending flights, admins can delete any) -router.delete('/flights/:id', async (req, res) => { +router.delete('/flights/:id', async (req: Request, res: Response) => { try { + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } const flightId = parseInt(req.params.id); const userIsAdmin = isAdmin(req.user.userId); @@ -263,17 +307,19 @@ router.delete('/flights/:id', async (req, res) => { } }); -// ========== ADMIN DEBUG ENDPOINTS ========== - // GET: /api/logbook/debug/raw-stats - Get raw stats cache data router.get('/debug/raw-stats', requireAdmin, async (req, res) => { try { - const result = await pool.query(` - SELECT * FROM logbook_stats_cache - WHERE user_id = $1 - `, [req.user.userId]); + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const result = await mainDb + .selectFrom('logbook_stats_cache') + .selectAll() + .where('user_id', '=', req.user.userId) + .execute(); - res.json(result.rows[0] || null); + res.json(result[0] || null); } catch (error) { console.error('Error fetching raw stats:', error); res.status(500).json({ error: 'Failed to fetch raw stats' }); @@ -283,13 +329,17 @@ router.get('/debug/raw-stats', requireAdmin, async (req, res) => { // GET: /api/logbook/debug/raw-flights - Get all flights with full data router.get('/debug/raw-flights', requireAdmin, async (req, res) => { try { - const result = await pool.query(` - SELECT * FROM logbook_flights - WHERE user_id = $1 - ORDER BY created_at DESC - `, [req.user.userId]); + if (!req.user || !isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const result = await mainDb + .selectFrom('logbook_flights') + .selectAll() + .where('user_id', '=', req.user.userId) + .orderBy('created_at', 'desc') + .execute(); - res.json(result.rows); + res.json(result); } catch (error) { console.error('Error fetching raw flights:', error); res.status(500).json({ error: 'Failed to fetch raw flights' }); @@ -299,13 +349,21 @@ router.get('/debug/raw-flights', requireAdmin, async (req, res) => { // GET: /api/logbook/debug/active-tracking - Get active flight tracking data router.get('/debug/active-tracking', requireAdmin, async (req, res) => { try { - const result = await pool.query(` - SELECT af.*, f.callsign, f.departure_icao, f.arrival_icao, f.flight_status - FROM logbook_active_flights af - LEFT JOIN logbook_flights f ON af.flight_id = f.id - `); + const result = await mainDb + .selectFrom('logbook_active_flights as af') + .leftJoin('logbook_flights as f', 'af.flight_id', 'f.id') + .select([ + (eb) => eb.ref('af.flight_id').as('flight_id'), + (eb) => eb.ref('af.roblox_username').as('roblox_username'), + (eb) => eb.ref('af.callsign').as('callsign'), + (eb) => eb.ref('f.callsign').as('f_callsign'), + (eb) => eb.ref('f.departure_icao').as('departure_icao'), + (eb) => eb.ref('f.arrival_icao').as('arrival_icao'), + (eb) => eb.ref('f.flight_status').as('flight_status'), + ]) + .execute(); - res.json(result.rows); + res.json(result); } catch (error) { console.error('Error fetching active tracking:', error); res.status(500).json({ error: 'Failed to fetch active tracking' }); @@ -317,10 +375,10 @@ router.delete('/debug/clear-telemetry/:flightId', requireAdmin, async (req, res) try { const flightId = parseInt(req.params.flightId); - await pool.query(` - DELETE FROM logbook_telemetry - WHERE flight_id = $1 - `, [flightId]); + await mainDb + .deleteFrom('logbook_telemetry') + .where('flight_id', '=', flightId) + .execute(); res.json({ success: true, message: 'Telemetry cleared' }); } catch (error) { @@ -332,16 +390,18 @@ router.delete('/debug/clear-telemetry/:flightId', requireAdmin, async (req, res) // POST: /api/logbook/debug/reset-stats - Reset stats cache for user router.post('/debug/reset-stats', requireAdmin, async (req, res) => { try { - await pool.query(` - DELETE FROM logbook_stats_cache - WHERE user_id = $1 - `, [req.user.userId]); + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + await mainDb + .deleteFrom('logbook_stats_cache') + .where('user_id', '=', req.user.userId) + .execute(); - // Recreate and recalculate - await pool.query(` - INSERT INTO logbook_stats_cache (user_id) - VALUES ($1) - `, [req.user.userId]); + await mainDb + .insertInto('logbook_stats_cache') + .values({ user_id: req.user.userId }) + .execute(); await updateUserStatsCache(req.user.userId); @@ -360,17 +420,25 @@ router.post('/debug/recalculate-flight/:flightId', requireAdmin, async (req, res const flightId = parseInt(req.params.flightId); // Get telemetry count - const telemetryResult = await pool.query(` - SELECT COUNT(*) as count FROM logbook_telemetry - WHERE flight_id = $1 - `, [flightId]); + const telemetryResult = await mainDb + .selectFrom('logbook_telemetry') + .select(mainDb.fn.countAll().as('count')) + .where('flight_id', '=', flightId) + .execute(); const flight = await getFlightById(flightId); + // Defensive: handle string | number | bigint + let telemetryPoints = 0; + const countVal = telemetryResult[0]?.count; + if (typeof countVal === 'string') telemetryPoints = parseInt(countVal); + else if (typeof countVal === 'number') telemetryPoints = countVal; + else if (typeof countVal === 'bigint') telemetryPoints = Number(countVal); + res.json({ success: true, flight, - telemetryPoints: parseInt(telemetryResult.rows[0].count) + telemetryPoints }); } catch (error) { console.error('Error recalculating flight:', error); @@ -378,46 +446,29 @@ router.post('/debug/recalculate-flight/:flightId', requireAdmin, async (req, res } }); -// GET: /api/logbook/debug/database-info - Get database table info -router.get('/debug/database-info', requireAdmin, async (req, res) => { - try { - const tables = await pool.query(` - SELECT - schemaname, - tablename, - pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size, - pg_stat_get_live_tuples(c.oid) AS rows - FROM pg_tables t - JOIN pg_class c ON t.tablename = c.relname - WHERE schemaname = 'public' - AND tablename LIKE 'logbook%' - ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC - `); - - res.json(tables.rows); - } catch (error) { - console.error('Error fetching database info:', error); - res.status(500).json({ error: 'Failed to fetch database info' }); - } -}); - // POST: /api/logbook/debug/export-data - Export all logbook data as JSON router.post('/debug/export-data', requireAdmin, async (req, res) => { try { - const flights = await pool.query(` - SELECT * FROM logbook_flights - WHERE user_id = $1 - ORDER BY created_at DESC - `, [req.user.userId]); - - const stats = await pool.query(` - SELECT * FROM logbook_stats_cache - WHERE user_id = $1 - `, [req.user.userId]); + if (!req.user || !isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const flights = await mainDb + .selectFrom('logbook_flights') + .selectAll() + .where('user_id', '=', req.user.userId) + .orderBy('created_at', 'desc') + .execute(); + + const stats = await mainDb + .selectFrom('logbook_stats_cache') + .selectAll() + .where('user_id', '=', req.user.userId) + .execute(); res.json({ - flights: flights.rows, - stats: stats.rows[0], + flights, + stats: stats[0], exportedAt: new Date().toISOString() }); } catch (error) { @@ -426,8 +477,11 @@ router.post('/debug/export-data', requireAdmin, async (req, res) => { } }); -router.get('/notifications', async (req, res) => { +router.get('/notifications', async (req: Request, res: Response) => { try { + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } const { unreadOnly } = req.query; const notifications = await getUserNotifications(req.user.userId, unreadOnly === 'true'); res.json(notifications); @@ -437,8 +491,11 @@ router.get('/notifications', async (req, res) => { } }); -router.post('/notifications/:id/read', async (req, res) => { +router.post('/notifications/:id/read', async (req: Request, res: Response) => { try { + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } await markNotificationAsRead(parseInt(req.params.id), req.user.userId); res.json({ success: true }); } catch (error) { @@ -447,8 +504,11 @@ router.post('/notifications/:id/read', async (req, res) => { } }); -router.post('/notifications/read-all', async (req, res) => { +router.post('/notifications/read-all', async (req: Request, res: Response) => { try { + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } await markAllNotificationsAsRead(req.user.userId); res.json({ success: true }); } catch (error) { @@ -457,8 +517,11 @@ router.post('/notifications/read-all', async (req, res) => { } }); -router.delete('/notifications/:id', async (req, res) => { +router.delete('/notifications/:id', async (req: Request, res: Response) => { try { + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } await deleteNotification(parseInt(req.params.id), req.user.userId); res.json({ success: true }); } catch (error) { @@ -468,31 +531,34 @@ router.delete('/notifications/:id', async (req, res) => { }); // POST: /api/logbook/flights/:id/complete - Manually complete an active flight -router.post('/flights/:id/complete', async (req, res) => { +router.post('/flights/:id/complete', async (req: Request, res: Response) => { try { + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } const flightId = parseInt(req.params.id); // Verify flight belongs to user and is active - const flight = await pool.query(` - SELECT callsign, user_id, flight_status - FROM logbook_flights - WHERE id = $1 - `, [flightId]); + const flight = await mainDb + .selectFrom('logbook_flights') + .select(['callsign', 'user_id', 'flight_status']) + .where('id', '=', flightId) + .execute(); - if (!flight.rows[0]) { + if (!flight[0]) { return res.status(404).json({ error: 'Flight not found' }); } - if (flight.rows[0].user_id !== req.user.userId) { + if (flight[0].user_id !== req.user.userId) { return res.status(403).json({ error: 'Not authorized to complete this flight' }); } - if (flight.rows[0].flight_status !== 'active') { + if (flight[0].flight_status !== 'active') { return res.status(400).json({ error: 'Flight is not active' }); } // Complete the flight - await completeFlightByCallsign(flight.rows[0].callsign); + await completeFlightByCallsign(flight[0].callsign); res.json({ success: true, message: 'Flight completed successfully' }); } catch (error) { @@ -502,8 +568,11 @@ router.post('/flights/:id/complete', async (req, res) => { }); // POST: /api/logbook/flights/:id/share - Generate or retrieve share token -router.post('/flights/:id/share', async (req, res) => { +router.post('/flights/:id/share', async (req: Request, res: Response) => { try { + if (!isJwtPayloadClient(req.user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } const flightId = parseInt(req.params.id); const shareToken = await generateShareToken(flightId, req.user.userId); @@ -514,14 +583,17 @@ router.post('/flights/:id/share', async (req, res) => { shareToken, shareUrl }); - } catch (error) { + } catch (error: unknown) { console.error('Error generating share token:', error); - if (error.message === 'Flight not found') { - return res.status(404).json({ error: 'Flight not found' }); - } - if (error.message === 'Not authorized') { - return res.status(403).json({ error: 'Not authorized to share this flight' }); + if (typeof error === 'object' && error !== null && 'message' in error) { + const message = (error as { message?: string }).message; + if (message === 'Flight not found') { + return res.status(404).json({ error: 'Flight not found' }); + } + if (message === 'Not authorized') { + return res.status(403).json({ error: 'Not authorized to share this flight' }); + } } res.status(500).json({ error: 'Failed to generate share link' }); diff --git a/server/routes/metar.js b/server/routes/metar.ts similarity index 97% rename from server/routes/metar.js rename to server/routes/metar.ts index 6862ce4..a53acdc 100644 --- a/server/routes/metar.js +++ b/server/routes/metar.ts @@ -21,7 +21,7 @@ router.get('/:icao', async (req, res) => { let data; try { data = JSON.parse(text); - } catch (jsonError) { + } catch { return res.status(500).json({ error: 'Invalid METAR data format' }); } diff --git a/server/routes/sessions.js b/server/routes/sessions.ts similarity index 67% rename from server/routes/sessions.js rename to server/routes/sessions.ts index 6dae33f..a8fbc9e 100644 --- a/server/routes/sessions.js +++ b/server/routes/sessions.ts @@ -1,33 +1,46 @@ import express from 'express'; import { - initializeSessionsTable, createSession, getSessionById, updateSession, deleteSession, getAllSessions, - decrypt, updateSessionName, - getSessionsByUserDetailed } from '../db/sessions.js'; import { addSessionToUser } from '../db/users.js'; -import { generateSessionId, generateAccessId } from '../tools/ids.js'; +import { generateSessionId, generateAccessId } from '../utils/ids.js'; import { recordNewSession } from '../db/statistics.js'; import { requireSessionAccess, requireSessionOwnership } from '../middleware/sessionAccess.js'; import { getSessionsByUser } from '../db/sessions.js'; -import requireAuth from '../middleware/isAuthenticated.js'; +import requireAuth from '../middleware/auth.js'; import { sessionCreationLimiter } from '../middleware/rateLimiting.js'; import { sanitizeAlphanumeric } from '../utils/sanitization.js'; +import { Request, Response } from 'express'; +import { JwtPayloadClient } from '../types/JwtPayload.js'; + +function isJwtPayloadClient(user: unknown): user is JwtPayloadClient { + return ( + typeof user === 'object' && + user !== null && + 'userId' in user && + typeof (user as Record).userId === 'string' + ); +} + const router = express.Router(); -initializeSessionsTable(); // POST: /api/sessions/create - Create new session -router.post('/create', sessionCreationLimiter, requireAuth, async (req, res) => { +router.post('/create', sessionCreationLimiter, requireAuth, async (req: Request, res: Response) => { try { - const { airportIcao, createdBy, isPFATC = false, activeRunway = null } = req.body; - if (!airportIcao || !createdBy) { - return res.status(400).json({ error: 'Airport ICAO and creator ID are required' }); + const user = req.user; + if (!isJwtPayloadClient(user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const createdBy = user.userId; + const { airportIcao, isPFATC = false, activeRunway = null } = req.body; + if (!airportIcao) { + return res.status(400).json({ error: 'Airport ICAO is required' }); } const userSessions = await getSessionsByUser(createdBy); @@ -40,12 +53,14 @@ router.post('/create', sessionCreationLimiter, requireAuth, async (req, res) => }); } + const sessionId = generateSessionId(); const accessId = generateAccessId(); const existing = await getSessionById(sessionId); if (existing) { - return router.post('/create')(req, res); + // If collision, try again recursively (should be extremely rare) + return res.status(500).json({ error: 'Session ID collision, please try again.' }); } await createSession({ sessionId, accessId, activeRunway, airportIcao, createdBy, isPFATC }); @@ -69,12 +84,25 @@ router.post('/create', sessionCreationLimiter, requireAuth, async (req, res) => } }); -// GET: /api/sessions/mine - Get user's sessions +// GET: /api/sessions/mine - Get sessions for the authenticated user router.get('/mine', requireAuth, async (req, res) => { try { - const userId = req.user.userId; - const sessions = await getSessionsByUserDetailed(userId); - res.json(sessions); + const userId = req.user?.userId; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const sessions = await getSessionsByUser(userId); + res.json(sessions.map(session => ({ + sessionId: session.session_id, + accessId: session.access_id, + airportIcao: session.airport_icao, + createdAt: session.created_at, + createdBy: session.created_by, + isPFATC: session.is_pfatc, + activeRunway: session.active_runway, + customName: session.custom_name, + flightCount: 0 + }))); } catch (error) { console.error('Error fetching user sessions:', error); res.status(500).json({ error: 'Internal server error', message: 'Failed to fetch user sessions' }); @@ -110,7 +138,15 @@ router.get('/:sessionId', requireSessionAccess, async (req, res) => { if (!session) { return res.status(404).json({ error: 'Session not found' }); } - const atis = decrypt(JSON.parse(session.atis)); + let atis = { letter: 'A', text: '', timestamp: new Date().toISOString() }; + if (session.atis) { + try { + atis = JSON.parse(session.atis); + } catch (e) { + console.log('parse error:', e); + // fallback to default atis + } + } res.json({ sessionId: session.session_id, accessId: session.access_id, @@ -119,7 +155,7 @@ router.get('/:sessionId', requireSessionAccess, async (req, res) => { createdAt: session.created_at, createdBy: session.created_by, isPFATC: session.is_pfatc, - atis: atis || { letter: 'A', text: '', timestamp: new Date().toISOString() } + atis }); } catch (error) { console.error('Error fetching session:', error); @@ -132,11 +168,19 @@ router.put('/:sessionId', requireSessionAccess, async (req, res) => { try { const { sessionId } = req.params; const { activeRunway, atis } = req.body; - const session = await updateSession(sessionId, { activeRunway, atis }); + // updateSession expects snake_case keys + const session = await updateSession(sessionId, { active_runway: activeRunway, atis }); if (!session) { return res.status(404).json({ error: 'Session not found' }); } - const decryptedAtis = decrypt(JSON.parse(session.atis)); + let decryptedAtis = { letter: 'A', text: '', timestamp: new Date().toISOString() }; + if (session.atis) { + try { + decryptedAtis = JSON.parse(session.atis); + } catch { + // fallback to default atis + } + } res.json({ sessionId: session.session_id, accessId: session.access_id, @@ -145,7 +189,7 @@ router.put('/:sessionId', requireSessionAccess, async (req, res) => { createdAt: session.created_at, createdBy: session.created_by, isPFATC: session.is_pfatc, - atis: decryptedAtis || { letter: 'A', text: '', timestamp: new Date().toISOString() } + atis: decryptedAtis }); } catch (error) { console.error('Error updating session:', error); @@ -173,8 +217,12 @@ router.post('/update-name', requireAuth, requireSessionOwnership, async (req, re }); // POST: /api/sessions/delete - Delete session (POST for compatibility) -router.post('/delete', requireAuth, async (req, res) => { +router.post('/delete', requireAuth, async (req: Request, res: Response) => { try { + const user = req.user; + if (!isJwtPayloadClient(user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } const { sessionId } = req.body; if (!sessionId) { return res.status(400).json({ error: 'Session ID required' }); @@ -185,14 +233,11 @@ router.post('/delete', requireAuth, async (req, res) => { return res.status(404).json({ error: 'Session not found' }); } - if (session.created_by !== req.user.userId) { + if (session.created_by !== user.userId) { return res.status(403).json({ error: 'You can only delete your own sessions' }); } - const deleted = await deleteSession(sessionId); - if (!deleted) { - return res.status(404).json({ error: 'Session not found' }); - } + await deleteSession(sessionId); res.json({ message: 'Session deleted successfully', sessionId }); } catch (error) { console.error('Error deleting session:', error); @@ -201,24 +246,33 @@ router.post('/delete', requireAuth, async (req, res) => { }); // POST: /api/sessions/delete-oldest - Delete user's oldest session -router.post('/delete-oldest', requireAuth, async (req, res) => { +router.post('/delete-oldest', requireAuth, async (req: Request, res: Response) => { try { - const userId = req.user.userId; + const user = req.user; + if (!isJwtPayloadClient(user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const userId = user.userId; const userSessions = await getSessionsByUser(userId); if (userSessions.length === 0) { return res.status(404).json({ error: 'No sessions found' }); } - const oldestSession = userSessions.sort((a, b) => - new Date(a.created_at) - new Date(b.created_at) - )[0]; + const oldestSession = userSessions + .filter(s => s.created_at) + .sort((a, b) => { + const aTime = a.created_at ? new Date(a.created_at).getTime() : 0; + const bTime = b.created_at ? new Date(b.created_at).getTime() : 0; + return aTime - bTime; + })[0]; - const deleted = await deleteSession(oldestSession.session_id); - if (!deleted) { - return res.status(404).json({ error: 'Failed to delete oldest session' }); + if (!oldestSession) { + return res.status(404).json({ error: 'No sessions with valid created_at found' }); } + await deleteSession(oldestSession.session_id); + res.json({ message: 'Oldest session deleted successfully', sessionId: oldestSession.session_id, @@ -232,7 +286,7 @@ router.post('/delete-oldest', requireAuth, async (req, res) => { }); // GET: /api/sessions/ - Get all sessions -router.get('/', async (req, res) => { +router.get('/', async (_req, res) => { try { const sessions = await getAllSessions(); res.json(sessions.map(session => ({ diff --git a/server/routes/uploads.js b/server/routes/uploads.ts similarity index 65% rename from server/routes/uploads.js rename to server/routes/uploads.ts index 3ec799e..c9a6cb7 100644 --- a/server/routes/uploads.js +++ b/server/routes/uploads.ts @@ -1,7 +1,7 @@ import { getUserById, updateUserSettings } from '../db/users.js'; import express from 'express'; import multer from 'multer'; -import requireAuth from '../middleware/isAuthenticated.js'; +import requireAuth from '../middleware/auth.js'; import FormData from 'form-data'; import axios from 'axios'; @@ -12,33 +12,53 @@ const CEPHIE_API_KEY = process.env.CEPHIE_API_KEY; const CEPHIE_UPLOAD_URL = 'https://api.cephie.app/api/v1/pfcontrol/upload'; const CEPHIE_DELETE_URL = 'https://api.cephie.app/api/v1/pfcontrol/delete'; -async function deleteOldImage(url) { +async function deleteOldImage(url: string | undefined) { if (!url) return; try { - const response = await fetch(CEPHIE_DELETE_URL, { - method: 'DELETE', + const response = await axios.delete(CEPHIE_DELETE_URL, { headers: { 'Content-Type': 'application/json', 'cephie-pfcontrol-key': CEPHIE_API_KEY, 'cephie-api-key': CEPHIE_API_KEY }, - body: JSON.stringify({ url }), + data: { url }, }); - if (!response.ok) { + if (response.status !== 200) { console.error('Failed to delete old image:', response.status, response.statusText); console.error('Response headers:', response.headers); - console.error('Response body:', await response.text()); + console.error('Response body:', response.data); console.error('Request URL:', CEPHIE_DELETE_URL); } } catch (error) { - console.error('Error deleting old image:', error); + if (axios.isAxiosError(error)) { + console.error('Error deleting old image:', error.response?.data || error.message); + } else { + console.error('Error deleting old image:', error); + } } } // POST: /api/uploads/upload-background - Upload a new background image -router.post('/upload-background', requireAuth, upload.single('image'), async (req, res) => { + +import { Request, Response } from 'express'; +import { JwtPayloadClient } from '../types/JwtPayload'; + +function isJwtPayloadClient(user: unknown): user is JwtPayloadClient { + return ( + typeof user === 'object' && + user !== null && + 'userId' in user && + typeof (user as Record).userId === 'string' + ); +} + +router.post('/upload-background', requireAuth, upload.single('image'), async (req: Request, res: Response) => { try { - const userId = req.user.userId; + const user = req.user; + if (!isJwtPayloadClient(user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const userId = user.userId; const file = req.file; if (!file || !file.mimetype.startsWith('image/')) { @@ -46,12 +66,12 @@ router.post('/upload-background', requireAuth, upload.single('image'), async (re return res.status(400).json({ error: 'Invalid or missing image file' }); } - const user = await getUserById(userId); - if (!user) { + const dbUser = await getUserById(userId); + if (!dbUser) { return res.status(404).json({ error: 'User not found' }); } - const currentSettings = user.settings || {}; + const currentSettings = dbUser.settings || {}; const currentImageUrl = currentSettings.backgroundImage?.selectedImage; if (currentImageUrl) { @@ -92,22 +112,31 @@ router.post('/upload-background', requireAuth, upload.single('image'), async (re res.json({ message: 'Background image uploaded successfully', url: newImageUrl }); } catch (error) { - console.error('Error uploading background image:', error?.response?.data || error); - res.status(500).json({ error: 'Failed to upload background image', details: error?.response?.data }); + if (axios.isAxiosError(error)) { + console.error('Error uploading background image:', error.response?.data || error.message); + res.status(500).json({ error: 'Failed to upload background image', details: error.response?.data }); + } else { + console.error('Error uploading background image:', error); + res.status(500).json({ error: 'Failed to upload background image' }); + } } }); // DELETE: /api/uploads/delete-background - Delete the current background image -router.delete('/delete-background', requireAuth, async (req, res) => { +router.delete('/delete-background', requireAuth, async (req: Request, res: Response) => { try { - const userId = req.user.userId; + const user = req.user; + if (!isJwtPayloadClient(user)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const userId = user.userId; - const user = await getUserById(userId); - if (!user) { + const dbUser = await getUserById(userId); + if (!dbUser) { return res.status(404).json({ error: 'User not found' }); } - const currentSettings = user.settings || {}; + const currentSettings = dbUser.settings || {}; const currentImageUrl = currentSettings.backgroundImage?.selectedImage; if (!currentImageUrl) { @@ -134,7 +163,7 @@ router.delete('/delete-background', requireAuth, async (req, res) => { }); // GET: /api/uploads/background-url/:filename - Get full URL for a background image -router.get('/background-url/:filename', requireAuth, async (req, res) => { +router.get('/background-url/:filename', requireAuth, async (req: express.Request, res: express.Response) => { try { const { filename } = req.params; diff --git a/server/server.js b/server/server.js deleted file mode 100644 index 33d2647..0000000 --- a/server/server.js +++ /dev/null @@ -1,119 +0,0 @@ -import path from 'path'; -import { fileURLToPath } from 'url'; -import express 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 flightTracker from './services/flightTracker.js'; -import chalk from 'chalk'; - -import { setupChatWebsocket } from './websockets/chatWebsocket.js'; -import { setupSessionUsersWebsocket } from './websockets/sessionUsersWebsocket.js'; -import { setupFlightsWebsocket } from './websockets/flightsWebsocket.js'; -import { setupOverviewWebsocket } from './websockets/overviewWebsocket.js'; -import { setupArrivalsWebsocket } from './websockets/arrivalsWebsocket.js'; -import './db/userNotifications.js'; - -const envFile = - process.env.NODE_ENV === 'production' - ? '.env.production' - : '.env.development'; -const cors_origin = - process.env.NODE_ENV === 'production' - ? ['https://control.pfconnect.online', 'https://test.pfconnect.online'] - : [ - 'http://localhost:9901', - 'http://localhost:5173', - 'https://control.pfconnect.online', - ]; -dotenv.config({ path: envFile }); -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', - 'PORT', -]; -const missingEnv = requiredEnv.filter( - (key) => !process.env[key] || process.env[key] === '' -); -if (missingEnv.length > 0) { - console.error( - 'Missing required environment variables:', - missingEnv.join(', ') - ); - process.exit(1); -} - -const PORT = - process.env.PORT || (process.env.NODE_ENV === 'production' ? 9900 : 9901); -if (!PORT || PORT === '' || PORT == undefined) { - console.error('PORT is not defined'); - process.exit(1); -} - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const app = express(); -app.set('trust proxy', 1); -app.use( - cors({ - origin: cors_origin, - credentials: true, - }) -); -app.use(cookieParser()); -app.use(express.json()); - -// Health check endpoint -app.get('/health', (req, res) => { - res.json({ - status: 'ok', - environment: process.env.NODE_ENV, - timestamp: new Date().toISOString(), - websockets: { - overview: '/sockets/overview', - flights: '/sockets/flights', - sessionUsers: '/sockets/session-users', - chat: '/sockets/chat', - arrivals: '/sockets/arrivals' - } - }); -}); - -app.use('/api', apiRoutes); - -app.use(express.static(path.join(__dirname, '../public'))); - -app.use( - express.static(path.join(__dirname, '..', 'dist'), { - setHeaders: (res, path) => { - if (path.endsWith('.js')) { - res.setHeader('Content-Type', 'application/javascript'); - } - }, - }) -); - -app.get('/{*any}', (req, res) => { - res.sendFile(path.join(__dirname, '..', 'dist', 'index.html')); -}); - -const server = http.createServer(app); -const sessionUsersIO = setupSessionUsersWebsocket(server); -setupChatWebsocket(server, sessionUsersIO); -setupFlightsWebsocket(server); -setupOverviewWebsocket(server, sessionUsersIO); -setupArrivalsWebsocket(server); - -server.listen(PORT, () => { - console.log(chalk.green(`Server running on http://localhost:${PORT}`)); - flightTracker.initialize(); -}); diff --git a/server/services/flightTracker.js b/server/services/flightTracker.ts similarity index 50% rename from server/services/flightTracker.js rename to server/services/flightTracker.ts index e17d02a..86b5b2a 100644 --- a/server/services/flightTracker.js +++ b/server/services/flightTracker.ts @@ -2,6 +2,7 @@ import WebSocket from 'ws'; import protobuf from 'protobufjs'; import { HttpsProxyAgent } from 'https-proxy-agent'; import chalk from 'chalk'; +import { sql } from 'kysely'; import { getActiveFlightByUsername, storeTelemetryPoint, @@ -12,7 +13,7 @@ import { removeActiveFlightTracking, updateUserStatsCache } from '../db/logbook.js'; -import pool from '../db/connections/connection.js'; +import { mainDb } from '../db/connection.js'; import { startLandingDataCollection, stopLandingDataCollection } from './landingDataFetcher.js'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; @@ -22,7 +23,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const debug = (message, type = 'log') => { +const debug = (message: string, type = 'log') => { if (process.env.DEBUG === 'true') { const coloredMessage = message.replace(/\[Flight Tracker\]/g, chalk.bgYellow('[Flight Tracker]')); if (type === 'error') console.error(coloredMessage); @@ -32,8 +33,9 @@ const debug = (message, type = 'log') => { }; const airportData = JSON.parse(readFileSync(join(__dirname, '../data/airportData.json'), 'utf-8')); -const airportElevations = {}; -airportData.forEach(airport => { +type Airport = { icao: string; elevation?: number }; +const airportElevations: { [icao: string]: number } = {}; +(airportData as Airport[]).forEach((airport: Airport) => { airportElevations[airport.icao] = airport.elevation || 0; }); @@ -47,7 +49,7 @@ const PHASE_THRESHOLDS = { LANDING_SPEED: 100, // Below 100kts on ground = landed }; -function getGroundLevel(arrivalIcao) { +function getGroundLevel(arrivalIcao: string) { const elevation = airportElevations[arrivalIcao] || 0; return { min: elevation - PHASE_THRESHOLDS.GROUND_ALT_BUFFER, @@ -56,7 +58,7 @@ function getGroundLevel(arrivalIcao) { }; } -function isAtGroundLevel(altitude, arrivalIcao) { +function isAtGroundLevel(altitude: number, arrivalIcao: string) { const ground = getGroundLevel(arrivalIcao); return altitude >= ground.min && altitude <= ground.max; } @@ -70,7 +72,44 @@ const STATE_THRESHOLDS = { TRACKING_TIMEOUT: 600, // 10 minutes - complete if no telemetry after landing }; +interface Plane { + server_id: string; + callsign: string; + roblox_username: string; + x: number; + y: number; + heading: number; + altitude: number; + speed: number; + model: string; + livery: string; +} + class FlightTracker { + private socket: WebSocket | null; + private reconnectInterval: number; + private protobufRoot: protobuf.Root | null; + private planesType: protobuf.Type | null; + private isConnected: boolean; + private flightData: Map; + private lastTelemetryTime: Map; + private reconnectAttempts: number; + private maxReconnectAttempts: number; + private connectionFailed: boolean; + private lastPlaneCountLog: number; + private flightNotFoundTimeout: number; + private proxies: string[]; + private currentProxyIndex: number; + constructor() { this.socket = null; this.reconnectInterval = 5000; @@ -91,8 +130,8 @@ class FlightTracker { this.startFlightMonitoring(); } - loadProxies() { - const proxies = []; + loadProxies(): string[] { + const proxies: (string | undefined)[] = []; if (process.env.PROXY_URL) { proxies.push(...process.env.PROXY_URL.split(',').map(p => p.trim())); @@ -104,7 +143,7 @@ class FlightTracker { i++; } - return proxies; + return proxies.filter((p): p is string => typeof p === 'string' && p.length > 0); } getNextProxy() { @@ -151,38 +190,39 @@ class FlightTracker { async checkTimeouts() { try { - const cancelResult = await pool.query(` - UPDATE logbook_flights - SET flight_status = 'cancelled' - WHERE flight_status = 'pending' - AND created_at < NOW() - INTERVAL '${STATE_THRESHOLDS.PENDING_TIMEOUT} seconds' - RETURNING id, callsign - `); - - if (cancelResult.rows.length > 0) { - for (const flight of cancelResult.rows) { + const cancelResult = await mainDb + .updateTable('logbook_flights') + .set({ flight_status: 'cancelled' }) + .where( + sql`flight_status = 'pending' AND created_at < NOW() - INTERVAL '${STATE_THRESHOLDS.PENDING_TIMEOUT} seconds'`, + '=', + true + ) + .returning(['id', 'callsign']) + .execute(); + + if (cancelResult.length > 0) { + for (const flight of cancelResult) { debug(`[Flight Tracker] Flight ${flight.callsign} cancelled - never departed`); } } - const abortResult = await pool.query(` - UPDATE logbook_flights f - SET flight_status = 'aborted' - FROM logbook_active_flights af - WHERE f.id = af.flight_id - AND f.flight_status = 'active' - AND af.landing_detected = true - AND af.last_update < NOW() - INTERVAL '${STATE_THRESHOLDS.TRACKING_TIMEOUT} seconds' - RETURNING f.id, f.callsign - `); - - if (abortResult.rows.length > 0) { - for (const flight of abortResult.rows) { + const abortResult = await mainDb + .updateTable('logbook_flights') + .set({ flight_status: 'aborted' }) + .from('logbook_active_flights as af') + .where( + sql`logbook_flights.id = af.flight_id AND logbook_flights.flight_status = 'active' AND af.landing_detected = true AND af.last_update < NOW() - INTERVAL '${STATE_THRESHOLDS.TRACKING_TIMEOUT} seconds'`, + '=', + true + ) + .returning(['logbook_flights.id', 'logbook_flights.callsign']) + .execute(); + + if (abortResult.length > 0) { + for (const flight of abortResult) { debug(`[Flight Tracker] Flight ${flight.callsign} aborted - tracking lost after landing`); - await pool.query(` - DELETE FROM logbook_active_flights - WHERE flight_id = $1 - `, [flight.id]); + await mainDb.deleteFrom('logbook_active_flights').where('flight_id', '=', flight.id).execute(); } } @@ -197,7 +237,7 @@ class FlightTracker { return; } - const wsOptions = { + const wsOptions: WebSocket.ClientOptions & { headers: { [key: string]: string } } = { 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', 'Origin': 'https://project-flight.com' @@ -220,7 +260,13 @@ class FlightTracker { }); this.socket.on('message', async (data) => { - await this.handleMessage(data); + let processedData: Buffer | ArrayBuffer | { arrayBuffer: () => Promise }; + if (Array.isArray(data)) { + processedData = Buffer.concat(data); + } else { + processedData = data; + } + await this.handleMessage(processedData); }); this.socket.on('close', () => { @@ -247,13 +293,38 @@ class FlightTracker { }); } - async handleMessage(data) { + async handleMessage(data: Buffer | ArrayBuffer | { arrayBuffer: () => Promise }) { try { - const buffer = data instanceof Buffer ? new Uint8Array(data) : new Uint8Array(await data.arrayBuffer()); + let buffer: Uint8Array; + if (data instanceof Buffer) { + buffer = new Uint8Array(data); + } else if (data instanceof ArrayBuffer) { + buffer = new Uint8Array(data); + } else if (typeof data === 'object' && typeof (data as { arrayBuffer: () => Promise }).arrayBuffer === 'function') { + buffer = new Uint8Array(await (data as { arrayBuffer: () => Promise }).arrayBuffer()); + } else { + throw new Error('Unsupported data type received in handleMessage'); + } + if (!this.planesType) { + debug(`[Flight Tracker] planesType is not initialized`, 'error'); + return; + } const decoded = this.planesType.decode(buffer); - const object = this.planesType.toObject(decoded, { defaults: true }); - - const pfatcPlanes = object.planes.filter(plane => plane.server_id === PFATC_SERVER_ID); + const object = this.planesType.toObject(decoded, { defaults: true }) as { planes: Plane[] }; + + const pfatcPlanes = (object.planes as Plane[]).filter( + (plane) => + plane.server_id === PFATC_SERVER_ID && + typeof plane.callsign === 'string' && + typeof plane.roblox_username === 'string' && + typeof plane.x === 'number' && + typeof plane.y === 'number' && + typeof plane.heading === 'number' && + typeof plane.altitude === 'number' && + typeof plane.speed === 'number' && + typeof plane.model === 'string' && + typeof plane.livery === 'string' + ); const now = Date.now(); if (!this.lastPlaneCountLog || (now - this.lastPlaneCountLog) >= 30000) { @@ -262,14 +333,14 @@ class FlightTracker { } for (const plane of pfatcPlanes) { - await this.processPlane(plane); + await this.processPlane(plane as Plane); } } catch (err) { debug(`[Flight Tracker] Failed to decode protobuf message: ${err}`, 'error'); } } - - async processPlane(plane) { + + async processPlane(plane: Plane) { try { const activeFlight = await getActiveFlightByUsername(plane.roblox_username); if (!activeFlight) return; @@ -291,27 +362,55 @@ class FlightTracker { const previousData = this.flightData.get(plane.roblox_username); - const flightResult = await pool.query(` - SELECT controller_status, arrival_icao, flight_status FROM logbook_flights WHERE id = $1 - `, [activeFlight.flight_id]); - let controllerStatus = flightResult.rows[0]?.controller_status; - const arrivalIcao = flightResult.rows[0]?.arrival_icao; - const flightStatus = flightResult.rows[0]?.flight_status; + if (typeof activeFlight.flight_id === 'undefined') { + debug(`[Flight Tracker] activeFlight.flight_id is undefined`, 'error'); + return; + } + const flightResult = await mainDb + .selectFrom('logbook_flights') + .select(['controller_status', 'arrival_icao', 'flight_status']) + .where('id', '=', activeFlight.flight_id) + .executeTakeFirst(); + + if (!flightResult) { + debug(`[Flight Tracker] Flight not found for id ${activeFlight.flight_id}`, 'error'); + return; + } + + let controllerStatus = flightResult.controller_status; + const arrivalIcao = flightResult.arrival_icao; + const flightStatus = flightResult.flight_status; if (controllerStatus?.toLowerCase() === 'depa' && currentData.altitude > 1000 && previousData) { const vs = this.calculateVerticalSpeed(currentData, previousData); if (Math.abs(vs) < 300) { - await pool.query(` - UPDATE logbook_flights SET controller_status = NULL WHERE id = $1 - `, [activeFlight.flight_id]); - controllerStatus = null; + await mainDb + .updateTable('logbook_flights') + .set({ controller_status: undefined }) + .where('id', '=', activeFlight.flight_id) + .execute(); + controllerStatus = undefined; } } - await this.detectFlightState(activeFlight, currentData, previousData, controllerStatus); + if (typeof activeFlight.callsign === 'undefined') { + debug(`[Flight Tracker] activeFlight.callsign is undefined`, 'error'); + return; + } + await this.detectFlightState( + { ...activeFlight, callsign: activeFlight.callsign as string }, + currentData, + controllerStatus + ); const phase = previousData - ? this.detectHybridPhase(currentData, previousData, controllerStatus, arrivalIcao, flightStatus) + ? this.detectHybridPhase( + currentData, + previousData, + controllerStatus, + arrivalIcao ?? null, + flightStatus + ) : 'unknown'; const lastTelemetry = this.lastTelemetryTime.get(plane.roblox_username); @@ -334,12 +433,12 @@ class FlightTracker { verticalSpeed: verticalSpeed }); - if (plane.model && !activeFlight.aircraft_model) { - await pool.query(` - UPDATE logbook_flights - SET aircraft_model = $1, livery = $2 - WHERE id = $3 - `, [plane.model, plane.livery, activeFlight.flight_id]); + if (plane.model) { + await mainDb + .updateTable('logbook_flights') + .set({ aircraft_model: plane.model, livery: plane.livery }) + .where('id', '=', activeFlight.flight_id) + .execute(); } this.lastTelemetryTime.set(plane.roblox_username, currentData.timestamp.getTime()); @@ -362,16 +461,17 @@ class FlightTracker { ); } - if (await this.detectLanding(currentData, previousData, activeFlight, arrivalIcao)) { + if (await this.detectLanding(currentData, previousData, arrivalIcao)) { if (!activeFlight.landing_detected) { debug(`[Flight Tracker] Landing detected: ${activeFlight.callsign}`); - await pool.query(` - UPDATE logbook_active_flights - SET landing_detected = true, - landing_time = NOW() - WHERE roblox_username = $1 - `, [plane.roblox_username]); + await mainDb + .updateTable('logbook_active_flights') + .set({ + landing_detected: true + }) + .where('roblox_username', '=', plane.roblox_username) + .execute(); const proxyUrl = this.proxies.length > 0 ? this.proxies[this.currentProxyIndex % this.proxies.length] : null; await startLandingDataCollection(plane.roblox_username, proxyUrl); @@ -385,14 +485,28 @@ class FlightTracker { } } - detectHybridPhase(current, previous, controllerStatus, arrivalIcao = null, flightStatus = null) { + detectHybridPhase( + current: { + altitude: number; + speed: number; + timestamp: Date; + }, + previous: { + altitude: number; + speed: number; + timestamp: Date; + } | undefined, + controllerStatus: string | undefined, + arrivalIcao: string | null = null, + flightStatus: string | null = null + ): string { const alt = current.altitude; const speed = current.speed; let vs = 0; if (previous && previous.timestamp) { const altChange = current.altitude - previous.altitude; - const timeChange = (current.timestamp - previous.timestamp) / 1000; + const timeChange = (current.timestamp.getTime() - previous.timestamp.getTime()) / 1000; if (timeChange > 0) { vs = Math.round((altChange / timeChange) * 60); } @@ -401,7 +515,7 @@ class FlightTracker { const status = controllerStatus?.toLowerCase(); // === PENDING FLIGHT - AWAITING CLEARANCE === - if (flightStatus === 'pending' && isAtGroundLevel(alt, arrivalIcao) && speed <= 12) { + if (flightStatus === 'pending' && isAtGroundLevel(alt, arrivalIcao ?? '') && speed <= 12) { return 'awaiting_clearance'; } @@ -417,7 +531,7 @@ class FlightTracker { if (status === 'destination_taxi') { return 'destination_taxi'; } - if (status === 'taxi' || (isAtGroundLevel(alt, arrivalIcao) && speed > 12)) { + if (status === 'taxi' || (isAtGroundLevel(alt, arrivalIcao ?? '') && speed > 12)) { return 'taxi'; } @@ -447,7 +561,7 @@ class FlightTracker { // === TELEMETRY-BASED DETECTION (when no controller status) === - if (isAtGroundLevel(alt, arrivalIcao)) { + if (isAtGroundLevel(alt, arrivalIcao ?? '')) { if (speed > 12) { return 'taxi'; } @@ -484,17 +598,27 @@ class FlightTracker { return 'unknown'; } - async detectFlightState(activeFlight, currentData, previousData, controllerStatus) { + async detectFlightState( + activeFlight: { flight_id?: number; roblox_username: string; callsign: string; initial_position_x?: number; initial_position_y?: number; landing_detected?: boolean; stationary_since?: string | Date | null; stationary_notification_sent?: boolean; }, + currentData: { x: number; y: number; altitude: number; speed: number; heading: number; }, + controllerStatus: string | undefined + ) { try { - const flightResult = await pool.query(` - SELECT flight_status, controller_managed, arrival_icao FROM logbook_flights WHERE id = $1 - `, [activeFlight.flight_id]); + if (typeof activeFlight.flight_id === 'undefined' || typeof activeFlight.callsign === 'undefined') { + debug(`[Flight Tracker] activeFlight.flight_id or callsign is undefined`, 'error'); + return; + } + const flightResult = await mainDb + .selectFrom('logbook_flights') + .select(['flight_status', 'controller_managed', 'arrival_icao']) + .where('id', '=', activeFlight.flight_id) + .executeTakeFirst(); - if (!flightResult.rows[0]) return; + if (!flightResult) return; - const arrivalIcao = flightResult.rows[0].arrival_icao; - const currentStatus = flightResult.rows[0].flight_status; - const controllerManaged = flightResult.rows[0].controller_managed; + const arrivalIcao = flightResult.arrival_icao; + const currentStatus = flightResult.flight_status; + const controllerManaged = flightResult.controller_managed; if (controllerManaged && controllerStatus?.toLowerCase() !== 'gate') { return; @@ -503,168 +627,230 @@ class FlightTracker { // === PENDING -> ACTIVE Detection === if (currentStatus === 'pending') { if (!activeFlight.initial_position_x) { - await pool.query(` - UPDATE logbook_active_flights - SET initial_position_x = $1, - initial_position_y = $2, - initial_position_time = NOW() - WHERE roblox_username = $3 - `, [currentData.x, currentData.y, activeFlight.roblox_username]); - - await pool.query(` - UPDATE logbook_flights - SET departure_position_x = $1, - departure_position_y = $2 - WHERE id = $3 - `, [currentData.x, currentData.y, activeFlight.flight_id]); + await mainDb + .updateTable('logbook_active_flights') + .set({ + initial_position_x: currentData.x, + initial_position_y: currentData.y, + initial_position_time: sql`NOW()` + }) + .where('roblox_username', '=', activeFlight.roblox_username) + .execute(); + + await mainDb + .updateTable('logbook_flights') + .set({ + departure_position_x: currentData.x, + departure_position_y: currentData.y + }) + .where('id', '=', activeFlight.flight_id) + .execute(); return; } + } const distance = this.calculateDistance( - activeFlight.initial_position_x, - activeFlight.initial_position_y, + activeFlight.initial_position_x ?? 0, + activeFlight.initial_position_y ?? 0, currentData.x, currentData.y ); const hasSpeed = currentData.speed > STATE_THRESHOLDS.MOVEMENT_SPEED; const hasMovedDistance = distance > STATE_THRESHOLDS.MOVEMENT_DISTANCE; - const isAirborne = !isAtGroundLevel(currentData.altitude, arrivalIcao); + const isAirborne = !isAtGroundLevel(currentData.altitude, arrivalIcao ?? ''); if ((hasSpeed && hasMovedDistance) || isAirborne) { - await pool.query(` - UPDATE logbook_flights - SET flight_status = 'active', - flight_start = NOW(), - activated_at = NOW() - WHERE id = $1 - `, [activeFlight.flight_id]); - - await pool.query(` - UPDATE logbook_active_flights - SET movement_started = true, - movement_start_time = NOW() - WHERE roblox_username = $1 - `, [activeFlight.roblox_username]); + await mainDb + .updateTable('logbook_flights') + .set({ + flight_status: 'active', + flight_start: sql`NOW()`, + activated_at: sql`NOW()` + }) + .where('id', '=', activeFlight.flight_id) + .execute(); + + await mainDb + .updateTable('logbook_active_flights') + .set({ + movement_started: true, + movement_start_time: sql`NOW()` + }) + .where('roblox_username', '=', activeFlight.roblox_username) + .execute(); debug(`[Flight Tracker] Flight ${activeFlight.callsign} is now ACTIVE`); } - } // === ACTIVE -> COMPLETED Detection (after landing) === else if (currentStatus === 'active' && activeFlight.landing_detected) { const isStationary = currentData.speed < STATE_THRESHOLDS.STATIONARY_SPEED; - const onGround = isAtGroundLevel(currentData.altitude, arrivalIcao); - + const onGround = isAtGroundLevel(currentData.altitude, arrivalIcao ?? ''); if (isStationary && onGround) { if (!activeFlight.stationary_since) { - await pool.query(` - UPDATE logbook_active_flights - SET stationary_since = NOW(), - stationary_position_x = $1, - stationary_position_y = $2, - stationary_notification_sent = false - WHERE roblox_username = $3 - `, [currentData.x, currentData.y, activeFlight.roblox_username]); + await mainDb + .updateTable('logbook_active_flights') + .set({ + stationary_since: sql`NOW()`, + stationary_position_x: currentData.x, + stationary_position_y: currentData.y, + stationary_notification_sent: false + }) + .where('roblox_username', '=', activeFlight.roblox_username) + .execute(); } else { - const stationaryDuration = (new Date() - new Date(activeFlight.stationary_since)) / 1000; + const stationaryDuration = (new Date().getTime() - new Date(activeFlight.stationary_since as string).getTime()) / 1000; if (stationaryDuration >= 60 && !activeFlight.stationary_notification_sent) { - const userResult = await pool.query(` - SELECT user_id FROM logbook_flights WHERE id = $1 - `, [activeFlight.flight_id]); - - if (userResult.rows[0]) { - await pool.query(` - INSERT INTO user_notifications (user_id, type, title, message, created_at) - VALUES ($1, 'info', 'Flight Ready to Complete', $2, NOW()) - `, [ - userResult.rows[0].user_id, - `Your flight ${activeFlight.callsign} has arrived at the gate. You can end your flight from the logbook page, or it will automatically complete if you disconnect.` - ]); - - await pool.query(` - UPDATE logbook_active_flights - SET stationary_notification_sent = true - WHERE roblox_username = $1 - `, [activeFlight.roblox_username]); + const userResult = await mainDb + .selectFrom('logbook_flights') + .select(['user_id']) + .where('id', '=', activeFlight.flight_id) + .executeTakeFirst(); + + if (userResult && userResult.user_id) { + await mainDb + .insertInto('user_notifications') + .values({ + id: sql`DEFAULT`, + user_id: userResult.user_id, + type: 'info', + title: 'Flight Ready to Complete', + message: `Your flight ${activeFlight.callsign} has arrived at the gate. You can end your flight from the logbook page, or it will automatically complete if you disconnect.`, + created_at: sql`NOW()` + }) + .execute(); + + await mainDb + .updateTable('logbook_active_flights') + .set({ stationary_notification_sent: true }) + .where('roblox_username', '=', activeFlight.roblox_username) + .execute(); } } } } else if (currentData.speed > STATE_THRESHOLDS.STATIONARY_SPEED) { if (activeFlight.stationary_since) { - await pool.query(` - UPDATE logbook_active_flights - SET stationary_since = NULL, - stationary_position_x = NULL, - stationary_position_y = NULL, - stationary_notification_sent = false - WHERE roblox_username = $1 - `, [activeFlight.roblox_username]); + await mainDb + .updateTable('logbook_active_flights') + .set({ + stationary_since: undefined, + stationary_position_x: undefined, + stationary_position_y: undefined, + stationary_notification_sent: false + }) + .where('roblox_username', '=', activeFlight.roblox_username) + .execute(); } } - } - + debug(`[Flight Tracker] Error detecting flight state`, 'error'); + } } catch (err) { - debug(`[Flight Tracker] Error detecting flight state: ${err}`, 'error'); + debug(`[Flight Tracker] Error detecting flight state for ${activeFlight.roblox_username}: ${err}`, 'error'); } } - calculateVerticalSpeed(current, previous) { + calculateVerticalSpeed( + current: { altitude: number; timestamp: number | Date }, + previous: { altitude: number; timestamp: number | Date } + ): number { const altChange = current.altitude - previous.altitude; - const timeChange = (current.timestamp - previous.timestamp) / 1000; - - if (timeChange === 0) return 0; - - const feetPerSecond = altChange / timeChange; + const timeChange = + (typeof current.timestamp === 'number' + ? current.timestamp + : new Date(current.timestamp).getTime()) - + (typeof previous.timestamp === 'number' + ? previous.timestamp + : new Date(previous.timestamp).getTime()); + const timeChangeSeconds = timeChange / 1000; + + if (timeChangeSeconds === 0) return 0; + + const feetPerSecond = altChange / timeChangeSeconds; return Math.round(feetPerSecond * 60); } - async detectLanding(current, previous, activeFlight, arrivalIcao = null) { + async detectLanding( + current: { altitude: number; speed: number }, + previous: { altitude: number } | undefined, + arrivalIcao: string | null | undefined = null + ): Promise { if (!previous) return false; - const isOnGround = isAtGroundLevel(current.altitude, arrivalIcao); + const isOnGround = isAtGroundLevel(current.altitude, arrivalIcao ?? ''); const lowSpeed = current.speed < PHASE_THRESHOLDS.LANDING_SPEED; const wasInAir = previous.altitude > 100; return isOnGround && lowSpeed && wasInAir; } - async handleFlightCompletion(activeFlight, robloxUsername) { + async handleFlightCompletion( + activeFlight: { + flight_id: number; + roblox_username: string; + callsign: string; + landing_detected?: boolean; + stationary_since?: string | Date | null; + stationary_notification_sent?: boolean; + [key: string]: unknown; + }, + robloxUsername: string + ) { try { await stopLandingDataCollection(robloxUsername); - const telemetryResult = await pool.query(` - SELECT * FROM logbook_telemetry - WHERE flight_id = $1 - ORDER BY timestamp ASC - `, [activeFlight.flight_id]); - - const telemetry = telemetryResult.rows; + const telemetry = await mainDb + .selectFrom('logbook_telemetry') + .selectAll() + .where('flight_id', '=', activeFlight.flight_id) + .orderBy('timestamp', 'asc') + .execute(); if (telemetry.length < 2) { - await pool.query(` - UPDATE logbook_flights - SET flight_status = 'aborted' - WHERE id = $1 - `, [activeFlight.flight_id]); + await mainDb + .updateTable('logbook_flights') + .set({ flight_status: 'aborted' }) + .where('id', '=', activeFlight.flight_id) + .execute(); await removeActiveFlightTracking(robloxUsername); this.flightData.delete(robloxUsername); this.lastTelemetryTime.delete(robloxUsername); return; } - const stats = await this.calculateFlightStats(telemetry, activeFlight); + const filteredTelemetry = telemetry.filter( + (t) => + typeof t.x === 'number' && + typeof t.y === 'number' && + typeof t.altitude_ft === 'number' && + typeof t.speed_kts === 'number' && + t.timestamp !== undefined && t.timestamp !== null + ).map(t => ({ + x: t.x as number, + y: t.y as number, + altitude_ft: t.altitude_ft as number, + speed_kts: t.speed_kts as number, + timestamp: t.timestamp + })); + + const stats = await this.calculateFlightStats(filteredTelemetry, activeFlight); + + if (stats.landingScore === null || stats.landingScore === undefined) { + stats.landingScore = 0; + } - await finalizeFlight(activeFlight.flight_id, stats); + await finalizeFlight(activeFlight.flight_id, { ...stats, landingScore: stats.landingScore ?? 0 }); - const flightResult = await pool.query(` - SELECT user_id FROM logbook_flights WHERE id = $1 - `, [activeFlight.flight_id]); + const flightResult = await mainDb + .selectFrom('logbook_flights') + .select(['user_id']) + .where('id', '=', activeFlight.flight_id) + .execute(); - if (flightResult.rows[0]) { - await updateUserStatsCache(flightResult.rows[0].user_id); + if (flightResult[0]) { + await updateUserStatsCache(flightResult[0].user_id); } await removeActiveFlightTracking(robloxUsername); @@ -676,19 +862,41 @@ class FlightTracker { } } - async calculateFlightStats(telemetry, activeFlight) { + async calculateFlightStats( + telemetry: Array<{ + x: number; + y: number; + altitude_ft: number; + speed_kts: number; + timestamp: string | number | Date; + }>, + activeFlight: { + roblox_username: string; + flight_id: number; + } + ) { const firstPoint = telemetry[0]; const lastPoint = telemetry[telemetry.length - 1]; - const durationMs = new Date(lastPoint.timestamp) - new Date(firstPoint.timestamp); + const firstTimestamp = typeof firstPoint.timestamp === 'number' + ? firstPoint.timestamp + : new Date(firstPoint.timestamp).getTime(); + const lastTimestamp = typeof lastPoint.timestamp === 'number' + ? lastPoint.timestamp + : new Date(lastPoint.timestamp).getTime(); + const durationMs = lastTimestamp - firstTimestamp; const durationMinutes = Math.round(durationMs / 60000); - const maxAltitude = Math.max(...telemetry.map(t => t.altitude_ft || 0)); - const maxSpeed = Math.max(...telemetry.map(t => t.speed_kts || 0)); + const maxAltitude = Math.max(...telemetry.map((t) => t.altitude_ft || 0)); + const maxSpeed = Math.max(...telemetry.map((t) => t.speed_kts || 0)); - const airbornePoints = telemetry.filter(t => t.altitude_ft > 100); - const averageSpeed = airbornePoints.length > 0 - ? Math.round(airbornePoints.reduce((sum, t) => sum + (t.speed_kts || 0), 0) / airbornePoints.length) - : 0; + const airbornePoints = telemetry.filter((t) => t.altitude_ft > 100); + const averageSpeed = + airbornePoints.length > 0 + ? Math.round( + airbornePoints.reduce((sum: number, t) => sum + (t.speed_kts || 0), 0) / + airbornePoints.length + ) + : 0; let totalDistance = 0; for (let i = 1; i < telemetry.length; i++) { @@ -717,11 +925,11 @@ class FlightTracker { }; } - calculateDistance(x1, y1, x2, y2) { + calculateDistance(x1: number, y1: number, x2: number, y2: number): number { return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); } - calculateSmoothnessScore(telemetry) { + calculateSmoothnessScore(telemetry: Array<{ speed_kts?: number; altitude_ft?: number }>) { let score = 100; for (let i = 1; i < telemetry.length; i++) { @@ -736,7 +944,7 @@ class FlightTracker { return Math.max(0, Math.min(100, score)); } - calculateLandingScore(landingRate) { + calculateLandingScore(landingRate: number) { const rate = Math.abs(landingRate); if (rate < 100) return 100; @@ -754,44 +962,65 @@ class FlightTracker { async checkForMissingFlights() { try { - const activeFlights = await pool.query(` - SELECT laf.*, lf.callsign, lf.user_id, lf.flight_status - FROM logbook_active_flights laf - JOIN logbook_flights lf ON laf.flight_id = lf.id - WHERE lf.flight_status IN ('pending', 'active') - `); + const activeFlights = await mainDb + .selectFrom('logbook_active_flights as laf') + .innerJoin('logbook_flights as lf', 'laf.flight_id', 'lf.id') + .selectAll('laf') + .select([ + 'lf.callsign', + 'lf.user_id', + 'lf.flight_status' + ]) + .where('lf.flight_status', 'in', ['pending', 'active']) + .execute(); const now = Date.now(); - for (const flight of activeFlights.rows) { + for (const flight of activeFlights) { const lastTelemetry = this.lastTelemetryTime.get(flight.roblox_username); if (lastTelemetry && (now - lastTelemetry) > this.flightNotFoundTimeout) { - if (flight.landing_detected) { + if (flight.landing_detected) { if (flight.stationary_position_x && flight.stationary_position_y) { - await pool.query(` - UPDATE logbook_flights - SET arrival_position_x = $1, - arrival_position_y = $2 - WHERE id = $3 - `, [flight.stationary_position_x, flight.stationary_position_y, flight.flight_id]); + await mainDb + .updateTable('logbook_flights') + .set({ + arrival_position_x: flight.stationary_position_x, + arrival_position_y: flight.stationary_position_y + }) + .where('id', '=', flight.flight_id!) + .execute(); } - await this.handleFlightCompletion(flight, flight.roblox_username); - } else { + if (typeof flight.flight_id === 'number') { + await this.handleFlightCompletion( + { ...flight, flight_id: flight.flight_id as number }, + flight.roblox_username + ); + } else { + debug(`[Flight Tracker] Skipping flight completion for ${flight.roblox_username} due to missing flight_id`, 'error'); + } + } else { await removeActiveFlightTracking(flight.roblox_username); - await pool.query(`DELETE FROM logbook_flights WHERE id = $1`, [flight.flight_id]); - - await pool.query(` - INSERT INTO user_notifications (user_id, type, title, message, created_at) - VALUES ($1, 'error', 'Flight Not Found', $2, NOW()) - `, [ - flight.user_id, - `We couldn't find your flight in the public ATC server. Make sure you're connected to the PFATC server. Your flight log entry for "${flight.callsign}" has been deleted.` - ]); - } + await mainDb + .deleteFrom('logbook_flights') + .where('id', '=', flight.flight_id!) + .execute(); + + await mainDb + .insertInto('user_notifications') + .values({ + id: sql`DEFAULT`, + user_id: flight.user_id, + type: 'error', + title: 'Flight Not Found', + message: `We couldn't find your flight in the public ATC server. Make sure you're connected to the PFATC server. Your flight log entry for "${flight.callsign}" has been deleted.`, + created_at: sql`NOW()` + }) + .execute(); + } this.lastTelemetryTime.delete(flight.roblox_username); this.flightData.delete(flight.roblox_username); diff --git a/server/services/landingDataFetcher.js b/server/services/landingDataFetcher.ts similarity index 87% rename from server/services/landingDataFetcher.js rename to server/services/landingDataFetcher.ts index a52b0fd..6197dc1 100644 --- a/server/services/landingDataFetcher.js +++ b/server/services/landingDataFetcher.ts @@ -43,14 +43,14 @@ function createProtobufSchema() { return root.lookupType("data.planes"); } -export async function startLandingDataCollection(robloxUsername, proxyUrl = null) { +export async function startLandingDataCollection(robloxUsername: string, proxyUrl: string | null = null) { if (activeConnections.has(robloxUsername)) { return; } const planesType = createProtobufSchema(); - const wsOptions = { + const wsOptions: WebSocket.ClientOptions = { 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', 'Origin': 'https://project-flight.com' @@ -72,7 +72,16 @@ export async function startLandingDataCollection(robloxUsername, proxyUrl = null socket.on('message', async (data) => { try { - const buffer = data instanceof Buffer ? new Uint8Array(data) : new Uint8Array(await data.arrayBuffer()); + let buffer: Uint8Array; + if (Array.isArray(data)) { + buffer = new Uint8Array(Buffer.concat(data)); + } else if (data instanceof Buffer) { + buffer = new Uint8Array(data); + } else if (data instanceof ArrayBuffer) { + buffer = new Uint8Array(data); + } else { + throw new Error("Unsupported WebSocket message data type"); + } const decoded = planesType.decode(buffer); const object = planesType.toObject(decoded, { defaults: true, longs: String }); @@ -108,7 +117,7 @@ export async function startLandingDataCollection(robloxUsername, proxyUrl = null activeConnections.set(robloxUsername, { socket, timeout }); } -export async function stopLandingDataCollection(robloxUsername) { +export async function stopLandingDataCollection(robloxUsername: string) { const connection = activeConnections.get(robloxUsername); if (!connection) { return null; diff --git a/server/services/logbookStatusHandler.js b/server/services/logbookStatusHandler.ts similarity index 59% rename from server/services/logbookStatusHandler.js rename to server/services/logbookStatusHandler.ts index 41fe6df..b7ce76c 100644 --- a/server/services/logbookStatusHandler.js +++ b/server/services/logbookStatusHandler.ts @@ -1,34 +1,35 @@ import chalk from 'chalk'; import { activateFlightByCallsign, completeFlightByCallsign } from '../db/logbook.js'; -import pool from '../db/connections/connection.js'; +import { mainDb } from '../db/connection.js'; -const log = (message, type = 'log') => { +const log = (message: string, type: 'log' | 'error' | 'debug' = 'log') => { const coloredMessage = message.replace(/\[Logbook\]/g, chalk.bgMagenta('[Logbook]')); if (type === 'error') console.error(coloredMessage); else if (type === 'debug') console.debug(coloredMessage); else console.log(coloredMessage); }; -export async function handleFlightStatusChange(callsign, status, controllerAirport = null) { +export async function handleFlightStatusChange(callsign: string, status: string, controllerAirport: string | null = null): Promise<{ action: string; flightId: number | null }> { if (!callsign || !status) { return { action: 'none', flightId: null }; } const normalizedStatus = status.toLowerCase(); - let result = { action: 'none', flightId: null }; + let result: { action: string; flightId: number | null } = { action: 'none', flightId: null }; let modifiedStatus = status; try { if (controllerAirport && (normalizedStatus === 'taxi' || normalizedStatus === 'rwy')) { - const flightInfo = await pool.query(` - SELECT departure_icao, arrival_icao - FROM logbook_flights - WHERE callsign = $1 AND flight_status IN ('pending', 'active') - LIMIT 1 - `, [callsign]); + const flightInfo = await mainDb + .selectFrom('logbook_flights') + .select(['departure_icao', 'arrival_icao']) + .where('callsign', '=', callsign) + .where('flight_status', 'in', ['pending', 'active']) + .limit(1) + .executeTakeFirst(); - if (flightInfo.rows.length > 0) { - const { departure_icao, arrival_icao } = flightInfo.rows[0]; + if (flightInfo) { + const { departure_icao, arrival_icao } = flightInfo; if (controllerAirport.toUpperCase() === departure_icao?.toUpperCase()) { modifiedStatus = normalizedStatus === 'taxi' ? 'origin_taxi' : 'origin_runway'; @@ -38,24 +39,26 @@ export async function handleFlightStatusChange(callsign, status, controllerAirpo } } - let updateResult = await pool.query(` - UPDATE logbook_flights lf - SET controller_status = $2 - FROM logbook_active_flights laf - WHERE laf.callsign = $1 AND laf.flight_id = lf.id - RETURNING lf.id - `, [callsign, modifiedStatus]); + let updateResult = await mainDb + .updateTable('logbook_flights as lf') + .from('logbook_active_flights as laf') + .set({ controller_status: modifiedStatus }) + .where('laf.callsign', '=', callsign) + .whereRef('laf.flight_id', '=', 'lf.id') + .returning('lf.id') + .execute(); - if (updateResult.rowCount === 0) { - updateResult = await pool.query(` - UPDATE logbook_flights - SET controller_status = $2 - WHERE callsign = $1 AND flight_status IN ('pending', 'active') - RETURNING id - `, [callsign, modifiedStatus]); + if (updateResult.length === 0) { + updateResult = await mainDb + .updateTable('logbook_flights') + .set({ controller_status: modifiedStatus }) + .where('callsign', '=', callsign) + .where('flight_status', 'in', ['pending', 'active']) + .returning('id') + .execute(); } - if (updateResult.rowCount > 0) { + if (updateResult.length > 0) { log(`[Logbook] Updated controller_status for ${callsign} to ${modifiedStatus}`); } else { log(`[Logbook] No flight found to update status for ${callsign}`, 'debug'); @@ -67,9 +70,7 @@ export async function handleFlightStatusChange(callsign, status, controllerAirpo result = { action: 'activated', flightId }; log(`[Logbook] Flight ${callsign} activated by controller (status: ${normalizedStatus})`); } - } - - else if (normalizedStatus === 'gate') { + } else if (normalizedStatus === 'gate') { const flightId = await completeFlightByCallsign(callsign); if (flightId) { result = { action: 'completed', flightId }; @@ -86,4 +87,4 @@ export async function handleFlightStatusChange(callsign, status, controllerAirpo } return result; -} +} \ No newline at end of file diff --git a/server/tools/detectVPN.js b/server/tools/detectVPN.js deleted file mode 100644 index 55be874..0000000 --- a/server/tools/detectVPN.js +++ /dev/null @@ -1,18 +0,0 @@ -import axios from 'axios'; -import { getClientIp } from './getIpAddress.js'; - -export async function detectVPN(req) { - const clientIp = getClientIp(req); - - // Skip localhost/private IPs - if (clientIp === '127.0.0.1' || clientIp === '::1' || clientIp.startsWith('192.168.') || clientIp.startsWith('10.') || clientIp.startsWith('172.') || clientIp.startsWith('fc00::') || clientIp.startsWith('fe80::')) { - return false; - } - - const response = await axios.get(`http://ip-api.com/json/${clientIp}?fields=proxy,hosting`, { - timeout: 5000 - }); - - const isVpn = response.data.proxy || response.data.hosting || false; - return isVpn; -} \ No newline at end of file diff --git a/server/tools/encryption.js b/server/tools/encryption.js deleted file mode 100644 index d802334..0000000 --- a/server/tools/encryption.js +++ /dev/null @@ -1,56 +0,0 @@ -import crypto from 'crypto'; -import dotenv from 'dotenv'; - -const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development'; -dotenv.config({ path: envFile }); - -const ENCRYPTION_KEY = process.env.DB_ENCRYPTION_KEY; -const ALGORITHM = 'aes-256-gcm'; - -if (!ENCRYPTION_KEY || ENCRYPTION_KEY.length !== 128) { - throw new Error('DB_ENCRYPTION_KEY must be 128 characters long'); -} - -const key = Buffer.from(ENCRYPTION_KEY, 'utf8').subarray(0, 32); - -export function encrypt(text) { - if (!text) return null; - - try { - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); - - let encrypted = cipher.update(JSON.stringify(text), 'utf8', 'hex'); - encrypted += cipher.final('hex'); - - const authTag = cipher.getAuthTag(); - - return { - iv: iv.toString('hex'), - data: encrypted, - authTag: authTag.toString('hex') - }; - } catch (error) { - console.error('Encryption error:', error); - return null; - } -} - -export function decrypt(encryptedData) { - if (!encryptedData) return null; - - try { - const { iv, data, authTag } = encryptedData; - const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex')); - - decipher.setAuthTag(Buffer.from(authTag, 'hex')); - - let decrypted = decipher.update(data, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - - return JSON.parse(decrypted); - } catch (error) { - console.error('Decryption error:', error); - return null; - } -} \ No newline at end of file diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..144c5f6 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "allowJs": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": false, + "outDir": "./dist", + "rootDir": "./", + "declaration": false, + "sourceMap": false + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/server/types/JwtPayload.ts b/server/types/JwtPayload.ts new file mode 100644 index 0000000..8cfad82 --- /dev/null +++ b/server/types/JwtPayload.ts @@ -0,0 +1,14 @@ +export interface JwtPayloadClient { + userId: string; + username: string; + discriminator: string | null; + avatar: string | null; + isAdmin: boolean; + rolePermissions?: string[]; + iat: number; + exp: number; +} + +export interface JwtPayload extends JwtPayloadClient { + id: string; +} \ No newline at end of file diff --git a/server/types/express.d.ts b/server/types/express.d.ts new file mode 100644 index 0000000..c9f5452 --- /dev/null +++ b/server/types/express.d.ts @@ -0,0 +1,9 @@ +import { JwtPayloadClient } from './JwtPayload.js'; + +declare global { + namespace Express { + interface Request { + user?: JwtPayloadClient; + } + } +} \ No newline at end of file diff --git a/server/utils/detectVPN.ts b/server/utils/detectVPN.ts new file mode 100644 index 0000000..63b802c --- /dev/null +++ b/server/utils/detectVPN.ts @@ -0,0 +1,31 @@ +import axios from 'axios'; +import { getClientIp } from './getIpAddress.js'; +import { Request } from 'express'; + +export async function detectVPN(req: Request) { + if (req.headers['x-forwarded-for']) { + return true; + } + + const clientIpRaw = getClientIp(req); + const clientIp = Array.isArray(clientIpRaw) ? clientIpRaw[0] : clientIpRaw; + + if ( + clientIp === '127.0.0.1' || + clientIp === '::1' || + clientIp.startsWith('192.168.') || + clientIp.startsWith('10.') || + clientIp.startsWith('172.') || + clientIp.startsWith('fc00::') || + clientIp.startsWith('fe80::') + ) { + return false; + } + + const response = await axios.get(`http://ip-api.com/json/${clientIp}?fields=proxy,hosting`, { + timeout: 5000 + }); + + const isVpn = response.data.proxy || response.data.hosting || false; + return isVpn; +} \ No newline at end of file diff --git a/server/utils/encryption.ts b/server/utils/encryption.ts new file mode 100644 index 0000000..5441e6f --- /dev/null +++ b/server/utils/encryption.ts @@ -0,0 +1,58 @@ +import crypto from 'crypto'; +import dotenv from 'dotenv'; + +const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development'; +dotenv.config({ path: envFile }); + +const ENCRYPTION_KEY = process.env.DB_ENCRYPTION_KEY; +const ALGORITHM = 'aes-256-gcm'; + +if (!ENCRYPTION_KEY || ENCRYPTION_KEY.length !== 128) { + throw new Error('DB_ENCRYPTION_KEY must be 128 characters long'); +} + +const key = Buffer.from(ENCRYPTION_KEY, 'utf8').subarray(0, 32); + +export function encrypt(text: unknown) { + if (!text) return null; + + try { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + + let encrypted = cipher.update(JSON.stringify(text), 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + return { + iv: iv.toString('hex'), + data: encrypted, + authTag: authTag.toString('hex') + }; + } catch (error) { + console.error('Encryption error:', error); + return null; + } +} + +export function decrypt(encryptedData: { iv: string; data: string; authTag: string }) { + if (!encryptedData || typeof encryptedData !== 'object' || !encryptedData.iv || !encryptedData.data || !encryptedData.authTag) { + return null; + } + + try { + const { iv, data, authTag } = encryptedData; + const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex')); + + decipher.setAuthTag(Buffer.from(authTag, 'hex')); + + let decrypted = decipher.update(data, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return JSON.parse(decrypted); + } catch (error) { + console.error('Decryption error:', error); + return null; + } +} \ No newline at end of file diff --git a/server/utils/flightUtils.js b/server/utils/flightUtils.ts similarity index 52% rename from server/utils/flightUtils.js rename to server/utils/flightUtils.ts index c7d11f6..1a55b8e 100644 --- a/server/utils/flightUtils.js +++ b/server/utils/flightUtils.ts @@ -1,9 +1,17 @@ -import { getAirportData, getAircraftData } from "../tools/getData.js"; -import { generateFlightId } from "../tools/ids.js"; +import { getAirportData, getAircraftData } from "./getData.js"; +import { generateFlightId } from "./ids.js"; + +export interface Flight { + flightType?: string; + flight_type?: string; + icao?: string; + runway?: string; + arrival?: string; +} export const generateRandomId = generateFlightId; -export async function generateSquawk(flight) { +export async function generateSquawk(flight: Flight) { let squawk = ""; if (flight.flightType === "VFR" || flight.flight_type === "VFR") { squawk = "7000"; @@ -15,19 +23,21 @@ export async function generateSquawk(flight) { return squawk; } -export async function generateSID(flight) { +export async function generateSID(flight: Flight) { const { icao, runway, arrival } = flight; const airportData = getAirportData(); - const airport = airportData.find((ap) => ap.icao === icao); + type DepartureData = { [runway: string]: { [arrival: string]: string } }; + type Airport = { icao: string; departures?: DepartureData }; + const airport = airportData.find((ap: Airport) => ap.icao === icao); if (!airport) { throw new Error("Airport not found"); } - let selectedRunway = runway; + let selectedRunway: string | undefined = runway; if (!selectedRunway || !airport.departures || !airport.departures[selectedRunway]) { const runways = airport.departures ? Object.keys(airport.departures) : []; - selectedRunway = runways.length > 0 ? runways[0] : null; + selectedRunway = runways.length > 0 ? runways[0] : undefined; } if (!selectedRunway || !airport.departures || !airport.departures[selectedRunway]) { @@ -41,23 +51,35 @@ export async function generateSID(flight) { sid = runwayData[arrival]; } else { const firstAvailableSid = Object.values(runwayData).find((val) => val !== ""); - sid = firstAvailableSid || ""; + sid = typeof firstAvailableSid === "string" ? firstAvailableSid : ""; } return { sid }; } -export async function getWakeTurbulence(aircraftType) { +interface AircraftInfo { + type: string; + wtc?: string; + [key: string]: unknown; +} + +export async function getWakeTurbulence(aircraftType: string) { const aircraftDataRaw = getAircraftData(); - let aircraftArray = []; + let aircraftArray: AircraftInfo[] = []; if (Array.isArray(aircraftDataRaw)) { - aircraftArray = aircraftDataRaw; + aircraftArray = aircraftDataRaw as AircraftInfo[]; } else { - aircraftArray = Object.entries(aircraftDataRaw).map(([type, info]) => ({ - type, - ...info - })); + aircraftArray = Object.entries(aircraftDataRaw).map(([type, info]) => { + if (typeof info === "object" && info !== null) { + return { + type, + ...(info as object) + } as AircraftInfo; + } else { + return { type } as AircraftInfo; + } + }); } if (!aircraftArray || aircraftArray.length === 0) { diff --git a/server/tools/getData.js b/server/utils/getData.ts similarity index 100% rename from server/tools/getData.js rename to server/utils/getData.ts diff --git a/server/tools/getIpAddress.js b/server/utils/getIpAddress.ts similarity index 84% rename from server/tools/getIpAddress.js rename to server/utils/getIpAddress.ts index 30628cd..4ec3176 100644 --- a/server/tools/getIpAddress.js +++ b/server/utils/getIpAddress.ts @@ -1,4 +1,6 @@ -export function getClientIp(req) { +import { Request } from 'express'; + +export function getClientIp(req: Request) { if (req.headers['cf-connecting-ip']) { return req.headers['cf-connecting-ip']; } diff --git a/server/tools/ids.js b/server/utils/ids.ts similarity index 100% rename from server/tools/ids.js rename to server/utils/ids.ts diff --git a/server/utils/sanitization.js b/server/utils/sanitization.ts similarity index 73% rename from server/utils/sanitization.js rename to server/utils/sanitization.ts index 6482a18..7e01fb6 100644 --- a/server/utils/sanitization.js +++ b/server/utils/sanitization.ts @@ -1,4 +1,4 @@ -export function sanitizeString(input, maxLength = 500) { +export function sanitizeString(input: string, maxLength: number = 500): string { if (typeof input !== 'string') { return ''; } @@ -16,14 +16,14 @@ export function sanitizeString(input, maxLength = 500) { return sanitized; } -export function sanitizeCallsign(callsign) { +export function sanitizeCallsign(callsign: string): string { if (typeof callsign !== 'string') return ''; return callsign - .replace(/[^A-Za-z0-9\-]/g, '') + .replace(/[^A-Za-z0-9-]/g, '') .substring(0, 16); } -export function sanitizeAirportCode(code) { +export function sanitizeAirportCode(code: string): string { if (typeof code !== 'string') return ''; return code .replace(/[^A-Za-z]/g, '') @@ -31,7 +31,7 @@ export function sanitizeAirportCode(code) { .toUpperCase(); } -export function sanitizeAlphanumeric(input, maxLength = 50) { +export function sanitizeAlphanumeric(input: string, maxLength: number = 50): string { if (typeof input !== 'string') return ''; return input .replace(/[^A-Za-z0-9\s\-_]/g, '') @@ -39,7 +39,7 @@ export function sanitizeAlphanumeric(input, maxLength = 50) { .substring(0, maxLength); } -export function sanitizeRunway(runway) { +export function sanitizeRunway(runway: string): string { if (typeof runway !== 'string') return ''; return runway .replace(/[^A-Za-z0-9]/g, '') @@ -47,14 +47,14 @@ export function sanitizeRunway(runway) { .toUpperCase(); } -export function sanitizeSquawk(squawk) { +export function sanitizeSquawk(squawk: string): string { if (typeof squawk !== 'string') return ''; return squawk .replace(/[^0-7]/g, '') .substring(0, 4); } -export function sanitizeFlightLevel(fl) { +export function sanitizeFlightLevel(fl: string): string { if (typeof fl !== 'string') return ''; return fl .replace(/[^0-9A-Za-z]/g, '') @@ -62,7 +62,7 @@ export function sanitizeFlightLevel(fl) { .toUpperCase(); } -export function sanitizeMessage(message, maxLength = 500) { +export function sanitizeMessage(message: string, maxLength: number = 500): string { if (typeof message !== 'string') return ''; let sanitized = message diff --git a/server/utils/validation.js b/server/utils/validation.ts similarity index 83% rename from server/utils/validation.js rename to server/utils/validation.ts index 357c680..12a4605 100644 --- a/server/utils/validation.js +++ b/server/utils/validation.ts @@ -1,4 +1,4 @@ -export function validateSessionId(sessionId) { +export function validateSessionId(sessionId: string | undefined) { if (!sessionId || typeof sessionId !== 'string') { throw new Error('Session ID is required'); } @@ -10,7 +10,7 @@ export function validateSessionId(sessionId) { return sessionId; } -export function validateAccessId(accessId) { +export function validateAccessId(accessId: unknown) { if (!accessId || typeof accessId !== 'string') { throw new Error('Access ID is required'); } @@ -22,7 +22,7 @@ export function validateAccessId(accessId) { return accessId; } -export function validateFlightId(flightId) { +export function validateFlightId(flightId: unknown) { if (flightId === undefined || flightId === null) { throw new Error('Flight ID is required'); } @@ -40,7 +40,7 @@ export function validateFlightId(flightId) { return id; } -export function validateCallsign(callsign) { +export function validateCallsign(callsign: unknown) { if (!callsign || typeof callsign !== 'string') { throw new Error('Callsign is required'); } @@ -58,7 +58,7 @@ export function validateCallsign(callsign) { return trimmed.toUpperCase(); } -export function validateSquawk(squawk) { +export function validateSquawk(squawk: unknown) { if (!squawk) return null; const trimmed = squawk.toString().trim(); @@ -70,7 +70,7 @@ export function validateSquawk(squawk) { return trimmed; } -export function validateFlightLevel(fl) { +export function validateFlightLevel(fl: string) { if (fl === undefined || fl === null) return null; const level = parseInt(fl, 10); @@ -86,7 +86,7 @@ export function validateFlightLevel(fl) { return level; } -export function validateStand(stand) { +export function validateStand(stand: unknown) { if (!stand) return null; const trimmed = stand.toString().trim(); @@ -98,7 +98,7 @@ export function validateStand(stand) { return trimmed; } -export function validateRemark(remark) { +export function validateRemark(remark: unknown) { if (!remark) return null; const trimmed = remark.toString().trim(); @@ -110,7 +110,7 @@ export function validateRemark(remark) { return trimmed; } -export function sanitizeInput(input) { +export function sanitizeInput(input: unknown) { if (!input) return input; return input diff --git a/server/websockets/arrivalsWebsocket.js b/server/websockets/arrivalsWebsocket.js deleted file mode 100644 index bdc30b3..0000000 --- a/server/websockets/arrivalsWebsocket.js +++ /dev/null @@ -1,211 +0,0 @@ -import { Server as SocketServer } from 'socket.io'; -import { updateFlight, getFlightsBySessionWithTime } from '../db/flights.js'; -import { validateSessionAccess } from '../middleware/sessionAccess.js'; -import { getSessionById, getAllSessions } from '../db/sessions.js'; -import { getFlightsIO } from './flightsWebsocket.js'; -import { handleFlightStatusChange } from '../services/logbookStatusHandler.js'; -import { validateSessionId, validateAccessId, validateFlightId } from '../utils/validation.js'; -import { sanitizeString, sanitizeSquawk, sanitizeFlightLevel } from '../utils/sanitization.js'; - -let io; -const updateTimers = new Map(); - -export function setupArrivalsWebsocket(httpServer) { - io = new SocketServer(httpServer, { - path: '/sockets/arrivals', - cors: { - origin: ['http://localhost:5173', 'http://localhost:9901', 'https://control.pfconnect.online', 'https://test.pfconnect.online'], - credentials: true - } - }); - - io.on('connection', async (socket) => { - try { - const sessionId = validateSessionId(socket.handshake.query.sessionId); - const accessId = validateAccessId(socket.handshake.query.accessId); - - const valid = await validateSessionAccess(sessionId, accessId); - if (!valid) { - socket.disconnect(true); - return; - } - - const session = await getSessionById(sessionId); - if (!session || !session.is_pfatc) { - socket.disconnect(true); - return; - } - - socket.data.sessionId = sessionId; - socket.data.session = session; - - socket.join(sessionId); - - try { - const externalArrivals = await getExternalArrivals(session.airport_icao); - socket.emit('initialExternalArrivals', externalArrivals); - } catch (error) { - console.error('Error fetching external arrivals:', error); - } - - socket.on('updateArrival', async ({ flightId, updates }) => { - const sessionId = socket.data.sessionId; - const session = socket.data.session; - try { - validateFlightId(flightId); - - if (updates.status) { - console.log(`[ArrivalWS] Received update for ${flightId}:`, JSON.stringify(updates)); - } - - const sourceSessionId = await findFlightSourceSession(flightId, session.airport_icao); - - if (!sourceSessionId) { - socket.emit('arrivalError', { action: 'update', flightId, error: 'Flight not found in any session' }); - return; - } - - const allowedFields = ['clearedFL', 'status', 'star', 'remark', 'squawk', 'gate']; - const filteredUpdates = {}; - - for (const [key, value] of Object.entries(updates)) { - if (allowedFields.includes(key)) { - filteredUpdates[key] = value; - } - } - - if (Object.keys(filteredUpdates).length === 0) { - socket.emit('arrivalError', { action: 'update', flightId, error: 'No valid fields to update' }); - return; - } - - if (filteredUpdates.clearedFL) filteredUpdates.clearedFL = sanitizeFlightLevel(filteredUpdates.clearedFL); - if (filteredUpdates.star) filteredUpdates.star = sanitizeString(filteredUpdates.star, 16); - if (filteredUpdates.remark) filteredUpdates.remark = sanitizeString(filteredUpdates.remark, 500); - if (filteredUpdates.squawk) filteredUpdates.squawk = sanitizeSquawk(filteredUpdates.squawk); - if (filteredUpdates.gate) filteredUpdates.gate = sanitizeString(filteredUpdates.gate, 8); - - const updatedFlight = await updateFlight(sourceSessionId, flightId, filteredUpdates); - - if (updatedFlight) { - // Handle logbook status changes - if (filteredUpdates.status && updatedFlight.callsign) { - console.log(`[ArrivalWS] Detected status change: ${updatedFlight.callsign} -> ${filteredUpdates.status}`); - // Get session's airport to determine origin vs destination - const controllerAirport = session?.airport_icao || null; - await handleFlightStatusChange(updatedFlight.callsign, filteredUpdates.status, controllerAirport); - } - - const flightsIO = getFlightsIO(); - if (flightsIO) { - flightsIO.to(sourceSessionId).emit('flightUpdated', updatedFlight); - } - - io.to(sessionId).emit('arrivalUpdated', updatedFlight); - - await broadcastToOtherArrivalSessions(updatedFlight, sessionId); - } else { - socket.emit('arrivalError', { action: 'update', flightId, error: 'Flight not found' }); - } - - } catch (error) { - console.error('Error updating arrival via websocket:', error); - socket.emit('arrivalError', { action: 'update', flightId, error: 'Failed to update arrival' }); - } - }); - } catch (error) { - console.error('Invalid session or access ID:', error.message); - socket.disconnect(true); - } - }); - - return io; -} - -async function findFlightSourceSession(flightId, arrivalAirport) { - try { - const allSessions = await getAllSessions(); - const pfatcSessions = allSessions.filter(session => - session.is_pfatc && session.airport_icao !== arrivalAirport - ); - - for (const session of pfatcSessions) { - try { - const flights = await getFlightsBySessionWithTime(session.session_id, 2); - const flight = flights.find(f => f.id === flightId); - if (flight) { - return session.session_id; - } - } catch (error) { - console.error(`Error checking session ${session.session_id} for flight ${flightId}:`, error); - } - } - return null; - } catch (error) { - console.error('Error finding flight source session:', error); - return null; - } -} - -async function broadcastToOtherArrivalSessions(flight, excludeSessionId) { - try { - const allSessions = await getAllSessions(); - const arrivalSessions = allSessions.filter(session => - session.is_pfatc && - session.session_id !== excludeSessionId && - session.airport_icao === flight.arrival?.toUpperCase() - ); - - for (const session of arrivalSessions) { - io.to(session.session_id).emit('arrivalUpdated', flight); - } - } catch (error) { - console.error('Error broadcasting to other arrival sessions:', error); - } -} - -async function getExternalArrivals(airportIcao) { - try { - const allSessions = await getAllSessions(); - const pfatcSessions = allSessions.filter(session => - session.is_pfatc && session.airport_icao !== airportIcao - ); - - const externalArrivals = []; - - for (const session of pfatcSessions) { - try { - const flights = await getFlightsBySessionWithTime(session.session_id, 2); - const arrivalsToThisAirport = flights.filter(flight => - flight.arrival?.toUpperCase() === airportIcao.toUpperCase() - ); - - const enrichedFlights = arrivalsToThisAirport.map(flight => ({ - ...flight, - sourceSessionId: session.session_id, - sourceAirport: session.airport_icao, - isExternal: true - })); - - externalArrivals.push(...enrichedFlights); - } catch (error) { - console.error(`Error fetching flights for session ${session.session_id}:`, error); - } - } - - return externalArrivals; - } catch (error) { - console.error('Error fetching external arrivals:', error); - return []; - } -} - -export function getArrivalsIO() { - return io; -} - -export function broadcastArrivalEvent(sessionId, event, data) { - if (io) { - io.to(sessionId).emit(event, data); - } -} \ No newline at end of file diff --git a/server/websockets/arrivalsWebsocket.ts b/server/websockets/arrivalsWebsocket.ts new file mode 100644 index 0000000..f05538c --- /dev/null +++ b/server/websockets/arrivalsWebsocket.ts @@ -0,0 +1,217 @@ +import { Server as SocketServer, Socket } from 'socket.io'; +import type { Server as HttpServer } from 'http'; +import { updateFlight, getFlightsBySessionWithTime, type ClientFlight } from '../db/flights.js'; +import { validateSessionAccess } from '../middleware/sessionAccess.js'; +import { getSessionById, getAllSessions } from '../db/sessions.js'; +import { getFlightsIO } from './flightsWebsocket.js'; +import { handleFlightStatusChange } from '../services/logbookStatusHandler.js'; +import { validateSessionId, validateAccessId, validateFlightId } from '../utils/validation.js'; +import { sanitizeString, sanitizeSquawk, sanitizeFlightLevel } from '../utils/sanitization.js'; +import type { FlightsDatabase } from '../db/types/connection/FlightsDatabase.js'; + +interface ArrivalUpdateData { + flightId: string | number; + updates: Partial; +} + +let io: SocketServer; +const updateTimers = new Map(); +export function setupArrivalsWebsocket(httpServer: HttpServer): SocketServer { + io = new SocketServer(httpServer, { + path: '/sockets/arrivals', + cors: { + origin: ['http://localhost:5173', 'http://localhost:9901', 'https://control.pfconnect.online', 'https://test.pfconnect.online'], + credentials: true + } + }); + + io.on('connection', async (socket: Socket) => { + try { + const sessionId = validateSessionId(socket.handshake.query.sessionId as string); + const accessId = validateAccessId(socket.handshake.query.accessId as string); + + const valid = await validateSessionAccess(sessionId, accessId); + if (!valid) { + socket.disconnect(true); + return; + } + + const session = await getSessionById(sessionId); + if (!session || !session.is_pfatc) { + socket.disconnect(true); + return; + } + + socket.data.sessionId = sessionId; + socket.data.session = session; + + socket.join(sessionId); + + try { + const externalArrivals = await getExternalArrivals(session.airport_icao); + socket.emit('initialExternalArrivals', externalArrivals); + } catch (error) { + console.error('Error fetching external arrivals:', error); + } + + socket.on('updateArrival', async ({ flightId, updates }: ArrivalUpdateData) => { + const sessionId = socket.data.sessionId; + const session = socket.data.session; + try { + validateFlightId(flightId); + + if (updates.status) { + console.log(`[ArrivalWS] Received update for ${flightId}:`, JSON.stringify(updates)); + } + + const sourceSessionId = await findFlightSourceSession(flightId as string, session.airport_icao); + + if (!sourceSessionId) { + socket.emit('arrivalError', { action: 'update', flightId, error: 'Flight not found in any session' }); + return; + } + + const allowedFields = ['clearedfl', 'status', 'star', 'remark', 'squawk', 'gate']; + const filteredUpdates: Record = {}; + + for (const [key, value] of Object.entries(updates)) { + if (allowedFields.includes(key)) { + filteredUpdates[key] = value; + } + } + + if (Object.keys(filteredUpdates).length === 0) { + socket.emit('arrivalError', { action: 'update', flightId, error: 'No valid fields to update' }); + return; + } + + if (filteredUpdates.clearedfl && typeof filteredUpdates.clearedfl === 'string') filteredUpdates.clearedfl = sanitizeFlightLevel(filteredUpdates.clearedfl); + if (filteredUpdates.star && typeof filteredUpdates.star === 'string') filteredUpdates.star = sanitizeString(filteredUpdates.star, 16); + if (filteredUpdates.remark && typeof filteredUpdates.remark === 'string') filteredUpdates.remark = sanitizeString(filteredUpdates.remark, 500); + if (filteredUpdates.squawk && typeof filteredUpdates.squawk === 'string') filteredUpdates.squawk = sanitizeSquawk(filteredUpdates.squawk); + if (filteredUpdates.gate && typeof filteredUpdates.gate === 'string') filteredUpdates.gate = sanitizeString(filteredUpdates.gate, 8); + + const updatedFlight = await updateFlight(sourceSessionId, flightId as string, filteredUpdates); + + if (updatedFlight) { + // Handle logbook status changes + if (filteredUpdates.status && typeof filteredUpdates.status === 'string' && updatedFlight.callsign) { + console.log(`[ArrivalWS] Detected status change: ${updatedFlight.callsign} -> ${filteredUpdates.status}`); + // Get session's airport to determine origin vs destination + const controllerAirport = session?.airport_icao || null; + await handleFlightStatusChange(updatedFlight.callsign, filteredUpdates.status, controllerAirport); + } + + const flightsIO = getFlightsIO(); + if (flightsIO) { + flightsIO.to(sourceSessionId).emit('flightUpdated', updatedFlight); + } + + io.to(sessionId).emit('arrivalUpdated', updatedFlight); + + await broadcastToOtherArrivalSessions(updatedFlight, sessionId); + } else { + socket.emit('arrivalError', { action: 'update', flightId, error: 'Flight not found' }); + } + + } catch (error) { + console.error('Error updating arrival via websocket:', error); + socket.emit('arrivalError', { action: 'update', flightId, error: 'Failed to update arrival' }); + } + }); + } catch (error) { + console.error('Invalid session or access ID:', (error as Error).message); + socket.disconnect(true); + } + }); + + return io; +} + +async function findFlightSourceSession(flightId: string, arrivalAirport: string): Promise { + try { + const allSessions = await getAllSessions(); + const pfatcSessions = allSessions.filter(session => + session.is_pfatc && session.airport_icao !== arrivalAirport + ); + + for (const session of pfatcSessions) { + try { + const flights = await getFlightsBySessionWithTime(session.session_id, 2); + const flight = flights.find(f => f.id === flightId); + if (flight) { + return session.session_id; + } + } catch (error) { + console.error(`Error checking session ${session.session_id} for flight ${flightId}:`, error); + } + } + return null; + } catch (error) { + console.error('Error finding flight source session:', error); + return null; + } +} + +async function broadcastToOtherArrivalSessions(flight: ClientFlight, excludeSessionId: string): Promise { + try { + const allSessions = await getAllSessions(); + const arrivalSessions = allSessions.filter(session => + session.is_pfatc && + session.session_id !== excludeSessionId && + session.airport_icao === flight.arrival?.toUpperCase() + ); + + for (const session of arrivalSessions) { + io.to(session.session_id).emit('arrivalUpdated', flight); + } + } catch (error) { + console.error('Error broadcasting to other arrival sessions:', error); + } +} + +async function getExternalArrivals(airportIcao: string): Promise { + try { + const allSessions = await getAllSessions(); + const pfatcSessions = allSessions.filter(session => + session.is_pfatc && session.airport_icao !== airportIcao + ); + + const externalArrivals: ClientFlight[] = []; + + for (const session of pfatcSessions) { + try { + const flights = await getFlightsBySessionWithTime(session.session_id, 2); + const arrivalsToThisAirport = flights.filter(flight => + flight.arrival?.toUpperCase() === airportIcao.toUpperCase() + ); + + const enrichedFlights = arrivalsToThisAirport.map(flight => ({ + ...flight, + sourceSessionId: session.session_id, + sourceAirport: session.airport_icao, + isExternal: true + })); + + externalArrivals.push(...enrichedFlights); + } catch (error) { + console.error(`Error fetching flights for session ${session.session_id}:`, error); + } + } + + return externalArrivals as ClientFlight[]; + } catch (error) { + console.error('Error fetching external arrivals:', error); + return []; + } +} + +export function getArrivalsIO(): SocketServer | undefined { + return io; +} + +export function broadcastArrivalEvent(sessionId: string, event: string, data: unknown): void { + if (io) { + io.to(sessionId).emit(event, data); + } +} \ No newline at end of file diff --git a/server/websockets/chatWebsocket.js b/server/websockets/chatWebsocket.ts similarity index 60% rename from server/websockets/chatWebsocket.js rename to server/websockets/chatWebsocket.ts index 5244b62..35cbc86 100644 --- a/server/websockets/chatWebsocket.js +++ b/server/websockets/chatWebsocket.ts @@ -3,11 +3,27 @@ import { addChatMessage, deleteChatMessage } 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'; const activeChatUsers = new Map(); -let sessionUsersIO = null; +let sessionUsersIO: SessionUsersWebsocketIO | null = null; + +interface MentionData { + messageId: string; + mentionedUserId: string; + mentionerUsername: string; + message: string; + sessionId: string; + timestamp: string; + [key: string]: unknown; +} + +interface SessionUsersWebsocketIO { + activeUsers?: Map>; + sendMentionToUser(userId: string, mentionData: MentionData): void; +} -export function setupChatWebsocket(httpServer, sessionUsersWebsocketIO) { +export function setupChatWebsocket(httpServer: Server, sessionUsersWebsocketIO: SessionUsersWebsocketIO) { sessionUsersIO = sessionUsersWebsocketIO; const io = new SocketServer(httpServer, { @@ -20,29 +36,39 @@ export function setupChatWebsocket(httpServer, sessionUsersWebsocketIO) { io.on('connection', async (socket) => { try { - const sessionId = validateSessionId(socket.handshake.query.sessionId); - const accessId = validateAccessId(socket.handshake.query.accessId); - const userId = socket.handshake.query.userId; - - const valid = await validateSessionAccess(sessionId, accessId); - if (!valid) { - socket.disconnect(true); - return; - } - - socket.data.sessionId = sessionId; - socket.data.userId = userId; - - socket.join(sessionId); - - if (!activeChatUsers.has(sessionId)) { - activeChatUsers.set(sessionId, new Set()); - } - activeChatUsers.get(sessionId).add(userId); - - io.to(sessionId).emit('activeChatUsers', Array.from(activeChatUsers.get(sessionId))); - - socket.on('chatMessage', async ({ user, message }) => { + const sessionId = validateSessionId( + Array.isArray(socket.handshake.query.sessionId) + ? socket.handshake.query.sessionId[0] + : socket.handshake.query.sessionId + ); + const accessId = validateAccessId( + Array.isArray(socket.handshake.query.accessId) + ? socket.handshake.query.accessId[0] + : socket.handshake.query.accessId + ); + const userId = Array.isArray(socket.handshake.query.userId) + ? socket.handshake.query.userId[0] + : socket.handshake.query.userId; + + const valid = await validateSessionAccess(sessionId, accessId); + if (!valid) { + socket.disconnect(true); + return; + } + + socket.data.sessionId = sessionId; + socket.data.userId = userId; + + socket.join(sessionId); + + if (!activeChatUsers.has(sessionId)) { + activeChatUsers.set(sessionId, new Set()); + } + activeChatUsers.get(sessionId).add(userId); + + io.to(sessionId).emit('activeChatUsers', Array.from(activeChatUsers.get(sessionId))); + + socket.on('chatMessage', async ({ user, message }) => { const sessionId = socket.data.sessionId; if (!sessionId || !message || message.length > 500) return; @@ -75,19 +101,19 @@ export function setupChatWebsocket(httpServer, sessionUsersWebsocketIO) { const sessionUsers = sessionUsersIO.activeUsers?.get(sessionId) || []; mentions.forEach(mentionedUsername => { - const mentionedUser = sessionUsers.find(u => u.username === mentionedUsername); + const mentionedUser = sessionUsers.find((u: { id: string; username: string }) => u.username === mentionedUsername); if (mentionedUser) { const mentionData = { - messageId: chatMsg.id, + messageId: String(chatMsg.id ?? ''), mentionedUserId: mentionedUser.id, mentionerUsername: user.username, message: sanitizedMessage, sessionId, - timestamp: chatMsg.sent_at + timestamp: chatMsg.sent_at ? String(chatMsg.sent_at) : '' }; - sessionUsersIO.sendMentionToUser(mentionedUser.id, mentionData); + (sessionUsersIO as SessionUsersWebsocketIO).sendMentionToUser(mentionedUser.id, mentionData); } }); } @@ -115,8 +141,9 @@ export function setupChatWebsocket(httpServer, sessionUsersWebsocketIO) { } } }); - } catch (error) { - console.error('Invalid session or access ID:', error.message); + + } catch { + console.error('Invalid session or access ID'); socket.disconnect(true); } }); @@ -124,7 +151,7 @@ export function setupChatWebsocket(httpServer, sessionUsersWebsocketIO) { return io; } -function parseMentions(message) { +function parseMentions(message: string) { const mentionRegex = /@(\w+)/g; const mentions = []; let match; diff --git a/server/websockets/flightsWebsocket.js b/server/websockets/flightsWebsocket.ts similarity index 63% rename from server/websockets/flightsWebsocket.js rename to server/websockets/flightsWebsocket.ts index 863ce01..c022cb1 100644 --- a/server/websockets/flightsWebsocket.js +++ b/server/websockets/flightsWebsocket.ts @@ -1,17 +1,45 @@ -import { Server as SocketServer } from 'socket.io'; -import { addFlight, updateFlight, deleteFlight } from '../db/flights.js'; +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, getAllSessions, getSessionById } from '../db/sessions.js'; import { getArrivalsIO } from './arrivalsWebsocket.js'; import { handleFlightStatusChange } from '../services/logbookStatusHandler.js'; -import flightsPool from '../db/connections/flightsConnection.js'; +import { flightsDb } from '../db/connection.js'; import { validateSessionId, validateAccessId, validateFlightId } from '../utils/validation.js'; -import { sanitizeCallsign, sanitizeString, sanitizeSquawk, sanitizeFlightLevel, sanitizeRunway, sanitizeMessage } from '../utils/sanitization.js'; +import { sanitizeCallsign, sanitizeString, sanitizeSquawk, sanitizeFlightLevel, sanitizeRunway } from '../utils/sanitization.js'; +import type { Server as HTTPServer } from 'http'; +import type { FlightsDatabase } from '../db/types/connection/FlightsDatabase.js'; -let io; +interface FlightUpdateData { + flightId: string | number; + updates: Record; +} + +interface PDCData { + flightId: string | number; + pdcText: string; + targetPilotUserId?: string; +} + +interface PDCRequestData { + flightId?: string | number; + callsign?: string; + note?: string; +} + +interface ContactMeData { + flightId: string | number; + message?: string; +} + +interface SessionUpdateData { + activeRunway?: string; +} -export function setupFlightsWebsocket(httpServer) { - io = new SocketServer(httpServer, { +let io: SocketIOServer; + +export function setupFlightsWebsocket(httpServer: HTTPServer): SocketIOServer { + io = new SocketIOServer(httpServer, { path: '/sockets/flights', cors: { origin: ['http://localhost:5173', 'http://localhost:9901', 'https://control.pfconnect.online', 'https://test.pfconnect.online'], @@ -19,15 +47,15 @@ export function setupFlightsWebsocket(httpServer) { } }); - io.on('connection', async (socket) => { - const sessionId = socket.handshake.query.sessionId; - const accessId = socket.handshake.query.accessId; + io.on('connection', async (socket: Socket) => { + const sessionId = socket.handshake.query.sessionId as string; + const accessId = socket.handshake.query.accessId as string; try { const validSessionId = validateSessionId(sessionId); socket.data.sessionId = validSessionId; - let role = 'pilot'; + let role: 'pilot' | 'controller' = 'pilot'; if (accessId) { const validAccessId = validateAccessId(accessId); const valid = await validateSessionAccess(validSessionId, validAccessId); @@ -41,12 +69,12 @@ export function setupFlightsWebsocket(httpServer) { socket.join(validSessionId); } catch (error) { - console.error('Invalid session or access ID:', error.message); + console.error('Invalid session or access ID:', (error as Error).message); socket.disconnect(true); return; } - socket.on('updateFlight', async ({ flightId, updates }) => { + socket.on('updateFlight', async ({ flightId, updates }: FlightUpdateData) => { const sessionId = socket.data.sessionId; if (socket.data.role !== 'controller') { socket.emit('flightError', { action: 'update', flightId, error: 'Not authorized' }); @@ -54,20 +82,20 @@ export function setupFlightsWebsocket(httpServer) { } try { validateFlightId(flightId); - if (updates.hasOwnProperty('hidden')) { + if (Object.prototype.hasOwnProperty.call(updates, 'hidden')) { return; } - if (updates.callsign) updates.callsign = sanitizeCallsign(updates.callsign); - if (updates.remark) updates.remark = sanitizeString(updates.remark, 500); - if (updates.squawk) updates.squawk = sanitizeSquawk(updates.squawk); - if (updates.clearedFL) updates.clearedFL = sanitizeFlightLevel(updates.clearedFL); - if (updates.cruisingFL) updates.cruisingFL = sanitizeFlightLevel(updates.cruisingFL); - if (updates.runway) updates.runway = sanitizeRunway(updates.runway); - if (updates.stand) updates.stand = sanitizeString(updates.stand, 8); - if (updates.gate) updates.gate = sanitizeString(updates.gate, 8); - if (updates.sid) updates.sid = sanitizeString(updates.sid, 16); - if (updates.star) updates.star = sanitizeString(updates.star, 16); + if (updates.callsign && typeof updates.callsign === 'string') updates.callsign = sanitizeCallsign(updates.callsign); + if (updates.remark && typeof updates.remark === 'string') updates.remark = sanitizeString(updates.remark, 500); + if (updates.squawk && typeof updates.squawk === 'string') updates.squawk = sanitizeSquawk(updates.squawk); + if (updates.clearedFL && typeof updates.clearedFL === 'string') updates.clearedFL = sanitizeFlightLevel(updates.clearedFL); + if (updates.cruisingFL && typeof updates.cruisingFL === 'string') updates.cruisingFL = sanitizeFlightLevel(updates.cruisingFL); + if (updates.runway && typeof updates.runway === 'string') updates.runway = sanitizeRunway(updates.runway); + if (updates.stand && typeof updates.stand === 'string') updates.stand = sanitizeString(updates.stand, 8); + if (updates.gate && typeof updates.gate === 'string') updates.gate = sanitizeString(updates.gate, 8); + if (updates.sid && typeof updates.sid === 'string') updates.sid = sanitizeString(updates.sid, 16); + if (updates.star && typeof updates.star === 'string') updates.star = sanitizeString(updates.star, 16); if (updates.clearance !== undefined) { if (typeof updates.clearance === 'string') { @@ -75,13 +103,13 @@ export function setupFlightsWebsocket(httpServer) { } } - const updatedFlight = await updateFlight(sessionId, flightId, updates); + const updatedFlight = await updateFlight(sessionId, flightId as string, updates); if (updatedFlight) { io.to(sessionId).emit('flightUpdated', updatedFlight); await broadcastToArrivalSessions(updatedFlight); - if (updates.status && updatedFlight.callsign) { + if (updates.status && typeof updates.status === 'string' && updatedFlight.callsign) { const session = await getSessionById(sessionId); const controllerAirport = session?.airport_icao || null; await handleFlightStatusChange(updatedFlight.callsign, updates.status, controllerAirport); @@ -89,22 +117,23 @@ export function setupFlightsWebsocket(httpServer) { } else { socket.emit('flightError', { action: 'update', flightId, error: 'Flight not found' }); } - } catch (error) { - socket.emit('flightError', { action: 'update', flightId, error: 'Failed to update flight' }); + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('Error updating flight:', error); + socket.emit('flightError', { action: 'update', flightId, error: errorMessage || 'Failed to update flight' }); } }); - socket.on('addFlight', async (flightData) => { + socket.on('addFlight', async (flightData: Partial) => { const sessionId = socket.data.sessionId; try { const enhancedFlightData = { ...flightData, - user_id: socket.handshake.auth?.userId, + user_id: (socket.handshake.auth)?.userId, ip_address: socket.handshake.address }; - const flight = await addFlight(sessionId, enhancedFlightData); + const flight = await addFlight(sessionId, enhancedFlightData as AddFlightData); socket.emit('flightAdded', flight); @@ -112,12 +141,12 @@ export function setupFlightsWebsocket(httpServer) { socket.to(sessionId).emit('flightAdded', sanitizedFlight); await broadcastToArrivalSessions(sanitizedFlight); - } catch (error) { + } catch { socket.emit('flightError', { action: 'add', error: 'Failed to add flight' }); } }); - socket.on('deleteFlight', async (flightId) => { + socket.on('deleteFlight', async (flightId: string | number) => { const sessionId = socket.data.sessionId; if (socket.data.role !== 'controller') { socket.emit('flightError', { action: 'delete', flightId, error: 'Not authorized' }); @@ -125,21 +154,26 @@ export function setupFlightsWebsocket(httpServer) { } try { validateFlightId(flightId); - await deleteFlight(sessionId, flightId); + await deleteFlight(sessionId, flightId as string); io.to(sessionId).emit('flightDeleted', { flightId }); - } catch (error) { + } catch { socket.emit('flightError', { action: 'delete', flightId, error: 'Failed to delete flight' }); } }); - socket.on('updateSession', async (updates) => { + socket.on('updateSession', async (updates: SessionUpdateData) => { const sessionId = socket.data.sessionId; if (socket.data.role !== 'controller') { socket.emit('sessionError', { error: 'Not authorized' }); return; } try { - const updatedSession = await updateSession(sessionId, updates); + const dbUpdates: Record = {}; + if (updates.activeRunway !== undefined) { + dbUpdates.active_runway = updates.activeRunway; + } + + const updatedSession = await updateSession(sessionId, dbUpdates); if (updatedSession) { io.to(sessionId).emit('sessionUpdated', { activeRunway: updatedSession.active_runway, @@ -147,12 +181,12 @@ export function setupFlightsWebsocket(httpServer) { } else { socket.emit('sessionError', { error: 'Session not found or update failed' }); } - } catch (error) { + } catch { socket.emit('sessionError', { error: 'Failed to update session' }); } }); - socket.on('issuePDC', async ({ flightId, pdcText, targetPilotUserId }) => { + socket.on('issuePDC', async ({ flightId, pdcText, targetPilotUserId }: PDCData) => { const sessionId = socket.data.sessionId; if (socket.data.role !== 'controller') { socket.emit('flightError', { action: 'issuePDC', flightId, error: 'Not authorized' }); @@ -169,7 +203,7 @@ export function setupFlightsWebsocket(httpServer) { pdc_remarks: sanitizedPDC }; - const updatedFlight = await updateFlight(sessionId, flightId, updates); + const updatedFlight = await updateFlight(sessionId, flightId as string, updates); if (updatedFlight) { io.to(sessionId).emit('flightUpdated', updatedFlight); io.to(sessionId).emit('pdcIssued', { flightId, pdcText: sanitizedPDC, updatedFlight }); @@ -178,12 +212,12 @@ export function setupFlightsWebsocket(httpServer) { } else { socket.emit('flightError', { action: 'issuePDC', flightId, error: 'Flight not found' }); } - } catch (error) { + } catch { socket.emit('flightError', { action: 'issuePDC', flightId, error: 'Failed to issue PDC' }); } }); - socket.on('requestPDC', ({ flightId, callsign, note }) => { + socket.on('requestPDC', ({ flightId, callsign, note }: PDCRequestData) => { const sessionId = socket.data.sessionId; try { if (flightId) { @@ -195,15 +229,15 @@ export function setupFlightsWebsocket(httpServer) { flightId, callsign: sanitizedCallsign, note: sanitizedNote, - requestedBy: socket.handshake.auth?.userId ?? socket.handshake.query?.username ?? null, + requestedBy: (socket.handshake.auth)?.userId ?? socket.handshake.query?.username ?? null, ts: new Date().toISOString() }); - } catch (err) { + } catch{ socket.emit('flightError', { action: 'requestPDC', flightId, error: 'Failed to request PDC' }); } }); - socket.on('contactMe', async ({ flightId, message }) => { + socket.on('contactMe', async ({ flightId, message }: ContactMeData) => { const sessionId = socket.data.sessionId; if (socket.data.role !== 'controller') { socket.emit('flightError', { action: 'contactMe', flightId, error: 'Not authorized' }); @@ -217,15 +251,17 @@ export function setupFlightsWebsocket(httpServer) { for (const session of allSessions) { try { const tableName = `flights_${session.session_id}`; - const result = await flightsPool.query( - `SELECT session_id FROM ${tableName} WHERE id = $1`, - [flightId] - ); - if (result.rows.length > 0) { + const result = await flightsDb + .selectFrom(tableName) + .select('session_id') + .where('id', '=', flightId as string) + .execute(); + + if (result.length > 0) { targetSessionId = session.session_id; break; } - } catch (err) { + } catch { continue; } } @@ -236,7 +272,7 @@ export function setupFlightsWebsocket(httpServer) { message: sanitizedMessage, ts: new Date().toISOString() }); - } catch (err) { + } catch { socket.emit('flightError', { action: 'contactMe', flightId, error: 'Failed to send contact message' }); } }); @@ -247,14 +283,15 @@ export function setupFlightsWebsocket(httpServer) { return io; } -async function broadcastToArrivalSessions(flight) { +async function broadcastToArrivalSessions(flight: ClientFlight): Promise { try { if (!flight.arrival) return; const allSessions = await getAllSessions(); const arrivalSessions = allSessions.filter(session => session.is_pfatc && - session.airport_icao === flight.arrival?.toUpperCase() + typeof flight.arrival === 'string' && + session.airport_icao === flight.arrival.toUpperCase() ); const arrivalsIO = getArrivalsIO(); @@ -263,16 +300,17 @@ async function broadcastToArrivalSessions(flight) { arrivalsIO.to(session.session_id).emit('arrivalUpdated', flight); } } - } catch (error) { + } catch { + // Silent error handling } } -export function getFlightsIO() { +export function getFlightsIO(): SocketIOServer | undefined { return io; } -export function broadcastFlightEvent(sessionId, event, data) { +export function broadcastFlightEvent(sessionId: string, event: string, data: unknown): void { if (io) { io.to(sessionId).emit(event, data); } -} +} \ No newline at end of file diff --git a/server/websockets/overviewWebsocket.js b/server/websockets/overviewWebsocket.ts similarity index 88% rename from server/websockets/overviewWebsocket.js rename to server/websockets/overviewWebsocket.ts index ca0ec58..10b4bc5 100644 --- a/server/websockets/overviewWebsocket.js +++ b/server/websockets/overviewWebsocket.ts @@ -1,11 +1,18 @@ import { Server as SocketServer } from 'socket.io'; -import { getAllSessions, decrypt } from '../db/sessions.js'; +import { getAllSessions } from '../db/sessions.js'; import { getFlightsBySessionWithTime } from '../db/flights.js'; +import { decrypt } from '../utils/encryption.js'; +import type { Server as HTTPServer } from 'http'; -let io; -const activeOverviewClients = new Set(); +let io: SocketServer; +const activeOverviewClients = new Set(); -export function setupOverviewWebsocket(httpServer, sessionUsersIO) { +interface SessionUser { + username?: string; + role?: string; +} + +export function setupOverviewWebsocket(httpServer: HTTPServer, sessionUsersIO: { activeUsers: Map }) { io = new SocketServer(httpServer, { path: '/sockets/overview', cors: { @@ -13,10 +20,9 @@ export function setupOverviewWebsocket(httpServer, sessionUsersIO) { credentials: true }, transports: ['websocket', 'polling'], - allowEIO3: true // Support older clients + allowEIO3: true }); - // Log all connection attempts io.engine.on('connection_error', (err) => { console.error('[Overview Socket] Engine connection error:', err); }); @@ -58,7 +64,7 @@ export function setupOverviewWebsocket(httpServer, sessionUsersIO) { return io; } -async function getOverviewData(sessionUsersIO) { +async function getOverviewData(sessionUsersIO: { activeUsers: Map }) { try { const allSessions = await getAllSessions(); const pfatcSessions = allSessions.filter(session => session.is_pfatc); @@ -126,7 +132,11 @@ async function getOverviewData(sessionUsersIO) { } } - const arrivalsByAirport = {}; + type ArrivalFlight = typeof activeSessions[number]['flights'][number] & { + sessionId: string; + departureAirport: string; + }; + const arrivalsByAirport: { [key: string]: ArrivalFlight[] } = {}; activeSessions.forEach(session => { session.flights.forEach(flight => { if (flight.arrival) { diff --git a/server/websockets/sessionUsersWebsocket.js b/server/websockets/sessionUsersWebsocket.ts similarity index 76% rename from server/websockets/sessionUsersWebsocket.js rename to server/websockets/sessionUsersWebsocket.ts index 8d2060c..f3cc334 100644 --- a/server/websockets/sessionUsersWebsocket.js +++ b/server/websockets/sessionUsersWebsocket.ts @@ -1,32 +1,148 @@ -import { Server as SocketServer } from 'socket.io'; +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/isAdmin.js'; +import { isAdmin } from '../middleware/admin.js'; import { validateSessionId, validateAccessId } from '../utils/validation.js'; +import type { Server as HttpServer } from 'http'; const activeUsers = new Map(); const sessionATISConfigs = new Map(); const atisTimers = new Map(); const fieldEditingStates = new Map(); -export function setupSessionUsersWebsocket(httpServer) { +interface Mention { + [key: string]: unknown; +} + +interface ATISConfig { + icao: string; + landingRunways: string[]; + departingRunways: string[]; + selectedApproaches: string[]; + remarks?: string; + userId?: string; +} + +interface SessionUsersServer extends Server { + sendMentionToUser: (userId: string, mention: Mention) => void; + activeUsers: typeof activeUsers; +} + +async function generateAutoATIS(sessionId: string, config: ATISConfig, io: SocketServer): Promise { + try { + const session = await getSessionById(sessionId); + if (!session?.atis) return; + + const currentAtis = JSON.parse(session.atis); + 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 ''; + + 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}`; + } + + 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}`; + }; + + const approachText = formatApproaches(); + const combinedRemarks = approachText + ? config.remarks + ? `${approachText}... ${config.remarks}` + : approachText + : config.remarks; + + const requestBody = { + ident: nextIdent, + icao: config.icao, + remarks1: combinedRemarks, + remarks2: {}, + landing_runways: config.landingRunways, + departing_runways: config.departingRunways, + 'output-type': 'atis', + override_runways: false + }; + + const response = await fetch(`https://atisgenerator.com/api/v1/airports/${config.icao}/atis`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`External API responded with ${response.status}`); + } + + const data = await response.json() as { + status: string; + message?: string; + data?: { text: string }; + }; + + 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'); + } + + const atisData = { + letter: nextIdent, + text: generatedAtis, + timestamp: new Date().toISOString(), + }; + + await updateSession(sessionId, { atis: JSON.stringify(atisData) }); + + io.to(sessionId).emit('atisUpdate', { + atis: atisData, + updatedBy: 'System', + isAutoGenerated: true + }); + } catch (error) { + console.error('Error in auto ATIS generation:', error); + } +} + +export function setupSessionUsersWebsocket(httpServer: HttpServer) { const io = new SocketServer(httpServer, { path: '/sockets/session-users', cors: { origin: ['http://localhost:5173', 'http://localhost:9901', 'https://control.pfconnect.online', 'https://test.pfconnect.online'], credentials: true } - }); + }) as SessionUsersServer; - const scheduleATISGeneration = (sessionId, config) => { + const scheduleATISGeneration = (sessionId: string, config: unknown) => { if (atisTimers.has(sessionId)) { clearInterval(atisTimers.get(sessionId)); } const timer = setInterval(async () => { try { - await generateAutoATIS(sessionId, config, io); + await generateAutoATIS(sessionId, config as ATISConfig, io); } catch (error) { console.error('Error auto-generating ATIS:', error); } @@ -35,7 +151,7 @@ export function setupSessionUsersWebsocket(httpServer) { atisTimers.set(sessionId, timer); }; - const broadcastFieldEditingStates = (sessionId) => { + const broadcastFieldEditingStates = (sessionId: string) => { const sessionEditingStates = fieldEditingStates.get(sessionId); if (sessionEditingStates) { const editingArray = Array.from(sessionEditingStates.values()); @@ -43,7 +159,18 @@ export function setupSessionUsersWebsocket(httpServer) { } }; - const addFieldEditingState = (sessionId, user, flightId, fieldName) => { + interface User { + userId: string; + username: string; + avatar?: string | null; + } + + const addFieldEditingState = ( + sessionId: string, + user: User, + flightId: string, + fieldName: string + ): void => { if (!fieldEditingStates.has(sessionId)) { fieldEditingStates.set(sessionId, new Map()); } @@ -63,7 +190,12 @@ export function setupSessionUsersWebsocket(httpServer) { broadcastFieldEditingStates(sessionId); }; - const removeFieldEditingState = (sessionId, userId, flightId, fieldName) => { + const removeFieldEditingState = ( + sessionId: string, + userId: string, + flightId: string, + fieldName: string + ): void => { const sessionStates = fieldEditingStates.get(sessionId); if (sessionStates) { const fieldKey = `${flightId}-${fieldName}`; @@ -97,9 +229,21 @@ export function setupSessionUsersWebsocket(httpServer) { io.on('connection', async (socket) => { try { - const sessionId = validateSessionId(socket.handshake.query.sessionId); - const accessId = validateAccessId(socket.handshake.query.accessId); - const user = JSON.parse(socket.handshake.query.user); + const sessionId = validateSessionId( + Array.isArray(socket.handshake.query.sessionId) + ? socket.handshake.query.sessionId[0] + : socket.handshake.query.sessionId + ); + const accessId = validateAccessId( + Array.isArray(socket.handshake.query.accessId) + ? socket.handshake.query.accessId[0] + : socket.handshake.query.accessId + ); + const user = JSON.parse( + Array.isArray(socket.handshake.query.user) + ? socket.handshake.query.user[0] + : socket.handshake.query.user || '{}' + ); socket.data.sessionId = sessionId; @@ -112,12 +256,18 @@ export function setupSessionUsersWebsocket(httpServer) { if (!activeUsers.has(sessionId)) { activeUsers.set(sessionId, []); } - const users = activeUsers.get(sessionId); + const users: Array<{ id: string }> = activeUsers.get(sessionId); // Fetch user roles - let userRoles = []; + let userRoles: Array<{ id: number; name: string; color: string; icon: string; priority: number }> = []; try { - userRoles = await getUserRoles(user.userId); + userRoles = (await getUserRoles(user.userId)).map(role => ({ + id: role.id, + name: role.name, + color: role.color ?? '#000000', + icon: role.icon ?? '', + priority: role.priority ?? 0 + })); } catch (error) { console.error('Error fetching user roles:', error); } @@ -141,7 +291,7 @@ export function setupSessionUsersWebsocket(httpServer) { position: socket.handshake.query.position || 'POSITION', roles: userRoles }; - const existingUserIndex = users.findIndex(u => u.id === sessionUser.id); + const existingUserIndex = users.findIndex((u: { id: string }) => u.id === sessionUser.id); if (existingUserIndex === -1) { users.push(sessionUser); } else { @@ -198,7 +348,7 @@ export function setupSessionUsersWebsocket(httpServer) { socket.on('positionChange', ({ position }) => { const users = activeUsers.get(sessionId); if (users) { - const userIndex = users.findIndex(u => u.id === user.userId); + const userIndex = users.findIndex((u: { id: string }) => u.id === user.userId); if (userIndex !== -1) { users[userIndex].position = position; io.to(sessionId).emit('sessionUsersUpdate', users); @@ -209,7 +359,7 @@ export function setupSessionUsersWebsocket(httpServer) { socket.on('disconnect', () => { const users = activeUsers.get(sessionId); if (users) { - const index = users.findIndex(u => u.id === user.userId); + const index = users.findIndex((u: { id: string }) => u.id === user.userId); if (index !== -1) { users.splice(index, 1); } @@ -236,12 +386,11 @@ export function setupSessionUsersWebsocket(httpServer) { } }); } catch (error) { - console.error('Invalid session or access ID:', error.message); - socket.disconnect(true); + console.error('Error in websocket connection:', error); } }); - io.sendMentionToUser = (userId, mention) => { + io.sendMentionToUser = (userId: string, mention: unknown) => { io.to(`user-${userId}`).emit('chatMention', mention); }; @@ -250,99 +399,6 @@ export function setupSessionUsersWebsocket(httpServer) { return io; } -export function getActiveUsers() { +export function getActiveUsers(): typeof activeUsers { return activeUsers; -} - -async function generateAutoATIS(sessionId, config, io) { - try { - const session = await getSessionById(sessionId); - if (!session?.atis) return; - - const currentAtis = JSON.parse(session.atis); - 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 ''; - - 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}`; - } - - 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}`; - }; - - const approachText = formatApproaches(); - const combinedRemarks = approachText - ? config.remarks - ? `${approachText}... ${config.remarks}` - : approachText - : config.remarks; - - const requestBody = { - ident: nextIdent, - icao: config.icao, - remarks1: combinedRemarks, - remarks2: {}, - landing_runways: config.landingRunways, - departing_runways: config.departingRunways, - 'output-type': 'atis', - override_runways: false - }; - - const response = await fetch(`https://atisgenerator.com/api/v1/airports/${config.icao}/atis`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - throw new Error(`External API responded with ${response.status}`); - } - - const data = await response.json(); - - 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'); - } - - const atisData = { - letter: nextIdent, - text: generatedAtis, - timestamp: new Date().toISOString(), - }; - - await updateSession(sessionId, { atis: atisData }); - - io.to(sessionId).emit('atisUpdate', { - atis: atisData, - updatedBy: 'System', - isAutoGenerated: true - }); - } catch (error) { - console.error('Error in auto ATIS generation:', error); - } } \ No newline at end of file diff --git a/src/components/tools/Toolbar.tsx b/src/components/tools/Toolbar.tsx index 6112c3f..b6aa91b 100644 --- a/src/components/tools/Toolbar.tsx +++ b/src/components/tools/Toolbar.tsx @@ -1,28 +1,28 @@ import { useState, useEffect, useRef } from 'react'; import { - Info, - MessageCircle, - Settings, - Wifi, - WifiOff, - RefreshCw, - PlaneLanding, - PlaneTakeoff, - Star, - Shield, - Wrench, - Award, - Crown, - Trophy, - Zap, - Target, - Heart, - Sparkles, - Flame, - TrendingUp, - FlaskConical, - Braces, - Radio, + Info, + MessageCircle, + Settings, + Wifi, + WifiOff, + RefreshCw, + PlaneLanding, + PlaneTakeoff, + Star, + Shield, + Wrench, + Award, + Crown, + Trophy, + Zap, + Target, + Heart, + Sparkles, + Flame, + TrendingUp, + FlaskConical, + Braces, + Radio, } from 'lucide-react'; import { io } from 'socket.io-client'; import { createSessionUsersSocket } from '../../sockets/sessionUsersSocket'; @@ -30,9 +30,9 @@ import { fetchSession } from '../../utils/fetch/sessions'; import { useAuth } from '../../hooks/auth/useAuth'; import { playSoundWithSettings } from '../../utils/playSound'; import type { - Position, - SessionUser, - ChatMention as SessionChatMention, + Position, + SessionUser, + ChatMention as SessionChatMention, } from '../../types/session'; import type { ChatMention } from '../../types/chats'; import WindDisplay from './WindDisplay'; @@ -44,284 +44,309 @@ import ChatSidebar from './ChatSidebar'; import ATIS from './ATIS'; interface ToolbarProps { - sessionId?: string; - accessId?: string; - icao: string | null; - activeRunway?: string; - onRunwayChange?: (runway: string) => void; - isPFATC?: boolean; - currentView?: 'departures' | 'arrivals'; - onViewChange?: (view: 'departures' | 'arrivals') => void; - showViewTabs?: boolean; - position: Position; - onPositionChange: (position: Position) => void; - onContactAcarsClick?: () => void; + sessionId?: string; + accessId?: string; + icao: string | null; + activeRunway?: string; + onRunwayChange?: (runway: string) => void; + isPFATC?: boolean; + currentView?: 'departures' | 'arrivals'; + onViewChange?: (view: 'departures' | 'arrivals') => void; + showViewTabs?: boolean; + position: Position; + onPositionChange: (position: Position) => void; + onContactAcarsClick?: () => void; } const getIconComponent = (iconName: string) => { - const icons: Record< - string, - React.ComponentType<{ className?: string; style?: React.CSSProperties }> - > = { - Star, - Shield, - Wrench, - Award, - Crown, - Trophy, - Zap, - Target, - Heart, - Sparkles, - Flame, - TrendingUp, - FlaskConical, - Braces, - }; - return icons[iconName] || Star; + const icons: Record< + string, + React.ComponentType<{ className?: string; style?: React.CSSProperties }> + > = { + Star, + Shield, + Wrench, + Award, + Crown, + Trophy, + Zap, + Target, + Heart, + Sparkles, + Flame, + TrendingUp, + FlaskConical, + Braces, + }; + return icons[iconName] || Star; }; const getHighestRole = ( - roles?: Array<{ - id: number; - name: string; - color: string; - icon: string; - priority: number; - }> + roles?: Array<{ + id: number; + name: string; + color: string; + icon: string; + priority: number; + }> ) => { - if (!roles || roles.length === 0) return null; - return roles.reduce((highest, current) => - current.priority > highest.priority ? current : highest - ); + if (!roles || roles.length === 0) return null; + return roles.reduce((highest, current) => + current.priority > highest.priority ? current : highest + ); }; export default function Toolbar({ - icao, - sessionId, - accessId, - activeRunway, - onRunwayChange, - isPFATC = false, - currentView = 'departures', - onViewChange, - showViewTabs = true, - position, - onPositionChange, - onContactAcarsClick, + icao, + sessionId, + accessId, + activeRunway, + onRunwayChange, + isPFATC = false, + currentView = 'departures', + onViewChange, + showViewTabs = true, + position, + onPositionChange, + onContactAcarsClick, }: ToolbarProps) { - const [runway, setRunway] = useState(activeRunway || ''); - const [chatOpen, setChatOpen] = useState(false); - const [atisOpen, setAtisOpen] = useState(false); - const [activeUsers, setActiveUsers] = useState([]); - const [unreadMentions, setUnreadMentions] = useState([]); - const [connectionStatus, setConnectionStatus] = useState< - 'Connected' | 'Reconnecting' | 'Disconnected' - >('Disconnected'); - const [atisLetter, setAtisLetter] = useState('A'); - const [atisFlash, setAtisFlash] = useState(false); - const socketRef = useRef | null>(null); - const { user } = useAuth(); - - const handleRunwayChange = (selectedRunway: string) => { - setRunway(selectedRunway); - if (onRunwayChange) { - onRunwayChange(selectedRunway); - } - }; - - const handlePositionChange = (selectedPosition: string) => { - onPositionChange(selectedPosition as Position); - }; - - const handleViewChange = (view: 'departures' | 'arrivals') => { - if (onViewChange) { - onViewChange(view); - } - }; - - const getAvatarUrl = (avatar: string | null) => { - if (!avatar) return '/assets/app/default/avatar.webp'; - return avatar; - }; - - const handleMentionReceived = (mention: SessionChatMention) => { - const chatMention: ChatMention = { - messageId: Number(mention.id), - mentionedUserId: mention.userId, - mentionerUsername: mention.username, - message: mention.message, - sessionId: mention.sessionId, - timestamp: mention.timestamp.toString(), - }; - setUnreadMentions((prev) => [...prev, chatMention]); - if (user) { - playSoundWithSettings( - 'chatNotificationSound', - user.settings, - 0.7 - ).catch((error) => { - console.warn('Failed to play chat notification sound:', error); - }); - } - }; - - const handleChatSidebarMention = (mention: ChatMention) => { - setUnreadMentions((prev) => [...prev, mention]); - if (user) { - playSoundWithSettings( - 'chatNotificationSound', - user.settings, - 0.7 - ).catch((error) => { - console.warn('Failed to play chat notification sound:', error); - }); - } - }; - - type AtisData = { - letter?: string; - updatedBy?: string; - isAutoGenerated?: boolean; - }; - - const handleAtisUpdate = (atisData: AtisData) => { - if (atisData.letter) { - setAtisLetter(atisData.letter); - } - }; - - const handleAtisUpdateFromSocket = (data: { - atis?: AtisData; - updatedBy?: string; - isAutoGenerated?: boolean; - }) => { - if (data.atis?.letter) { - setAtisLetter(data.atis.letter); - - if (data.updatedBy !== user?.username || data.isAutoGenerated) { - setAtisFlash(true); - setTimeout(() => setAtisFlash(false), 30000); + const [runway, setRunway] = useState(activeRunway || ''); + const [chatOpen, setChatOpen] = useState(false); + const [atisOpen, setAtisOpen] = useState(false); + const [activeUsers, setActiveUsers] = useState([]); + const [unreadMentions, setUnreadMentions] = useState([]); + const [connectionStatus, setConnectionStatus] = useState< + 'Connected' | 'Reconnecting' | 'Disconnected' + >('Disconnected'); + const [atisLetter, setAtisLetter] = useState('A'); + const [atisFlash, setAtisFlash] = useState(false); + const socketRef = useRef | null>(null); + const { user } = useAuth(); + + // Load initial ATIS data on mount or when sessionId/accessId changes + useEffect(() => { + const loadInitialAtisData = async () => { + if (!sessionId || !accessId) return; + try { + const { fetchSession } = await import('../../utils/fetch/sessions'); + const session = await fetchSession(sessionId, accessId); + if (session.atis) { + let atisObj = session.atis; + if (typeof atisObj === 'string') { + try { + atisObj = JSON.parse(atisObj); + } catch { + atisObj = { + letter: 'A', + text: '', + timestamp: new Date().toISOString(), + }; } + } + if (atisObj && atisObj.letter) { + setAtisLetter(atisObj.letter); + } } + } catch { + console.error('Error loading initial ATIS data'); + } }; - - const handleAtisOpen = () => { - setAtisOpen(true); - setChatOpen(false); - setAtisFlash(false); + loadInitialAtisData(); + }, [sessionId, accessId]); + + const handleRunwayChange = (selectedRunway: string) => { + setRunway(selectedRunway); + if (onRunwayChange) { + onRunwayChange(selectedRunway); + } + }; + + const handlePositionChange = (selectedPosition: string) => { + onPositionChange(selectedPosition as Position); + }; + + const handleViewChange = (view: 'departures' | 'arrivals') => { + if (onViewChange) { + onViewChange(view); + } + }; + + const getAvatarUrl = (avatar: string | null) => { + if (!avatar) return '/assets/app/default/avatar.webp'; + return avatar; + }; + + const handleMentionReceived = (mention: SessionChatMention) => { + const chatMention: ChatMention = { + messageId: Number(mention.id), + mentionedUserId: mention.userId, + mentionerUsername: mention.username, + message: mention.message, + sessionId: mention.sessionId, + timestamp: mention.timestamp.toString(), }; - - const handleAtisClose = () => { - setAtisOpen(false); - }; - - const handleChatOpen = () => { - setChatOpen(true); - setAtisOpen(false); - }; - - const handleChatClose = () => { - setChatOpen(false); - }; - - useEffect(() => { - if (!sessionId || !accessId || !user) return; - - socketRef.current = createSessionUsersSocket( - sessionId, - accessId, - { - userId: user.userId, - username: user.username, - avatar: user.avatar, - }, - (users: SessionUser[]) => setActiveUsers(users), - () => setConnectionStatus('Connected'), - () => setConnectionStatus('Disconnected'), - () => setConnectionStatus('Reconnecting'), - () => setConnectionStatus('Connected'), - handleMentionReceived, - undefined, - position - ); - - if (socketRef.current) { - socketRef.current.on('atisUpdate', handleAtisUpdateFromSocket); - } - - return () => { - if (socketRef.current) { - socketRef.current.off('atisUpdate', handleAtisUpdateFromSocket); - socketRef.current.disconnect(); - } - }; - }, [sessionId, accessId, user]); - - // Update position without reconnecting socket - useEffect(() => { - if (socketRef.current) { - socketRef.current.emit('positionChange', position); - } - }, [position]); - - useEffect(() => { - if (activeRunway !== undefined) { - setRunway(activeRunway); + setUnreadMentions((prev) => [...prev, chatMention]); + if (user) { + playSoundWithSettings('chatNotificationSound', user.settings, 0.7).catch( + (error) => { + console.warn('Failed to play chat notification sound:', error); } - }, [activeRunway]); - - useEffect(() => { - if (chatOpen) { - setUnreadMentions([]); + ); + } + }; + + const handleChatSidebarMention = (mention: ChatMention) => { + setUnreadMentions((prev) => [...prev, mention]); + if (user) { + playSoundWithSettings('chatNotificationSound', user.settings, 0.7).catch( + (error) => { + console.warn('Failed to play chat notification sound:', error); } - }, [chatOpen]); + ); + } + }; + + type AtisData = { + letter?: string; + updatedBy?: string; + isAutoGenerated?: boolean; + }; + + const handleAtisUpdate = (atisData: AtisData) => { + if (atisData.letter) { + setAtisLetter(atisData.letter); + } + }; + + const handleAtisUpdateFromSocket = (data: { + atis?: AtisData; + updatedBy?: string; + isAutoGenerated?: boolean; + }) => { + if (data.atis?.letter) { + setAtisLetter(data.atis.letter); + + if (data.updatedBy !== user?.username || data.isAutoGenerated) { + setAtisFlash(true); + setTimeout(() => setAtisFlash(false), 30000); + } + } + }; + + const handleAtisOpen = () => { + setAtisOpen(true); + setChatOpen(false); + setAtisFlash(false); + }; + + const handleAtisClose = () => { + setAtisOpen(false); + }; + + const handleChatOpen = () => { + setChatOpen(true); + setAtisOpen(false); + }; + + const handleChatClose = () => { + setChatOpen(false); + }; + + useEffect(() => { + if (!sessionId || !accessId || !user) return; + + socketRef.current = createSessionUsersSocket( + sessionId, + accessId, + { + userId: user.userId, + username: user.username, + avatar: user.avatar, + }, + (users: SessionUser[]) => setActiveUsers(users), + () => setConnectionStatus('Connected'), + () => setConnectionStatus('Disconnected'), + () => setConnectionStatus('Reconnecting'), + () => setConnectionStatus('Connected'), + handleMentionReceived, + undefined, + position + ); - // Add this useEffect to load initial ATIS data - useEffect(() => { - const loadInitialAtisData = async () => { - if (!sessionId || !accessId) return; + if (socketRef.current) { + socketRef.current.on('atisUpdate', handleAtisUpdateFromSocket); + } - try { - const sessionData = await fetchSession(sessionId, accessId); - if (sessionData?.atis?.letter) { - setAtisLetter(sessionData.atis.letter); - } - } catch (error) { - console.error('Error loading initial ATIS data:', error); - } - }; - - loadInitialAtisData(); - }, [sessionId, accessId]); - - const getStatusColor = () => { - switch (connectionStatus) { - case 'Connected': - return 'text-green-500'; - case 'Reconnecting': - return 'text-yellow-500'; - case 'Disconnected': - return 'text-red-500'; - } + return () => { + if (socketRef.current) { + socketRef.current.off('atisUpdate', handleAtisUpdateFromSocket); + socketRef.current.disconnect(); + } }; - - const getStatusIcon = () => { - switch (connectionStatus) { - case 'Connected': - return ; - case 'Reconnecting': - return ( - - ); - case 'Disconnected': - return ; + }, [sessionId, accessId, user]); + + // Update position without reconnecting socket + useEffect(() => { + if (socketRef.current) { + socketRef.current.emit('positionChange', position); + } + }, [position]); + + useEffect(() => { + if (activeRunway !== undefined) { + setRunway(activeRunway); + } + }, [activeRunway]); + + useEffect(() => { + if (chatOpen) { + setUnreadMentions([]); + } + }, [chatOpen]); + + // Add this useEffect to load initial ATIS data + useEffect(() => { + const loadInitialAtisData = async () => { + if (!sessionId || !accessId) return; + + try { + const sessionData = await fetchSession(sessionId, accessId); + if (sessionData?.atis?.letter) { + setAtisLetter(sessionData.atis.letter); } + } catch (error) { + console.error('Error loading initial ATIS data:', error); + } }; - return ( -
; + case 'Reconnecting': + return ; + case 'Disconnected': + return ; + } + }; + + return ( +
-
- - -
- -
-
- {activeUsers.slice(0, 5).map((user, index) => { - const highestRole = getHighestRole(user.roles); - const RoleIcon = highestRole - ? getIconComponent(highestRole.icon) - : null; - - return ( -
- {user.username} { - e.currentTarget.src = - '/assets/app/default/avatar.webp'; - }} - style={{ - border: `2px solid ${ - highestRole?.color || '#ffffff' - }`, - }} - /> - {/* Combined tooltip for username and role */} -
-
- - {user.username} - - {highestRole && RoleIcon && ( - <> - - • - - - - {highestRole.name} - - - )} -
-
-
- ); - })} - {activeUsers.length > 5 && ( -
+ + +
+ +
+
+ {activeUsers.slice(0, 5).map((user, index) => { + const highestRole = getHighestRole(user.roles); + const RoleIcon = highestRole + ? getIconComponent(highestRole.icon) + : null; + + return ( +
+ {user.username} { + e.currentTarget.src = '/assets/app/default/avatar.webp'; + }} + style={{ + border: `2px solid ${highestRole?.color || '#ffffff'}`, + }} + /> + {/* Combined tooltip for username and role */} +
+
+ + {user.username} + + {highestRole && RoleIcon && ( + <> + • + + - +{activeUsers.length - 5} -
- )} -
-
- {icao && ( - - {icao} + {highestRole.name} + )} - {getStatusIcon()} - - {connectionStatus} - +
+
+ ); + })} + {activeUsers.length > 5 && ( +
+ +{activeUsers.length - 5}
+ )} +
+
+ {icao && ( + {icao} + )} + {getStatusIcon()} + + {connectionStatus} + +
+
-
+ {isPFATC && showViewTabs && ( +
+ - -
- )} - - - - - - - - - - {isPFATC && ( - - )} - - - - - - + + + +
+ )} + + + + + + + +
- ); + )} + + + {isPFATC && ( + + )} + + + + + + +
+
+ ); } diff --git a/src/pages/Flights.tsx b/src/pages/Flights.tsx index 581a356..a5e3c68 100644 --- a/src/pages/Flights.tsx +++ b/src/pages/Flights.tsx @@ -13,8 +13,8 @@ import { useSettings } from '../hooks/settings/useSettings'; import type { Flight } from '../types/flight'; import type { Position } from '../types/session'; import type { - ArrivalsTableColumnSettings, - DepartureTableColumnSettings, + ArrivalsTableColumnSettings, + DepartureTableColumnSettings, } from '../types/settings'; import type { FieldEditingState } from '../sockets/sessionUsersSocket'; import Navbar from '../components/Navbar'; @@ -31,1100 +31,1038 @@ import Loader from '../components/common/Loader'; const API_BASE_URL = import.meta.env.VITE_SERVER_URL; interface SessionData { - sessionId: string; - airportIcao: string; - activeRunway?: string; - atis?: unknown; - isPFATC: boolean; + sessionId: string; + airportIcao: string; + activeRunway?: string; + atis?: unknown; + isPFATC: boolean; } interface AvailableImage { - filename: string; - path: string; - extension: string; + filename: string; + path: string; + extension: string; } export default function Flights() { - const { sessionId } = useParams<{ sessionId?: string }>(); - const [searchParams] = useSearchParams(); - const accessId = searchParams.get('accessId') ?? undefined; - const isMobile = useMediaQuery({ maxWidth: 1000 }); - - const [accessError, setAccessError] = useState(null); - const [validatingAccess, setValidatingAccess] = useState(true); - const [session, setSession] = useState(null); - const [flights, setFlights] = useState([]); - const [loading, setLoading] = useState(true); - const [initialLoadComplete, setInitialLoadComplete] = useState(false); - const [flashingPDCIds, setFlashingPDCIds] = useState>( - new Set() - ); - const [flightsSocket, setFlightsSocket] = useState | null>(null); - const [arrivalsSocket, setArrivalsSocket] = useState | null>(null); - const [lastSessionId, setLastSessionId] = useState(null); - const [availableImages, setAvailableImages] = useState( - [] - ); - const [startupSoundPlayed, setStartupSoundPlayed] = useState(false); - const { user } = useAuth(); - const { settings } = useSettings(); - const [currentView, setCurrentView] = useState<'departures' | 'arrivals'>( - 'departures' - ); - const [externalArrivals, setExternalArrivals] = 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< - Flight[] - >([]); - const [customArrivalFlights, setCustomArrivalFlights] = useState( - [] - ); - const [showAddDepartureModal, setShowAddDepartureModal] = useState(false); - const [showAddArrivalModal, setShowAddArrivalModal] = useState(false); - const [showContactAcarsModal, setShowContactAcarsModal] = useState(false); - const [activeAcarsFlights, setActiveAcarsFlights] = useState< - Set - >(new Set()); - const [activeAcarsFlightData, setActiveAcarsFlightData] = useState< - Flight[] - >([]); - - const userRef = useRef(user); - const settingsRef = useRef(settings); - const flightsSocketConnectedRef = useRef(false); - const arrivalsSocketConnectedRef = useRef(false); - const sessionUsersSocketConnectedRef = useRef(false); - - useEffect(() => { - userRef.current = user; - settingsRef.current = settings; - }, [user, settings]); - - 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); - }); - } - }, []); - - type AtisData = { - letter?: string; - updatedBy?: string; - isAutoGenerated?: boolean; + const { sessionId } = useParams<{ sessionId?: string }>(); + const [searchParams] = useSearchParams(); + const accessId = searchParams.get('accessId') ?? undefined; + const isMobile = useMediaQuery({ maxWidth: 1000 }); + + const [accessError, setAccessError] = useState(null); + const [validatingAccess, setValidatingAccess] = useState(true); + const [session, setSession] = useState(null); + const [flights, setFlights] = useState([]); + const [loading, setLoading] = useState(true); + const [initialLoadComplete, setInitialLoadComplete] = useState(false); + const [flashingPDCIds, setFlashingPDCIds] = useState>(new Set()); + const [flightsSocket, setFlightsSocket] = useState | null>(null); + const [arrivalsSocket, setArrivalsSocket] = useState | null>(null); + const [lastSessionId, setLastSessionId] = useState(null); + const [availableImages, setAvailableImages] = useState([]); + const [startupSoundPlayed, setStartupSoundPlayed] = useState(false); + const { user } = useAuth(); + const { settings } = useSettings(); + const [currentView, setCurrentView] = useState<'departures' | 'arrivals'>( + 'departures' + ); + const [externalArrivals, setExternalArrivals] = 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< + Flight[] + >([]); + const [customArrivalFlights, setCustomArrivalFlights] = useState( + [] + ); + const [showAddDepartureModal, setShowAddDepartureModal] = useState(false); + const [showAddArrivalModal, setShowAddArrivalModal] = useState(false); + const [showContactAcarsModal, setShowContactAcarsModal] = useState(false); + const [activeAcarsFlights, setActiveAcarsFlights] = useState< + Set + >(new Set()); + const [activeAcarsFlightData, setActiveAcarsFlightData] = useState( + [] + ); + + const userRef = useRef(user); + const settingsRef = useRef(settings); + const flightsSocketConnectedRef = useRef(false); + const arrivalsSocketConnectedRef = useRef(false); + const sessionUsersSocketConnectedRef = useRef(false); + + useEffect(() => { + userRef.current = user; + settingsRef.current = settings; + }, [user, settings]); + + 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); + }); + } + }, []); + + type AtisData = { + letter?: string; + updatedBy?: string; + isAutoGenerated?: boolean; + }; + + const handleAtisUpdateFromSocket = (data: { + atis?: AtisData; + updatedBy?: string; + isAutoGenerated?: boolean; + }) => { + if (data.atis?.letter) { + console.log('ATIS updated:', data); + } + }; + + useEffect(() => { + const loadImages = async () => { + try { + const data = await fetchBackgrounds(); + setAvailableImages(data); + } catch (error) { + console.error('Error loading available images:', error); + } }; + loadImages(); + }, []); + + useEffect(() => { + if (!sessionId) { + setAccessError('Session ID is required'); + setValidatingAccess(false); + return; + } - const handleAtisUpdateFromSocket = (data: { - atis?: AtisData; - updatedBy?: string; - isAutoGenerated?: boolean; - }) => { - if (data.atis?.letter) { - console.log('ATIS updated:', data); - } - }; + if (!accessId) { + setAccessError('Access ID is required. Please use a valid session link.'); + setValidatingAccess(false); + return; + } - useEffect(() => { - const loadImages = async () => { - try { - const data = await fetchBackgrounds(); - setAvailableImages(data); - } catch (error) { - console.error('Error loading available images:', error); - } - }; - loadImages(); - }, []); - - useEffect(() => { - if (!sessionId) { - setAccessError('Session ID is required'); - setValidatingAccess(false); - return; + setValidatingAccess(false); + setAccessError(null); + }, [sessionId, accessId]); + + useEffect(() => { + 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'); + } else { + setAccessError('Unable to access session'); } - - if (!accessId) { - setAccessError( - 'Access ID is required. Please use a valid session link.' - ); - setValidatingAccess(false); - return; + return null; + }), + fetchFlights(sessionId).catch((error) => { + console.error('Error fetching flights:', error); + return []; + }), + ]) + .then(([sessionData, flightsData]) => { + if (sessionData) { + setSession(sessionData); } - - setValidatingAccess(false); - setAccessError(null); - }, [sessionId, accessId]); - - useEffect(() => { - 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'); - } else { - setAccessError('Unable to access session'); - } - return null; - }), - fetchFlights(sessionId).catch((error) => { - console.error('Error fetching flights:', error); - return []; - }), - ]) - .then(([sessionData, flightsData]) => { - if (sessionData) { - setSession(sessionData); - } - setFlights(flightsData); - setInitialLoadComplete(true); - if (!startupSoundPlayed && user && settings) { - playSoundWithSettings('startupSound', settings, 0.7).catch( - (error) => { - console.warn( - 'Failed to play session startup sound:', - error - ); - } - ); - setStartupSoundPlayed(true); - } - }) - .finally(() => { - setLoading(false); - }); - }, [ - sessionId, - accessId, - lastSessionId, - initialLoadComplete, - startupSoundPlayed, - user, - settings, - accessError, - ]); - - useEffect(() => { - if (!sessionId || !accessId || !initialLoadComplete || accessError) - return; - - if (flightsSocketConnectedRef.current) return; - - flightsSocketConnectedRef.current = true; - - const socket = createFlightsSocket( - sessionId, - accessId, - // onFlightUpdated - (flight: Flight) => { - setFlights((prev) => - prev.map((f) => (f.id === flight.id ? flight : f)) - ); - }, - // onFlightAdded - (flight: Flight) => { - setFlights((prev) => [...prev, flight]); - const currentSettings = settingsRef.current; - if (currentSettings) { - playSoundWithSettings( - 'newStripSound', - currentSettings, - 0.7 - ).catch((error) => { - console.warn('Failed to play new strip sound:', error); - }); - } - }, - // onFlightDeleted - ({ flightId }) => { - setFlights((prev) => - prev.filter((flight) => flight.id !== flightId) - ); - }, - // onFlightError + setFlights(flightsData); + setInitialLoadComplete(true); + if (!startupSoundPlayed && user && settings) { + playSoundWithSettings('startupSound', settings, 0.7).catch( (error) => { - console.error('Flight websocket error:', error); + console.warn('Failed to play session startup sound:', error); } - ); - socket.socket.on('sessionUpdated', (updates) => { - setSession((prev) => (prev ? { ...prev, ...updates } : null)); - }); - setFlightsSocket(socket); - return () => { - flightsSocketConnectedRef.current = false; - socket.socket.disconnect(); - }; - }, [sessionId, accessId, initialLoadComplete]); - const handleIssuePDC = async ( - flightId: string | number, - pdcText: string - ) => { - if (!flightsSocket?.socket) { - console.warn('handleIssuePDC: no flights socket available'); - throw new Error('No flights socket'); + ); + setStartupSoundPlayed(true); } - flightsSocket.socket.emit('issuePDC', { flightId, pdcText }); - }; - - useEffect(() => { - if (!showContactAcarsModal) return; - - const fetchActiveAcars = async () => { - try { - const response = await fetch( - `${import.meta.env.VITE_SERVER_URL}/api/flights/acars/active`, - { - credentials: 'include', - } - ); - - if (response.ok) { - const flights: Flight[] = await response.json(); - setActiveAcarsFlightData(flights); - setActiveAcarsFlights(new Set(flights.map((f) => f.id))); - } - } catch { - // Ignore errors + }) + .finally(() => { + setLoading(false); + }); + }, [ + sessionId, + accessId, + lastSessionId, + initialLoadComplete, + startupSoundPlayed, + user, + settings, + accessError, + ]); + + useEffect(() => { + if (!sessionId || !accessId || !initialLoadComplete || accessError) return; + + if (flightsSocketConnectedRef.current) return; + + flightsSocketConnectedRef.current = true; + + const socket = createFlightsSocket( + sessionId, + accessId, + // onFlightUpdated + (flight: Flight) => { + setFlights((prev) => + prev.map((f) => (f.id === flight.id ? flight : f)) + ); + }, + // onFlightAdded + (flight: Flight) => { + setFlights((prev) => [...prev, flight]); + const currentSettings = settingsRef.current; + if (currentSettings) { + playSoundWithSettings('newStripSound', currentSettings, 0.7).catch( + (error) => { + console.warn('Failed to play new strip sound:', error); } - }; - - fetchActiveAcars(); - }, [showContactAcarsModal]); - - const handleSendContact = async ( - flightId: string | number, - message: string - ) => { - if (!flightsSocket?.socket) { - throw new Error('No flights socket'); + ); } - flightsSocket.socket.emit('contactMe', { flightId, message }); + }, + // onFlightDeleted + ({ flightId }) => { + setFlights((prev) => prev.filter((flight) => flight.id !== flightId)); + }, + // onFlightError + (error) => { + console.error('Flight websocket error:', error); + } + ); + socket.socket.on('sessionUpdated', (updates) => { + setSession((prev) => (prev ? { ...prev, ...updates } : null)); + }); + setFlightsSocket(socket); + return () => { + flightsSocketConnectedRef.current = false; + socket.socket.disconnect(); }; - - useEffect(() => { - if ( - !sessionId || - !accessId || - !initialLoadComplete || - !session?.isPFATC - ) - return; - - if (arrivalsSocketConnectedRef.current) return; - - arrivalsSocketConnectedRef.current = true; - - const socket = createArrivalsSocket( - sessionId, - accessId, - // onArrivalUpdated - (flight: Flight) => { - setExternalArrivals((prev) => - prev.map((f) => (f.id === flight.id ? flight : f)) - ); - }, - // onArrivalError - (error) => { - console.error('Arrival websocket error:', error); - }, - // onInitialExternalArrivals - (flights: Flight[]) => { - console.log('Received initial external arrivals:', flights); - setExternalArrivals(flights); - } - ); - setArrivalsSocket(socket); - return () => { - arrivalsSocketConnectedRef.current = false; - socket.socket.disconnect(); - }; - }, [sessionId, accessId, initialLoadComplete, session?.isPFATC]); - - useEffect(() => { - if (!sessionId || !accessId || !user) return; - - if (sessionUsersSocketConnectedRef.current) return; - - sessionUsersSocketConnectedRef.current = true; - - const userId = user.userId; - const username = user.username; - const avatar = user.avatar; - - const socket = createSessionUsersSocket( - sessionId, - accessId, - { - userId, - username, - avatar, - }, - () => {}, - () => {}, - () => {}, - () => {}, - () => {}, - handleMentionReceived, - (editingStates: FieldEditingState[]) => - setFieldEditingStates(editingStates), - 'ALL' + }, [sessionId, accessId, initialLoadComplete]); + const handleIssuePDC = async (flightId: string | number, pdcText: string) => { + if (!flightsSocket?.socket) { + console.warn('handleIssuePDC: no flights socket available'); + throw new Error('No flights socket'); + } + flightsSocket.socket.emit('issuePDC', { flightId, pdcText }); + }; + + useEffect(() => { + if (!showContactAcarsModal) return; + + const fetchActiveAcars = async () => { + try { + const response = await fetch( + `${import.meta.env.VITE_SERVER_URL}/api/flights/acars/active`, + { + credentials: 'include', + } ); - setSessionUsersSocket(socket); - - if (socket) { - socket.on('atisUpdate', handleAtisUpdateFromSocket); - } - - return () => { - sessionUsersSocketConnectedRef.current = false; - if (socket) { - socket.off('atisUpdate', handleAtisUpdateFromSocket); - socket.disconnect(); - } - }; - }, [ - sessionId, - accessId, - user?.userId, - user?.username, - user?.avatar, - handleMentionReceived, - ]); - - useEffect(() => { - if (sessionUsersSocket && sessionUsersSocket.emitPositionChange) { - sessionUsersSocket.emitPositionChange(position); - } - }, [position, sessionUsersSocket]); - - useEffect(() => { - if (!flightsSocket?.socket) return; - - const onPdcRequest = (payload: { flightId?: string | number }) => { - const id = payload?.flightId; - if (!id) return; - setFlashingPDCIds((prev) => { - const next = new Set(prev); - next.add(String(id)); - return next; - }); - }; - - flightsSocket.socket.on('pdcRequest', onPdcRequest); - return () => { - flightsSocket.socket.off('pdcRequest', onPdcRequest); - }; - }, [flightsSocket]); - - const handleToggleClearance = ( - flightId: string | number, - checked: boolean - ) => { - // Persist as boolean - handleFlightUpdate(flightId, { clearance: checked }); - - // Always stop flashing once checked - if (checked) { - setFlashingPDCIds((prev) => { - const next = new Set(prev); - next.delete(String(flightId)); - return next; - }); + if (response.ok) { + const flights: Flight[] = await response.json(); + setActiveAcarsFlightData(flights); + setActiveAcarsFlights(new Set(flights.map((f) => f.id))); } + } catch { + // Ignore errors + } }; - 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 { - setLocalHiddenFlights((prev) => { - const newSet = new Set(prev); - newSet.delete(flightId); - return newSet; - }); - } - return; - } - - // Check if it's a custom departure flight - const isCustomDeparture = customDepartureFlights.some( - (f) => f.id === flightId - ); - if (isCustomDeparture) { - setCustomDepartureFlights((prev) => - prev.map((f) => (f.id === flightId ? { ...f, ...updates } : f)) - ); - return; - } + fetchActiveAcars(); + }, [showContactAcarsModal]); - // Check if it's a custom arrival flight - const isCustomArrival = customArrivalFlights.some( - (f) => f.id === flightId - ); - if (isCustomArrival) { - setCustomArrivalFlights((prev) => - prev.map((f) => (f.id === flightId ? { ...f, ...updates } : f)) - ); - return; - } + const handleSendContact = async ( + flightId: string | number, + message: string + ) => { + if (!flightsSocket?.socket) { + throw new Error('No flights socket'); + } + flightsSocket.socket.emit('contactMe', { flightId, message }); + }; - const isExternalArrival = externalArrivals.some( - (f) => f.id === flightId - ); + useEffect(() => { + if (!sessionId || !accessId || !initialLoadComplete || !session?.isPFATC) + return; - 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'); - setFlights((prev) => - prev.map((flight) => - flight.id === flightId ? { ...flight, ...updates } : flight - ) - ); - } - }; + if (arrivalsSocketConnectedRef.current) return; - const handleFlightDelete = (flightId: string | number) => { - // Check if it's a custom departure flight - const isCustomDeparture = customDepartureFlights.some( - (f) => f.id === flightId - ); - if (isCustomDeparture) { - setCustomDepartureFlights((prev) => - prev.filter((f) => f.id !== flightId) - ); - return; - } + arrivalsSocketConnectedRef.current = true; - // Check if it's a custom arrival flight - const isCustomArrival = customArrivalFlights.some( - (f) => f.id === flightId + const socket = createArrivalsSocket( + sessionId, + accessId, + // onArrivalUpdated + (flight: Flight) => { + setExternalArrivals((prev) => + prev.map((f) => (f.id === flight.id ? flight : f)) ); - if (isCustomArrival) { - setCustomArrivalFlights((prev) => - prev.filter((f) => f.id !== flightId) - ); - return; - } - - // Regular flight deletion via WebSocket - if (flightsSocket?.socket?.connected) { - flightsSocket.deleteFlight(flightId); - } else { - console.warn('Socket not connected, updating local state only'); - setFlights((prev) => - prev.filter((flight) => flight.id !== flightId) - ); - } + }, + // onArrivalError + (error) => { + console.error('Arrival websocket error:', error); + }, + // onInitialExternalArrivals + (flights: Flight[]) => { + console.log('Received initial external arrivals:', flights); + setExternalArrivals(flights); + } + ); + setArrivalsSocket(socket); + return () => { + arrivalsSocketConnectedRef.current = false; + socket.socket.disconnect(); }; + }, [sessionId, accessId, initialLoadComplete, session?.isPFATC]); + + useEffect(() => { + if (!sessionId || !accessId || !user) return; + + if (sessionUsersSocketConnectedRef.current) return; + + sessionUsersSocketConnectedRef.current = true; + + const userId = user.userId; + const username = user.username; + const avatar = user.avatar; + + const socket = createSessionUsersSocket( + sessionId, + accessId, + { + userId, + username, + avatar, + }, + () => {}, + () => {}, + () => {}, + () => {}, + () => {}, + handleMentionReceived, + (editingStates: FieldEditingState[]) => + setFieldEditingStates(editingStates), + 'ALL' + ); - const handleAddCustomDeparture = (flightData: Partial) => { - const newFlight: Flight = { - id: `custom-dep-${Date.now()}`, - session_id: sessionId || '', - 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', - remark: flightData.remark, - hidden: false, - }; - setCustomDepartureFlights((prev) => [...prev, newFlight]); - }; + setSessionUsersSocket(socket); - const handleAddCustomArrival = (flightData: Partial) => { - const newFlight: Flight = { - id: `custom-arr-${Date.now()}`, - session_id: sessionId || '', - 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', - remark: flightData.remark, - hidden: false, - }; - setCustomArrivalFlights((prev) => [...prev, newFlight]); - }; + if (socket) { + socket.on('atisUpdate', handleAtisUpdateFromSocket); + } - const handleRunwayChange = async (selectedRunway: string) => { - if (!sessionId) return; - try { - await updateSession(sessionId, { activeRunway: selectedRunway }); - 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' - ); - } - } catch (error) { - console.error('Failed to update runway:', error); - } + return () => { + sessionUsersSocketConnectedRef.current = false; + if (socket) { + socket.off('atisUpdate', handleAtisUpdateFromSocket); + socket.disconnect(); + } }; - - const handleViewChange = (view: 'departures' | 'arrivals') => { - setCurrentView(view); + }, [ + sessionId, + accessId, + user?.userId, + user?.username, + user?.avatar, + handleMentionReceived, + ]); + + useEffect(() => { + if (sessionUsersSocket && sessionUsersSocket.emitPositionChange) { + sessionUsersSocket.emitPositionChange(position); + } + }, [position, sessionUsersSocket]); + + useEffect(() => { + if (!flightsSocket?.socket) return; + + const onPdcRequest = (payload: { flightId?: string | number }) => { + const id = payload?.flightId; + if (!id) return; + setFlashingPDCIds((prev) => { + const next = new Set(prev); + next.add(String(id)); + return next; + }); }; - const getAllowedStatuses = (pos: Position): string[] => { - switch (pos) { - 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']; - default: - return []; - } + flightsSocket.socket.on('pdcRequest', onPdcRequest); + return () => { + flightsSocket.socket.off('pdcRequest', onPdcRequest); }; + }, [flightsSocket]); + + const handleToggleClearance = ( + flightId: string | number, + checked: boolean + ) => { + // Persist as boolean + handleFlightUpdate(flightId, { clearance: checked }); + + // Always stop flashing once checked + if (checked) { + setFlashingPDCIds((prev) => { + const next = new Set(prev); + next.delete(String(flightId)); + return next; + }); + } + }; + + 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 { + setLocalHiddenFlights((prev) => { + const newSet = new Set(prev); + newSet.delete(flightId); + return newSet; + }); + } + return; + } - const departureFlights = useMemo(() => { - const regularDepartures = flights - .filter( - (flight) => - flight.departure?.toUpperCase() === - session?.airportIcao?.toUpperCase() - ) - .map((flight) => ({ - ...flight, - hidden: localHiddenFlights.has(flight.id), - })); - - // Combine regular flights with custom flights - return [...regularDepartures, ...customDepartureFlights]; - }, [ - flights, - session?.airportIcao, - localHiddenFlights, - customDepartureFlights, - ]); - - const arrivalFlights = useMemo(() => { - const ownArrivals = flights.filter( - (flight) => - flight.arrival?.toUpperCase() === - session?.airportIcao?.toUpperCase() - ); + // Check if it's a custom departure flight + const isCustomDeparture = customDepartureFlights.some( + (f) => f.id === flightId + ); + if (isCustomDeparture) { + setCustomDepartureFlights((prev) => + prev.map((f) => (f.id === flightId ? { ...f, ...updates } : f)) + ); + return; + } - let baseArrivals = ownArrivals; - if (session?.isPFATC) { - baseArrivals = [...ownArrivals, ...externalArrivals]; - } + // Check if it's a custom arrival flight + const isCustomArrival = customArrivalFlights.some((f) => f.id === flightId); + if (isCustomArrival) { + setCustomArrivalFlights((prev) => + prev.map((f) => (f.id === flightId ? { ...f, ...updates } : f)) + ); + return; + } - const mappedArrivals = baseArrivals.map((flight) => ({ - ...flight, - hidden: localHiddenFlights.has(flight.id), - })); - - // Combine regular arrivals with custom arrivals - return [...mappedArrivals, ...customArrivalFlights]; - }, [ - flights, - externalArrivals, - session?.airportIcao, - session?.isPFATC, - localHiddenFlights, - customArrivalFlights, - ]); - - const filteredFlights = useMemo(() => { - let baseFlights: Flight[] = []; - - if (currentView === 'arrivals') { - const ownArrivals = flights.filter( - (flight) => - flight.arrival?.toUpperCase() === - session?.airportIcao?.toUpperCase() - ); - - if (session?.isPFATC) { - baseFlights = [...ownArrivals, ...externalArrivals]; - } else { - baseFlights = ownArrivals; - } + const isExternalArrival = 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'); + setFlights((prev) => + prev.map((flight) => + flight.id === flightId ? { ...flight, ...updates } : flight + ) + ); + } + }; - // Add custom arrival flights - baseFlights = [...baseFlights, ...customArrivalFlights]; - } else { - baseFlights = flights.filter( - (flight) => - flight.departure?.toUpperCase() === - session?.airportIcao?.toUpperCase() - ); - - // Add custom departure flights - baseFlights = [...baseFlights, ...customDepartureFlights]; - } + const handleFlightDelete = (flightId: string | number) => { + // Check if it's a custom departure flight + const isCustomDeparture = customDepartureFlights.some( + (f) => f.id === flightId + ); + if (isCustomDeparture) { + setCustomDepartureFlights((prev) => + prev.filter((f) => f.id !== flightId) + ); + return; + } - if (currentView === 'departures' && position !== 'ALL') { - const allowedStatuses = getAllowedStatuses(position); - baseFlights = baseFlights.filter((flight) => - allowedStatuses.includes(flight.status || '') - ); - } + // Check if it's a custom arrival flight + const isCustomArrival = customArrivalFlights.some((f) => f.id === flightId); + if (isCustomArrival) { + setCustomArrivalFlights((prev) => prev.filter((f) => f.id !== flightId)); + return; + } - return baseFlights.map((flight) => ({ - ...flight, - hidden: localHiddenFlights.has(flight.id), - })); - }, [ - flights, - externalArrivals, - currentView, - session?.airportIcao, - session?.isPFATC, - localHiddenFlights, - position, - customDepartureFlights, - customArrivalFlights, - ]); - - const backgroundImage = useMemo(() => { - const selectedImage = settings?.backgroundImage?.selectedImage; - let bgImage = 'url("/assets/app/backgrounds/mdpc_01.png")'; - - const getImageUrl = (filename: string | null): string | null => { - 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}`; - }; - - 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') { - const favorites = settings?.backgroundImage?.favorites || []; - if (favorites.length > 0) { - const randomFav = - favorites[Math.floor(Math.random() * favorites.length)]; - const favImageUrl = getImageUrl(randomFav); - if ( - favImageUrl && - favImageUrl !== 'random' && - favImageUrl !== 'favorites' - ) { - bgImage = `url(${favImageUrl})`; - } - } - } else if (selectedImage) { - const imageUrl = getImageUrl(selectedImage); - if (imageUrl && imageUrl !== 'random' && imageUrl !== 'favorites') { - bgImage = `url(${imageUrl})`; - } - } + // Regular flight deletion via WebSocket + if (flightsSocket?.socket?.connected) { + flightsSocket.deleteFlight(flightId); + } else { + console.warn('Socket not connected, updating local state only'); + setFlights((prev) => prev.filter((flight) => flight.id !== flightId)); + } + }; + + const handleAddCustomDeparture = (flightData: Partial) => { + const newFlight: Flight = { + id: `custom-dep-${Date.now()}`, + session_id: sessionId || '', + 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', + remark: flightData.remark, + hidden: false, + }; + setCustomDepartureFlights((prev) => [...prev, newFlight]); + }; + + const handleAddCustomArrival = (flightData: Partial) => { + const newFlight: Flight = { + id: `custom-arr-${Date.now()}`, + session_id: sessionId || '', + 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', + remark: flightData.remark, + hidden: false, + }; + setCustomArrivalFlights((prev) => [...prev, newFlight]); + }; + + const handleRunwayChange = async (selectedRunway: string) => { + if (!sessionId) return; + try { + await updateSession(sessionId, accessId ?? '', { + activeRunway: selectedRunway, + }); + 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'); + } + } catch (error) { + console.error('Failed to update runway:', error); + } + }; + + const handleViewChange = (view: 'departures' | 'arrivals') => { + setCurrentView(view); + }; + + const getAllowedStatuses = (pos: Position): string[] => { + switch (pos) { + 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']; + default: + return []; + } + }; + + const departureFlights = useMemo(() => { + const regularDepartures = flights + .filter( + (flight) => + flight.departure?.toUpperCase() === + session?.airportIcao?.toUpperCase() + ) + .map((flight) => ({ + ...flight, + hidden: localHiddenFlights.has(flight.id), + })); + + // Combine regular flights with custom flights + return [...regularDepartures, ...customDepartureFlights]; + }, [ + flights, + session?.airportIcao, + localHiddenFlights, + customDepartureFlights, + ]); + + const arrivalFlights = useMemo(() => { + const ownArrivals = flights.filter( + (flight) => + flight.arrival?.toUpperCase() === session?.airportIcao?.toUpperCase() + ); - return bgImage; - }, [ - settings?.backgroundImage?.selectedImage, - settings?.backgroundImage?.favorites, - availableImages, - ]); + let baseArrivals = ownArrivals; + if (session?.isPFATC) { + baseArrivals = [...ownArrivals, ...externalArrivals]; + } - const showCombinedView = !isMobile && settings?.layout?.showCombinedView; - const flightRowOpacity = settings?.layout?.flightRowOpacity ?? 100; + const mappedArrivals = baseArrivals.map((flight) => ({ + ...flight, + hidden: localHiddenFlights.has(flight.id), + })); + + // Combine regular arrivals with custom arrivals + return [...mappedArrivals, ...customArrivalFlights]; + }, [ + flights, + externalArrivals, + session?.airportIcao, + session?.isPFATC, + localHiddenFlights, + customArrivalFlights, + ]); + + const filteredFlights = useMemo(() => { + let baseFlights: Flight[] = []; + + if (currentView === 'arrivals') { + const ownArrivals = flights.filter( + (flight) => + flight.arrival?.toUpperCase() === session?.airportIcao?.toUpperCase() + ); + + if (session?.isPFATC) { + baseFlights = [...ownArrivals, ...externalArrivals]; + } else { + baseFlights = ownArrivals; + } + + // Add custom arrival flights + baseFlights = [...baseFlights, ...customArrivalFlights]; + } else { + baseFlights = flights.filter( + (flight) => + flight.departure?.toUpperCase() === + session?.airportIcao?.toUpperCase() + ); + + // Add custom departure flights + baseFlights = [...baseFlights, ...customDepartureFlights]; + } - const getBackgroundStyle = (opacity: number) => { - if (opacity === 0) { - return { backgroundColor: 'transparent' }; - } - const alpha = opacity / 100; - return { - backgroundColor: `rgba(0, 0, 0, ${alpha})`, - }; - }; + if (currentView === 'departures' && position !== 'ALL') { + const allowedStatuses = getAllowedStatuses(position); + baseFlights = baseFlights.filter((flight) => + allowedStatuses.includes(flight.status || '') + ); + } - const backgroundStyle = getBackgroundStyle(flightRowOpacity); - - const defaultDepartureColumns: DepartureTableColumnSettings = { - time: true, - callsign: true, - stand: true, - aircraft: true, - wakeTurbulence: true, - flightType: true, - arrival: true, - runway: true, - sid: true, - rfl: true, - cfl: true, - squawk: true, - clearance: true, - status: true, - remark: true, - pdc: true, - hide: true, - delete: true, + return baseFlights.map((flight) => ({ + ...flight, + hidden: localHiddenFlights.has(flight.id), + })); + }, [ + flights, + externalArrivals, + currentView, + session?.airportIcao, + session?.isPFATC, + localHiddenFlights, + position, + customDepartureFlights, + customArrivalFlights, + ]); + + const backgroundImage = useMemo(() => { + const selectedImage = settings?.backgroundImage?.selectedImage; + let bgImage = 'url("/assets/app/backgrounds/mdpc_01.png")'; + + const getImageUrl = (filename: string | null): string | null => { + 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}`; }; - const defaultArrivalsColumns: ArrivalsTableColumnSettings = { - time: true, - callsign: true, - gate: true, - aircraft: true, - wakeTurbulence: true, - flightType: true, - departure: true, - runway: true, - star: true, - rfl: true, - cfl: true, - squawk: true, - status: true, - remark: true, - hide: true, - }; + 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') { + const favorites = settings?.backgroundImage?.favorites || []; + if (favorites.length > 0) { + const randomFav = + favorites[Math.floor(Math.random() * favorites.length)]; + const favImageUrl = getImageUrl(randomFav); + if ( + favImageUrl && + favImageUrl !== 'random' && + favImageUrl !== 'favorites' + ) { + bgImage = `url(${favImageUrl})`; + } + } + } else if (selectedImage) { + const imageUrl = getImageUrl(selectedImage); + if (imageUrl && imageUrl !== 'random' && imageUrl !== 'favorites') { + bgImage = `url(${imageUrl})`; + } + } - const departureColumns = { - ...defaultDepartureColumns, - ...settings?.departureTableColumns, - }; - const arrivalsColumns = { - ...defaultArrivalsColumns, - ...settings?.arrivalsTableColumns, - }; + return bgImage; + }, [ + settings?.backgroundImage?.selectedImage, + settings?.backgroundImage?.favorites, + availableImages, + ]); - const handleFieldEditingStart = ( - flightId: string | number, - fieldName: string - ) => { - if (sessionUsersSocket?.emitFieldEditingStart) { - sessionUsersSocket.emitFieldEditingStart(flightId, fieldName); - } - }; + const showCombinedView = !isMobile && settings?.layout?.showCombinedView; + const flightRowOpacity = settings?.layout?.flightRowOpacity ?? 100; - const handleFieldEditingStop = ( - flightId: string | number, - fieldName: string - ) => { - if (sessionUsersSocket?.emitFieldEditingStop) { - sessionUsersSocket.emitFieldEditingStop(flightId, fieldName); - } + const getBackgroundStyle = (opacity: number) => { + if (opacity === 0) { + return { backgroundColor: 'transparent' }; + } + const alpha = opacity / 100; + return { + backgroundColor: `rgba(0, 0, 0, ${alpha})`, }; - - // Early return for validation states - if (validatingAccess) { - return ( -
-
- - -
-
- ); + }; + + const backgroundStyle = getBackgroundStyle(flightRowOpacity); + + const defaultDepartureColumns: DepartureTableColumnSettings = { + time: true, + callsign: true, + stand: true, + aircraft: true, + wakeTurbulence: true, + flightType: true, + arrival: true, + runway: true, + sid: true, + rfl: true, + cfl: true, + squawk: true, + clearance: true, + status: true, + remark: true, + pdc: true, + hide: true, + delete: true, + }; + + const defaultArrivalsColumns: ArrivalsTableColumnSettings = { + time: true, + callsign: true, + gate: true, + aircraft: true, + wakeTurbulence: true, + flightType: true, + departure: true, + runway: true, + star: true, + rfl: true, + cfl: true, + squawk: true, + status: true, + remark: true, + hide: true, + }; + + const departureColumns = { + ...defaultDepartureColumns, + ...settings?.departureTableColumns, + }; + const arrivalsColumns = { + ...defaultArrivalsColumns, + ...settings?.arrivalsTableColumns, + }; + + const handleFieldEditingStart = ( + flightId: string | number, + fieldName: string + ) => { + if (sessionUsersSocket?.emitFieldEditingStart) { + sessionUsersSocket.emitFieldEditingStart(flightId, fieldName); } - - if (accessError) { - return ( - - ); + }; + + const handleFieldEditingStop = ( + flightId: string | number, + fieldName: string + ) => { + if (sessionUsersSocket?.emitFieldEditingStop) { + sessionUsersSocket.emitFieldEditingStop(flightId, fieldName); } + }; + + // Early return for validation states + if (validatingAccess) { + return ( +
+
+ + +
+
+ ); + } + if (accessError) { return ( -
-
-
- -
- - setShowContactAcarsModal(true) - } + + ); + } + + return ( +
+
+
+ +
+ setShowContactAcarsModal(true)} + /> +
+ {loading ? ( +
+ Loading {currentView}... +
+ ) : showCombinedView ? ( + <> + +
+ + +
+ + ) : ( + <> + {currentView === 'departures' ? ( + <> + -
- {loading ? ( -
- Loading {currentView}... -
- ) : showCombinedView ? ( - <> - -
- - -
- - ) : ( - <> - {currentView === 'departures' ? ( - <> - -
- -
- - ) : ( - <> - -
- -
- - )} - - )} +
+
-
-
- - {/* Modals */} - setShowAddDepartureModal(false)} - onAdd={handleAddCustomDeparture} - flightType="departure" - airportIcao={session?.airportIcao} - /> - setShowAddArrivalModal(false)} - onAdd={handleAddCustomArrival} - flightType="arrival" - airportIcao={session?.airportIcao} - /> - setShowContactAcarsModal(false)} - flights={activeAcarsFlightData} - onSendContact={handleSendContact} - activeAcarsFlights={activeAcarsFlights} - airportIcao={session?.airportIcao || ''} - /> + + ) : ( + <> + +
+ +
+ + )} + + )} +
- ); +
+ + {/* Modals */} + setShowAddDepartureModal(false)} + onAdd={handleAddCustomDeparture} + flightType="departure" + airportIcao={session?.airportIcao} + /> + setShowAddArrivalModal(false)} + onAdd={handleAddCustomArrival} + flightType="arrival" + airportIcao={session?.airportIcao} + /> + setShowContactAcarsModal(false)} + flights={activeAcarsFlightData} + onSendContact={handleSendContact} + activeAcarsFlights={activeAcarsFlights} + airportIcao={session?.airportIcao || ''} + /> +
+ ); } diff --git a/src/pages/Sessions.tsx b/src/pages/Sessions.tsx index ada5cc2..badbac6 100644 --- a/src/pages/Sessions.tsx +++ b/src/pages/Sessions.tsx @@ -2,21 +2,21 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import Navbar from '../components/Navbar'; import { - Workflow, - Calendar, - Plane, - AlertTriangle, - Pencil, - Trash2, - Info, - X, - PlaneTakeoff, + Workflow, + Calendar, + Plane, + AlertTriangle, + Pencil, + Trash2, + Info, + X, + PlaneTakeoff, } from 'lucide-react'; import { useAuth } from '../hooks/auth/useAuth'; import { - fetchMySessions, - updateSessionName, - deleteSession, + fetchMySessions, + updateSessionName, + deleteSession, } from '../utils/fetch/sessions'; import type { SessionInfo } from '../types/session'; import { fetchFlights } from '../utils/fetch/flights'; @@ -25,487 +25,432 @@ import Loader from '../components/common/Loader'; import TextInput from '../components/common/TextInput'; export default function Sessions() { - const { user, isLoading } = useAuth(); - const [sessions, setSessions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [editingName, setEditingName] = useState(null); - const [editNameValue, setEditNameValue] = useState(''); - const [savingName, setSavingName] = useState(null); - const [sessionToDelete, setSessionToDelete] = useState(null); - const [deleteInProgress, setDeleteInProgress] = useState( - null - ); - - useEffect(() => { - if (!user) { - setLoading(false); - return; - } - fetchMySessions() - .then(async (data) => { - const sessionsWithCounts = await Promise.all( - data.map(async (session) => { - try { - const flights = await fetchFlights( - session.sessionId - ); - return { ...session, flightCount: flights.length }; - } catch { - return { ...session, flightCount: 0 }; - } - }) - ); - setSessions(sessionsWithCounts); - }) - .catch(() => setError('Failed to load sessions.')) - .finally(() => setLoading(false)); - }, [user]); + const { user, isLoading } = useAuth(); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [editingName, setEditingName] = useState(null); + const [editNameValue, setEditNameValue] = useState(''); + const [savingName, setSavingName] = useState(null); + const [sessionToDelete, setSessionToDelete] = useState(null); + const [deleteInProgress, setDeleteInProgress] = useState(null); - const startEditingName = (sessionId: string, currentName: string) => { - setEditingName(sessionId); - setEditNameValue(currentName || ''); - }; + useEffect(() => { + if (!user) { + setLoading(false); + return; + } + fetchMySessions() + .then(async (data) => { + const sessionsWithCounts = await Promise.all( + data.map(async (session) => { + try { + const flights = await fetchFlights(session.sessionId); + return { ...session, flightCount: flights.length }; + } catch { + return { ...session, flightCount: 0 }; + } + }) + ); + setSessions(sessionsWithCounts); + }) + .catch(() => setError('Failed to load sessions.')) + .finally(() => setLoading(false)); + }, [user]); - const saveSessionName = async (sessionId: string) => { - if (!editNameValue.trim()) { - setEditingName(null); - setEditNameValue(''); - return; - } - setSavingName(sessionId); - try { - const { customName } = await updateSessionName( - sessionId, - editNameValue.trim() - ); - setSessions((prev) => - prev.map((s) => - s.sessionId === sessionId ? { ...s, customName } : s - ) - ); - setEditingName(null); - setEditNameValue(''); - } catch { - setError('Failed to update session name.'); - } finally { - setSavingName(null); - } - }; + const startEditingName = (sessionId: string, currentName: string) => { + setEditingName(sessionId); + setEditNameValue(currentName || ''); + }; - const confirmDelete = (sessionId: string) => { - setSessionToDelete(sessionId); - }; + const saveSessionName = async (sessionId: string) => { + if (!editNameValue.trim()) { + setEditingName(null); + setEditNameValue(''); + return; + } + setSavingName(sessionId); + try { + const { customName } = await updateSessionName( + sessionId, + editNameValue.trim() + ); + setSessions((prev) => + prev.map((s) => (s.sessionId === sessionId ? { ...s, customName } : s)) + ); + setEditingName(null); + setEditNameValue(''); + } catch { + setError('Failed to update session name.'); + } finally { + setSavingName(null); + } + }; - const handleDeleteSession = async () => { - if (!sessionToDelete) return; - setDeleteInProgress(sessionToDelete); - try { - await deleteSession(sessionToDelete); - setSessions((prev) => - prev.filter((s) => s.sessionId !== sessionToDelete) - ); - setSessionToDelete(null); - } catch { - setError('Failed to delete session.'); - } finally { - setDeleteInProgress(null); - } - }; + const confirmDelete = (sessionId: string) => { + setSessionToDelete(sessionId); + }; - if (isLoading || loading) { - return ( -
- - -
- ); + const handleDeleteSession = async () => { + if (!sessionToDelete) return; + setDeleteInProgress(sessionToDelete); + try { + await deleteSession(sessionToDelete); + setSessions((prev) => + prev.filter((s) => s.sessionId !== sessionToDelete) + ); + setSessionToDelete(null); + } catch { + setError('Failed to delete session.'); + } finally { + setDeleteInProgress(null); } + }; - if (!user) { - return ( -
- -
- -

- Not logged in -

-

- Please log in to view your sessions. -

- - Go Home - -
-
- ); - } + if (isLoading || loading) { + return ( +
+ + +
+ ); + } + if (!user) { return ( -
- - {/* Header */} -
-
-
+ +
+ +

Not logged in

+

+ Please log in to view your sessions. +

+ + Go Home + +
+
+ ); + } + + return ( +
+ + {/* Header */} +
+
+
-
-
- -
-
-

+
+ +
+
+

- My Sessions -

-
-

-

- {sessions.length}/10 sessions created -

-
+
+

+ {sessions.length}/10 sessions created +

+ -
+ > + Create New Session + +
+
+
+ {/* Content */} +
+ {error ? ( +
+ {error} +
+ ) : sessions.length === 0 ? ( +
+

No sessions yet

+

+ You haven't created any sessions yet. +

+ + Create Your First Session + +
+ ) : ( + <> + {sessions.length >= 10 && ( +
+
+ + + Session limit reached +
-
- {/* Content */} -
- {error ? ( -
- {error} -
- ) : sessions.length === 0 ? ( -
-

- No sessions yet -

-

- You haven't created any sessions yet. -

- - Create Your First Session - +

+ You have reached the maximum of 10 sessions.{' '} + + Delete an old session to create a new one. + +

+
+ )} +
+ {sessions.map((session) => ( +
+ +
+ + + {session.customName + ? session.customName + : `${session.airportIcao || 'Unknown'} Session`} + + {session.isLegacy && ( + + )}
- ) : ( - <> - {sessions.length >= 10 && ( -
-
- - - Session limit reached - -
-

- You have reached the maximum of 10 sessions.{' '} - - Delete an old session to create a new - one. - -

-
- )} -
- {sessions.map((session) => ( -
- -
- - - {session.customName - ? session.customName - : `${ - session.airportIcao || - 'Unknown' - } Session`} - - {session.isLegacy && ( - - )} -
-
-
- - {new Date( - session.createdAt - ).toLocaleString()} -
- {session.activeRunway && ( -
- - Departure Runway:{' '} - {session.activeRunway} -
- )} -
- {session.isPFATC ? ( - <> - - - PFATC Session - - - ) : ( - <> - - - Standard Session - - - )} -
-
- - Flights: {session.flightCount} -
-
- -
- - -
- {sessionToDelete && ( -
-
-
-
-
- -
-

- Delete Session -

-
- -
- -
- {sessions.find( - (s) => - s.sessionId === - sessionToDelete - )?.isLegacy && ( -
-
- - - Legacy - Session - -
-

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

-
- )} -

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

-

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

-

- ID: {sessionToDelete} -

-
- -
- - -
-
-
- )} -
- ))} +
+
+ + {session.createdAt + ? new Date(session.createdAt).toLocaleString() + : 'Date unavailable'} +
+ {session.activeRunway && ( +
+ + Departure Runway: {session.activeRunway}
- - )} -
- {/* Edit Name Modal */} - {editingName && ( -
-
+ )} +
+ {session.isPFATC ? ( + <> + + + PFATC Session + + + ) : ( + <> + + + Standard Session + + + )} +
+
+ + Flights: {session.flightCount} +
+
+ +
+ + +
+ {sessionToDelete && ( +
+
-
-
- -
-

- Edit Session Name -

+
+
+
- +

+ Delete Session +

+
+
+
-

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

- { - if (e.key === 'Enter') - saveSessionName(editingName); - if (e.key === 'Escape') - setEditingName(null); - }} - /> + {sessions.find((s) => s.sessionId === sessionToDelete) + ?.isLegacy && ( +
+
+ + + Legacy Session + +
+

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

+
+ )} +

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

+

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

+

+ ID: {sessionToDelete} +

+
- - + +
+
+ )}
- )} + ))} +
+ + )} +
+ {/* Edit Name Modal */} + {editingName && ( +
+
+
+
+
+ +
+

Edit Session Name

+
+ +
+
+

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

+ { + if (e.key === 'Enter') saveSessionName(editingName); + if (e.key === 'Escape') setEditingName(null); + }} + /> +
+
+ + +
+
- ); + )} +
+ ); } diff --git a/src/utils/fetch/admin.ts b/src/utils/fetch/admin.ts index 402edb5..3717db9 100644 --- a/src/utils/fetch/admin.ts +++ b/src/utils/fetch/admin.ts @@ -244,10 +244,6 @@ export async function fetchAdminSessions(): Promise { return makeAdminRequest('/sessions'); } -export async function fetchSystemInfo(): Promise { - return makeAdminRequest('/system-info'); -} - export async function revealUserIP(userId: string): Promise { return makeAdminRequest(`/users/${userId}/reveal-ip`, { method: 'POST' diff --git a/src/utils/fetch/sessions.ts b/src/utils/fetch/sessions.ts index 4c1e861..4785127 100644 --- a/src/utils/fetch/sessions.ts +++ b/src/utils/fetch/sessions.ts @@ -45,8 +45,11 @@ export async function createSession(data: { return res.json(); } -export async function updateSession(sessionId: string, updates: Partial): Promise { - const res = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}`, { +export async function updateSession(sessionId: string, accessId: string, updates: Partial): Promise { + const url = new URL(`${API_BASE_URL}/api/sessions/${sessionId}`); + url.searchParams.append('accessId', accessId); + + const res = await fetch(url.toString(), { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..c4694aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.server.json" } ] -} +} \ No newline at end of file diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..1a3ba50 --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "node", + "ignoreDeprecations": "6.0", + "outDir": "dist/server", + "rootDir": "server", + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["server"] +} \ No newline at end of file