diff --git a/server/db/apiLogs.ts b/server/db/apiLogs.ts new file mode 100644 index 0000000..fbc52e3 --- /dev/null +++ b/server/db/apiLogs.ts @@ -0,0 +1,283 @@ +import { mainDb } from "./connection.js"; +import { decrypt } from "../utils/encryption.js"; +import { sql } from "kysely"; + +export interface ApiLogFilters { + userId?: string; + username?: string; + method?: string; + path?: string; + statusCode?: number; + dateFrom?: string; + dateTo?: string; + search?: string; +} + +export interface ApiLogStats { + totalRequests: number; + averageResponseTime: number; + errorRate: number; + topEndpoints: Array<{ + path: string; + count: number; + avgResponseTime: number; + }>; + statusCodeDistribution: Array<{ + statusCode: number; + count: number; + }>; + dailyStats: Array<{ + date: string; + requestCount: number; + errorCount: number; + avgResponseTime: number; + }>; +} + +export async function getApiLogs( + page = 1, + limit = 50, + filters: ApiLogFilters = {} +) { + try { + const offset = (page - 1) * limit; + + let query = mainDb + .selectFrom('api_logs') + .select([ + 'id', + 'user_id', + 'username', + 'method', + 'path', + 'status_code', + 'response_time', + 'ip_address', + 'user_agent', + 'request_body', + 'response_body', + 'error_message', + 'timestamp' + ]) + .orderBy('timestamp', 'desc'); + + // Apply filters + if (filters.userId) { + query = query.where(q => + q.or([ + q('user_id', 'ilike', `%${filters.userId}%`), + q('username', 'ilike', `%${filters.userId}%`) + ]) + ); + } + + if (filters.method) { + query = query.where('method', '=', filters.method.toUpperCase()); + } + + if (filters.path) { + query = query.where('path', 'ilike', `%${filters.path}%`); + } + + if (filters.statusCode) { + query = query.where('status_code', '=', filters.statusCode); + } + + if (filters.dateFrom) { + query = query.where('timestamp', '>=', new Date(filters.dateFrom)); + } + + if (filters.dateTo) { + query = query.where('timestamp', '<=', new Date(filters.dateTo)); + } + + if (filters.search) { + query = query.where(q => + q.or([ + q('path', 'ilike', `%${filters.search}%`), + q('username', 'ilike', `%${filters.search}%`), + q('method', 'ilike', `%${filters.search}%`) + ]) + ); + } + + let countQuery = mainDb + .selectFrom('api_logs') + .select(sql`count(*)`.as('count')); + + if (filters.userId) { + countQuery = countQuery.where(q => + q.or([ + q('user_id', 'ilike', `%${filters.userId}%`), + q('username', 'ilike', `%${filters.userId}%`) + ]) + ); + } + if (filters.method) { + countQuery = countQuery.where('method', '=', filters.method.toUpperCase()); + } + if (filters.path) { + countQuery = countQuery.where('path', 'ilike', `%${filters.path}%`); + } + if (filters.statusCode) { + countQuery = countQuery.where('status_code', '=', filters.statusCode); + } + if (filters.dateFrom) { + countQuery = countQuery.where('timestamp', '>=', new Date(filters.dateFrom)); + } + if (filters.dateTo) { + countQuery = countQuery.where('timestamp', '<=', new Date(filters.dateTo)); + } + if (filters.search) { + countQuery = countQuery.where(q => + q.or([ + q('path', 'ilike', `%${filters.search}%`), + q('username', 'ilike', `%${filters.search}%`), + q('method', 'ilike', `%${filters.search}%`) + ]) + ); + } + + const totalResult = await countQuery.executeTakeFirst(); + const total = Number(totalResult?.count || 0); + + // Get paginated results + const logs = await query + .limit(limit) + .offset(offset) + .execute(); + + // Decrypt sensitive fields for display + const decryptedLogs = logs.map(log => ({ + ...log, + ip_address: log.ip_address ? decrypt(JSON.parse(log.ip_address)) : null, + request_body: log.request_body ? decrypt(JSON.parse(log.request_body)) : null, + response_body: log.response_body ? decrypt(JSON.parse(log.response_body)) : null, + error_message: log.error_message ? decrypt(JSON.parse(log.error_message)) : null, + })); + + return { + logs: decryptedLogs, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit) + } + }; + } catch (error) { + console.error('Error fetching API logs:', error); + throw error; + } +} + +export async function getApiLogById(logId: number | string) { + try { + const log = await mainDb + .selectFrom('api_logs') + .selectAll() + .where('id', '=', typeof logId === 'string' ? parseInt(logId) : logId) + .executeTakeFirst(); + + if (!log) return null; + + // Decrypt sensitive fields + return { + ...log, + ip_address: log.ip_address ? decrypt(JSON.parse(log.ip_address)) : null, + request_body: log.request_body ? decrypt(JSON.parse(log.request_body)) : null, + response_body: log.response_body ? decrypt(JSON.parse(log.response_body)) : null, + error_message: log.error_message ? decrypt(JSON.parse(log.error_message)) : null, + }; + } catch (error) { + console.error('Error fetching API log by ID:', error); + throw error; + } +} + +export async function getApiLogStats(days: number = 7): Promise { + try { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - days); + + // Total requests and average response time + const totalStatsResult = await mainDb + .selectFrom('api_logs') + .select([ + sql`count(*)`.as('totalRequests'), + sql`avg(response_time)`.as('averageResponseTime'), + sql`count(case when status_code >= 400 then 1 end)`.as('errorCount') + ]) + .where('timestamp', '>=', cutoffDate) + .executeTakeFirst(); + + const totalRequests = Number(totalStatsResult?.totalRequests || 0); + const averageResponseTime = Number(totalStatsResult?.averageResponseTime || 0); + const errorCount = Number(totalStatsResult?.errorCount || 0); + const errorRate = totalRequests > 0 ? (errorCount / totalRequests) * 100 : 0; + + // Top endpoints + const topEndpoints = await mainDb + .selectFrom('api_logs') + .select([ + 'path', + sql`count(*)`.as('count'), + sql`avg(response_time)`.as('avgResponseTime') + ]) + .where('timestamp', '>=', cutoffDate) + .groupBy('path') + .orderBy('count', 'desc') + .limit(10) + .execute(); + + // Status code distribution + const statusCodeDistribution = await mainDb + .selectFrom('api_logs') + .select([ + 'status_code', + sql`count(*)`.as('count') + ]) + .where('timestamp', '>=', cutoffDate) + .groupBy('status_code') + .orderBy('count', 'desc') + .execute(); + + // Daily stats + const dailyStats = await mainDb + .selectFrom('api_logs') + .select([ + sql`date(timestamp)`.as('date'), + sql`count(*)`.as('requestCount'), + sql`count(case when status_code >= 400 then 1 end)`.as('errorCount'), + sql`avg(response_time)`.as('avgResponseTime') + ]) + .where('timestamp', '>=', cutoffDate) + .groupBy(sql`date(timestamp)`) + .orderBy('date', 'desc') + .execute(); + + return { + totalRequests, + averageResponseTime: Math.round(averageResponseTime), + errorRate: Math.round(errorRate * 100) / 100, + topEndpoints: topEndpoints.map(endpoint => ({ + path: endpoint.path, + count: Number(endpoint.count), + avgResponseTime: Math.round(Number(endpoint.avgResponseTime)) + })), + statusCodeDistribution: statusCodeDistribution.map(status => ({ + statusCode: status.status_code, + count: Number(status.count) + })), + dailyStats: dailyStats.map(day => ({ + date: day.date, + requestCount: Number(day.requestCount), + errorCount: Number(day.errorCount), + avgResponseTime: Math.round(Number(day.avgResponseTime)) + })) + }; + } catch (error) { + console.error('Error fetching API log stats:', error); + throw error; + } +} \ No newline at end of file diff --git a/server/db/flights.ts b/server/db/flights.ts index 5aae92f..71070a4 100644 --- a/server/db/flights.ts +++ b/server/db/flights.ts @@ -251,10 +251,12 @@ export async function getFlightsBySessionWithTime(sessionId: string, hoursBack = .where((eb) => eb.or([ eb('timestamp', '>=', sinceDateISOString), - eb('updated_at', '>=', sinceDateUTC), + eb('updated_at', '>=', sql`${sinceDateISOString}`), + eb('created_at', '>=', sql`${sinceDateISOString}`), ]) ) - .orderBy('timestamp', 'asc') + .orderBy(sql`COALESCE(timestamp::timestamp, created_at, updated_at)`, 'desc') + .orderBy('callsign', 'asc') .execute(); // Enrich flights with user data diff --git a/server/db/schemas.ts b/server/db/schemas.ts index cdb370d..ffc3932 100644 --- a/server/db/schemas.ts +++ b/server/db/schemas.ts @@ -180,6 +180,59 @@ export async function createMainTables() { .addColumn('created_at', 'timestamp', (col) => col.defaultTo('now()')) .addColumn('updated_at', 'timestamp', (col) => col.defaultTo('now()')) .execute(); + + // api_logs + await mainDb.schema + .createTable('api_logs') + .ifNotExists() + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('user_id', 'varchar(255)') + .addColumn('username', 'varchar(255)') + .addColumn('method', 'varchar(10)', (col) => col.notNull()) + .addColumn('path', 'text', (col) => col.notNull()) + .addColumn('status_code', 'integer', (col) => col.notNull()) + .addColumn('response_time', 'integer', (col) => col.notNull()) + .addColumn('ip_address', 'text') + .addColumn('user_agent', 'text') + .addColumn('request_body', 'text') + .addColumn('response_body', 'text') + .addColumn('error_message', 'text') + .addColumn('timestamp', 'timestamp', (col) => col.defaultTo('now()')) + .execute(); + + try { + await mainDb.schema + .createIndex('idx_api_logs_timestamp') + .on('api_logs') + .column('timestamp') + .execute(); + } catch { + // Index might already exist + } + + try { + await mainDb.schema + .createIndex('idx_api_logs_user_id') + .on('api_logs') + .column('user_id') + .execute(); + } catch { + // Index might already exist + } + + await mainDb.schema + .createIndex('api_logs_path_idx') + .ifNotExists() + .on('api_logs') + .column('path') + .execute(); + + await mainDb.schema + .createIndex('api_logs_status_code_idx') + .ifNotExists() + .on('api_logs') + .column('status_code') + .execute(); } // Helper to create a dynamic flights table for a session diff --git a/server/db/types/connection/MainDatabase.ts b/server/db/types/connection/MainDatabase.ts index 3990252..376b1cd 100644 --- a/server/db/types/connection/MainDatabase.ts +++ b/server/db/types/connection/MainDatabase.ts @@ -15,6 +15,7 @@ import { UpdateModalsTable } from "./main/UpdateModalsTable"; import { FlightLogsTable } from "./main/FlightLogsTable"; import { GlobalHolidaySettingsTable } from "./main/GlobalHolidaySettingsTable"; import { FeedbackTable } from "./main/FeedbackTable"; +import { ApiLogsTable } from "./main/ApiLogsTable"; export interface MainDatabase { app_settings: AppSettingsTable; @@ -34,4 +35,5 @@ export interface MainDatabase { flight_logs: FlightLogsTable; global_holiday_settings: GlobalHolidaySettingsTable; feedback: FeedbackTable; + api_logs: ApiLogsTable; } \ No newline at end of file diff --git a/server/db/types/connection/main/ApiLogsTable.ts b/server/db/types/connection/main/ApiLogsTable.ts new file mode 100644 index 0000000..be29d77 --- /dev/null +++ b/server/db/types/connection/main/ApiLogsTable.ts @@ -0,0 +1,15 @@ +export interface ApiLogsTable { + id: number; + user_id: string | null; + username: string | null; + method: string; + path: string; + status_code: number; + response_time: number; + ip_address: string; + user_agent: string | null; + request_body: string | null; + response_body: string | null; + error_message: string | null; + timestamp: Date; +} \ No newline at end of file diff --git a/server/main.ts b/server/main.ts index c08c207..04e011c 100644 --- a/server/main.ts +++ b/server/main.ts @@ -22,6 +22,7 @@ import { startStatsFlushing } from './utils/statisticsCache.js'; import { updateLeaderboard } from './db/leaderboard.js'; import { startFlightLogsCleanup } from './db/flightLogs.js'; import { initializeGlobalHolidaySettings } from './db/globalHolidaySettings.js'; +import { apiLogger, cleanupOldApiLogs } from './middleware/apiLogger.js'; dotenv.config({ path: process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development' }); console.log(chalk.bgBlue('NODE_ENV:'), process.env.NODE_ENV); @@ -76,6 +77,8 @@ app.use(cors({ app.use(cookieParser()); app.use(express.json()); +app.use(apiLogger()); + app.get('/health', (_req, res) => { res.json({ status: 'ok', environment: process.env.NODE_ENV }); }); @@ -123,7 +126,10 @@ sectorControllerIO.adapter(createAdapter(pubClient, subClient)); startStatsFlushing(); startFlightLogsCleanup(); updateLeaderboard(); -setInterval(updateLeaderboard, 12 * 60 * 60 * 1000); // 12h +setInterval(updateLeaderboard, 12 * 60 * 60 * 1000); +setInterval(() => { + cleanupOldApiLogs(1); +}, 60 * 60 * 1000); server.listen(PORT, () => { console.log(chalk.green(`Server running on http://localhost:${PORT}`)); diff --git a/server/middleware/apiLogger.ts b/server/middleware/apiLogger.ts new file mode 100644 index 0000000..3c6ea40 --- /dev/null +++ b/server/middleware/apiLogger.ts @@ -0,0 +1,217 @@ +import { Request, Response, NextFunction } from 'express'; +import { mainDb } from '../db/connection.js'; +import { encrypt } from '../utils/encryption.js'; +import { getClientIp } from '../utils/getIpAddress.js'; +import { JwtPayloadClient } from '../types/JwtPayload.js'; +import { sql } from 'kysely'; + +interface RequestWithUser extends Request { + user?: JwtPayloadClient; +} + +export interface ApiLogEntry { + user_id: string | null; + username: string | null; + method: string; + path: string; + status_code: number; + response_time: number; + ip_address: string; + user_agent: string | null; + request_body: string | null; + response_body: string | null; + error_message: string | null; + timestamp: Date; +} + +const EXCLUDED_PATHS = [ + '/health', + '/api/data/metar', + '/api/data/airports', + '/api/data/airlines', + '/api/data/aircrafts', + '/api/data/frequencies', + '/api/admin/api-logs', + '/assets', + '.css', + '.js', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.ico', + '.svg', + '.woff', + '.woff2', + '.ttf', + '.eot' +]; + +const SENSITIVE_FIELDS = [ + 'token', + 'secret', + 'key', + 'authorization', +]; + +function shouldLogRequest(path: string): boolean { + return !EXCLUDED_PATHS.some(excluded => + path.toLowerCase().includes(excluded.toLowerCase()) + ); +} + +function sanitizeObject(obj: unknown): unknown { + if (!obj || typeof obj !== 'object') return obj; + + if (Array.isArray(obj)) { + return obj.map(item => sanitizeObject(item)); + } else { + const sanitized: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + const lowerKey = key.toLowerCase(); + + if (SENSITIVE_FIELDS.some(field => lowerKey.includes(field))) { + sanitized[key] = '[REDACTED]'; + } else { + sanitized[key] = sanitizeObject(value); + } + } + + return sanitized; + } +} + +function truncateString(str: string, maxLength: number = 10000): string { + if (str.length <= maxLength) return str; + return str.substring(0, maxLength) + '... [TRUNCATED]'; +} + +export async function logApiCall(logEntry: ApiLogEntry): Promise { + try { + const ipAddress = Array.isArray(logEntry.ip_address) + ? logEntry.ip_address.join(', ') + : logEntry.ip_address; + + const encryptedIpAddress = encrypt(ipAddress); + + const encryptedRequestBody = logEntry.request_body + ? encrypt(logEntry.request_body) + : null; + + const encryptedResponseBody = logEntry.response_body + ? encrypt(logEntry.response_body) + : null; + + const encryptedErrorMessage = logEntry.error_message + ? encrypt(logEntry.error_message) + : null; + + await mainDb + .insertInto('api_logs') + .values({ + id: sql`DEFAULT`, + user_id: logEntry.user_id, + username: logEntry.username, + method: logEntry.method, + path: logEntry.path, + status_code: logEntry.status_code, + response_time: logEntry.response_time, + ip_address: JSON.stringify(encryptedIpAddress), + user_agent: logEntry.user_agent, + request_body: encryptedRequestBody ? JSON.stringify(encryptedRequestBody) : null, + response_body: encryptedResponseBody ? JSON.stringify(encryptedResponseBody) : null, + error_message: encryptedErrorMessage ? JSON.stringify(encryptedErrorMessage) : null, + timestamp: logEntry.timestamp + }) + .execute(); + } catch (error) { + console.error('Failed to log API call:', error); + // Logging failures shouldn't break the API + } +} + +export function apiLogger() { + return async (req: RequestWithUser, res: Response, next: NextFunction) => { + const startTime = Date.now(); + const originalSend = res.send; + let responseBody: string | null = null; + const errorMessage: string | null = null; + + if (!shouldLogRequest(req.path)) { + return next(); + } + + res.send = function(data) { + try { + if (data && typeof data === 'object') { + responseBody = truncateString(JSON.stringify(sanitizeObject(data))); + } else if (typeof data === 'string') { + responseBody = truncateString(data); + } + } catch { + responseBody = '[SERIALIZATION_ERROR]'; + } + return originalSend.call(this, data); + }; + + res.on('finish', async () => { + const endTime = Date.now(); + const responseTime = endTime - startTime; + + try { + let requestBody: string | null = null; + + if (req.body && Object.keys(req.body).length > 0) { + try { + requestBody = truncateString(JSON.stringify(sanitizeObject(req.body))); + } catch { + requestBody = '[SERIALIZATION_ERROR]'; + } + } + + const ipAddress = getClientIp(req); + const finalIpAddress = Array.isArray(ipAddress) ? ipAddress.join(', ') : ipAddress; + + const logEntry: ApiLogEntry = { + user_id: req.user?.userId || null, + username: req.user?.username || null, + method: req.method, + path: req.originalUrl || req.path, + status_code: res.statusCode, + response_time: responseTime, + ip_address: finalIpAddress, + user_agent: req.get('User-Agent') || null, + request_body: requestBody, + response_body: responseBody, + error_message: errorMessage, + timestamp: new Date() + }; + + setImmediate(() => logApiCall(logEntry)); + } catch (error) { + console.error('Error creating API log entry:', error); + } + }); + + next(); + }; +} + +// Cleanup function to remove old logs +export async function cleanupOldApiLogs(daysToKeep: number = 30): Promise { + try { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + + const result = await mainDb + .deleteFrom('api_logs') + .where('timestamp', '<', cutoffDate) + .execute(); + + console.log(`Cleaned up ${result.length} old API log entries`); + } catch (error) { + console.error('Failed to cleanup old API logs:', error); + } +} \ No newline at end of file diff --git a/server/routes/admin/api-logs.ts b/server/routes/admin/api-logs.ts new file mode 100644 index 0000000..6a07f2d --- /dev/null +++ b/server/routes/admin/api-logs.ts @@ -0,0 +1,86 @@ +import express from 'express'; +import { createAuditLogger } from '../../middleware/auditLogger.js'; +import { requirePermission } from '../../middleware/rolePermissions.js'; +import { getApiLogs, getApiLogById, getApiLogStats } from '../../db/apiLogs.js'; + +const router = express.Router(); + +router.use(requirePermission('audit')); + +// GET: /api/admin/api-logs - Get API logs +router.get('/', createAuditLogger('ADMIN_API_LOGS_ACCESSED'), async (req, res) => { + try { + const pageParam = req.query.page; + const limitParam = req.query.limit; + const userIdParam = req.query.userId; + const methodParam = req.query.method; + const pathParam = req.query.path; + const statusCodeParam = req.query.statusCode; + const dateFromParam = req.query.dateFrom; + const dateToParam = req.query.dateTo; + const searchParam = req.query.search; + + const page = typeof pageParam === 'string' ? parseInt(pageParam) : 1; + const limit = typeof limitParam === 'string' ? parseInt(limitParam) : 50; + const userId = typeof userIdParam === 'string' ? userIdParam : undefined; + const method = typeof methodParam === 'string' ? methodParam : undefined; + const path = typeof pathParam === 'string' ? pathParam : undefined; + const statusCode = typeof statusCodeParam === 'string' ? parseInt(statusCodeParam) : undefined; + const dateFrom = typeof dateFromParam === 'string' ? dateFromParam : undefined; + const dateTo = typeof dateToParam === 'string' ? dateToParam : undefined; + const search = typeof searchParam === 'string' ? searchParam : undefined; + + const data = await getApiLogs(page, limit, { + userId, + method, + path, + statusCode, + dateFrom, + dateTo, + search + }); + + res.json(data); + } catch (error) { + console.error('Error fetching API logs:', error); + res.status(500).json({ error: 'Failed to fetch API logs' }); + } +}); + +// GET: /api/admin/api-logs/stats - Get API logs statistics +router.get('/stats', createAuditLogger('ADMIN_API_LOGS_STATS_ACCESSED'), async (req, res) => { + try { + const daysParam = req.query.days; + const days = typeof daysParam === 'string' ? parseInt(daysParam) : 7; + + const stats = await getApiLogStats(days); + res.json(stats); + } catch (error) { + console.error('Error fetching API logs stats:', error); + res.status(500).json({ error: 'Failed to fetch API logs statistics' }); + } +}); + +// GET: /api/admin/api-logs/:id - Get specific API log +router.get('/:id', createAuditLogger('ADMIN_API_LOG_VIEWED'), async (req, res) => { + try { + const logId = parseInt(req.params.id); + + if (isNaN(logId)) { + return res.status(400).json({ error: 'Invalid log ID' }); + } + + const log = await getApiLogById(logId); + + if (!log) { + return res.status(404).json({ error: 'API log not found' }); + } + + res.json(log); + } catch (error) { + console.error('Error fetching API log:', error); + res.status(500).json({ error: 'Failed to fetch API log' }); + } +}); + +export default router; \ No newline at end of file diff --git a/server/routes/admin/index.ts b/server/routes/admin/index.ts index 54f2f5c..d9e2afc 100644 --- a/server/routes/admin/index.ts +++ b/server/routes/admin/index.ts @@ -21,6 +21,7 @@ import chatReportsRouter from './chat-reports.js'; import updateModalsRouter from './updateModals.js'; import flightLogsRouter from './flight-logs.js'; import feedbackRouter from './feedback.js'; +import apiLogsRouter from './api-logs.js'; const router = express.Router(); @@ -38,6 +39,7 @@ router.use('/chat-reports', chatReportsRouter); router.use('/update-modals', updateModalsRouter); router.use('/flight-logs', flightLogsRouter); router.use('/feedback', feedbackRouter); +router.use('/api-logs', apiLogsRouter); // GET: /api/admin/statistics - Get dashboard statistics router.get('/statistics', requirePermission('admin'), async (req, res) => { diff --git a/server/routes/chats.ts b/server/routes/chats.ts index b798236..cc433d2 100644 --- a/server/routes/chats.ts +++ b/server/routes/chats.ts @@ -4,6 +4,7 @@ import { chatMessageLimiter } from '../middleware/rateLimiting.js'; import requireAuth from '../middleware/auth.js'; import { chatsDb } from '../db/connection.js'; import { decrypt } from '../utils/encryption.js'; +import { sql } from 'kysely'; const router = express.Router(); @@ -35,12 +36,10 @@ router.post('/global/:messageId/report', requireAuth, async (req, res) => { // GET: /api/chats/global - Get global chat messages (last 30 minutes) router.get('/global/messages', requireAuth, async (req, res) => { try { - const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000); - const messages = await chatsDb .selectFrom('global_chat') .selectAll() - .where('sent_at', '>=', thirtyMinutesAgo) + .where((eb) => eb(sql`sent_at`, '>=', sql`(NOW() AT TIME ZONE 'UTC') - INTERVAL '30 minutes'`)) .where('deleted_at', 'is', null) .orderBy('sent_at', 'asc') .execute(); diff --git a/server/websockets/globalChatWebsocket.ts b/server/websockets/globalChatWebsocket.ts index 6c591f5..8d0283d 100644 --- a/server/websockets/globalChatWebsocket.ts +++ b/server/websockets/globalChatWebsocket.ts @@ -56,11 +56,10 @@ export function setupGlobalChatWebsocket(httpServer: Server, sessionUsersWebsock // Clean up old messages every 5 minutes setInterval(async () => { try { - const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000); await chatsDb .updateTable('global_chat') - .set({ deleted_at: sql`NOW()` }) - .where('sent_at', '<', thirtyMinutesAgo) + .set({ deleted_at: sql`(NOW() AT TIME ZONE 'UTC')` }) + .where((eb) => eb(sql`sent_at`, '<', sql`(NOW() AT TIME ZONE 'UTC') - INTERVAL '30 minutes'`)) .where('deleted_at', 'is', null) .execute(); } catch (error) { diff --git a/server/websockets/overviewWebsocket.ts b/server/websockets/overviewWebsocket.ts index d09c50c..7e2bab7 100644 --- a/server/websockets/overviewWebsocket.ts +++ b/server/websockets/overviewWebsocket.ts @@ -242,7 +242,8 @@ export function setupOverviewWebsocket( ts: new Date().toISOString(), }); } - } catch { + } catch (error) { + console.error('Error sending contact message:', error); socket.emit('flightError', { action: 'contactMe', flightId, @@ -299,8 +300,8 @@ async function broadcastToArrivalSessions(flight: Flight): Promise { arrivalsIO.to(session.session_id).emit('arrivalUpdated', flight); } } - } catch { - // Silent + } catch (error) { + console.error('Error broadcasting to arrival sessions:', error); } } @@ -488,9 +489,10 @@ export async function getOverviewData(sessionUsersIO: SessionUsersServer) { sessionId: string; departureAirport: string; }; - const arrivalsByAirport: { [key: string]: ArrivalFlight[] } = {}; - activeSessions.forEach((session) => { - session.flights.forEach((flight) => { + + const arrivalsByAirport: Record = {}; + for (const session of activeSessions) { + for (const flight of session.flights) { if (flight.arrival) { const arrivalIcao = flight.arrival.toUpperCase(); if (!arrivalsByAirport[arrivalIcao]) { @@ -502,8 +504,8 @@ export async function getOverviewData(sessionUsersIO: SessionUsersServer) { departureAirport: session.airportIcao, }); } - }); - }); + } + } return { activeSessions, @@ -530,8 +532,12 @@ export function hasOverviewClients() { } export function broadcastFlightUpdate(sessionId: string, flight: Flight) { + if (!io) { + console.error('Overview IO not initialized'); + return; + } io.emit('flightUpdated', { sessionId, - flight: flight, + flight, }); } diff --git a/src/App.tsx b/src/App.tsx index 144b591..ae101d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,12 +36,13 @@ import AdminRoles from './pages/admin/AdminRoles'; import AdminChatReports from './pages/admin/AdminChatReports'; import AdminFlightLogs from './pages/admin/AdminFlightLogs'; import AdminFeedback from './pages/admin/AdminFeedback'; -import { getTesterSettings } from './utils/fetch/data'; +import AdminApiLogs from './pages/admin/AdminApiLogs'; import { fetchActiveUpdateModal, type UpdateModal, } from './utils/fetch/updateModal'; +import { getTesterSettings } from './utils/fetch/data'; import { fetchGlobalHolidayStatus } from './utils/fetch/data'; export default function App() { @@ -314,6 +315,14 @@ export default function App() { } /> + + + + } + /> } /> diff --git a/src/components/admin/AdminSidebar.tsx b/src/components/admin/AdminSidebar.tsx index a8db50f..ebd1d94 100644 --- a/src/components/admin/AdminSidebar.tsx +++ b/src/components/admin/AdminSidebar.tsx @@ -17,6 +17,7 @@ import { ShieldCheck, LockKeyhole, Star, + HeartPulse, } from 'lucide-react'; import { useState, useEffect } from 'react'; import { useAuth } from '../../hooks/auth/useAuth'; @@ -162,6 +163,13 @@ export default function AdminSidebar({ collapsed: usersCollapsed, setCollapsed: setUsersCollapsed, items: [ + { + icon: HeartPulse, + label: 'API Logs', + path: '/admin/api-logs', + textColor: 'blue-400', + permission: 'audit', + }, { icon: ShieldCheck, label: 'Testers', diff --git a/src/components/dropdowns/AltitudeDropdown.tsx b/src/components/dropdowns/AltitudeDropdown.tsx index 976d059..130f616 100644 --- a/src/components/dropdowns/AltitudeDropdown.tsx +++ b/src/components/dropdowns/AltitudeDropdown.tsx @@ -23,7 +23,7 @@ export default function AltitudeDropdown({ }, [value]); const altitudes: string[] = []; - for (let i = 10; i <= 200; i += 5) { + for (let i = 10; i <= 500; i += 5) { altitudes.push(i.toString().padStart(3, '0')); } diff --git a/src/components/modals/AtisReminderModal.tsx b/src/components/modals/AtisReminderModal.tsx index fbd6db1..fc9bb39 100644 --- a/src/components/modals/AtisReminderModal.tsx +++ b/src/components/modals/AtisReminderModal.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Copy, Check } from 'lucide-react'; +import { Copy, Check, Loader2 } from 'lucide-react'; import Button from '../common/Button'; interface AtisReminderModalProps { @@ -28,6 +28,7 @@ export default function AtisReminderModal({ }: AtisReminderModalProps) { const submitLink = `${window.location?.origin}/submit/${sessionId}`; const [copied, setCopied] = useState(false); + const [isLoading, setIsLoading] = useState(false); // NO SWITCHES? // ⠀⣞⢽⢪⢣⢣⢣⢫⡺⡵⣝⡮⣗⢷⢽⢽⢽⣮⡷⡽⣜⣜⢮⢺⣜⢷⢽⢝⡽⣝ // ⠸⡸⠜⠕⠕⠁⢁⢇⢏⢽⢺⣪⡳⡝⣎⣏⢯⢞⡿⣟⣷⣳⢯⡷⣽⢽⢯⣳⣫⠇ @@ -76,6 +77,24 @@ export default function AtisReminderModal({ } }; + const handleCopyAndContinue = async () => { + if (isLoading || copied) return; + + try { + await navigator.clipboard.writeText(`${airportName}\n\n${formattedAtis}`); + setCopied(true); + } catch (err) { + console.error('Failed to copy:', err); + } + + setTimeout(() => { + setIsLoading(true); + setTimeout(() => { + onContinue(); + }, 1000); + }, 500); + }; + return (
@@ -107,10 +126,26 @@ export default function AtisReminderModal({
diff --git a/src/components/modals/CallsignHelpModal.tsx b/src/components/modals/CallsignHelpModal.tsx new file mode 100644 index 0000000..5174863 --- /dev/null +++ b/src/components/modals/CallsignHelpModal.tsx @@ -0,0 +1,362 @@ +import { X, ExternalLink } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +interface CallsignHelpModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function CallsignHelpModal({ + isOpen, + onClose, +}: CallsignHelpModalProps) { + const [isScrolled, setIsScrolled] = useState(false); + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + if (!isOpen) return null; + + return ( +
+
+
+

+ Callsign Formatting Guide +

+ +
+ +
+ +
+ setIsScrolled((e.target as HTMLElement).scrollTop > 0) + } + > +
+

+ Important: Enter your{' '} + aircraft callsign only, not your spoken + callsign. +

+
+ +
+

+ Commercial Airlines +

+
+

+ Use the 3-letter ICAO airline code followed by + the flight number: +

+
+
+ + UAL123 + + + United Airlines Flight 123 + +
+
+ + DLH456 + + + Lufthansa Flight 456 + +
+
+ + BAW789 + + + British Airways Flight 789 + +
+
+
+

+ Radiotelephony: FAA vs ICAO Procedures +

+
+

+ FAA (US): Group digits - "United Twenty-Three Fifty-Three" (UAL2353) +

+

+ ICAO (International): Individual digits - "United Two Three Five Three" (UAL2353) +

+

+ Your written callsign remains the same (UAL2353) regardless of region. +

+
+
+
+
+ +
+

+ General Aviation (GA) +

+
+

+ Use your full tail number/registration{' '} + without spaces or dashes: +

+
+
+ + N978CP + + + U.S. registered aircraft + +
+
+ + GBABC + + + UK registered aircraft + +
+
+ + CFTCA + + + Canadian registered aircraft + +
+
+ +
+

+ + Project Flight & PTFS: + {' '} + Due to limited registration numbers, append your flight number after the registration: +

+
+ + N34S4P3212 + + + N34S4P (registration) + 3212 (flight number) + +
+
+ +
+

+ + Common Mistake: + {' '} + Do NOT use your spoken callsign here! +

+
+
+ + + Citation + + + (this is your aircraft type/spoken callsign) + +
+
+ + + N525TA + + + (correct - your tail number) + +
+
+

+ While you may say "Citation Five Two Five Tango Alpha" on the + radio, your callsign in the system should be{' '} + N525TA. +

+
+
+
+ +
+

+ Weight Class Suffixes +

+
+

+ Do NOT include weight + class suffixes in your callsign: +

+
+
+ + + UAL123 HEAVY + +
+
+ + + AFR447SUPER + +
+
+ + + UAL123 + +
+
+

+ You would append "Heavy" or "Super" when speaking on the radio + (e.g., "United One Twenty-Three Heavy"), but not in the written + callsign field. +

+
+
+ +
+

+ Quick Reference +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Flight Type + + Format + + Example +
Commercial + + [ICAO][Number] + + + + SWA1234 + +
General Aviation + + [Registration] + + + + N12345 + +
Cargo/Charter + + [ICAO][Number] + + + + FDX9876 + +
+
+
+ +
+

+ Additional Resources +

+ +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/components/tools/FeedbackBanner.tsx b/src/components/tools/FeedbackBanner.tsx index a94a951..47065f2 100644 --- a/src/components/tools/FeedbackBanner.tsx +++ b/src/components/tools/FeedbackBanner.tsx @@ -1,5 +1,12 @@ -import { useState, useRef, useEffect } from 'react'; -import { Star, Check, X, MessageCircle } from 'lucide-react'; +import { useState, useEffect, useRef, useMemo } from 'react'; +import { + Star, + Check, + X, + MessageCircle, + ChevronDown, + ChevronUp, +} from 'lucide-react'; import { submitFeedback } from '../../utils/fetch/feedback'; import { Portal } from './Portal'; import Button from '../common/Button'; @@ -15,43 +22,142 @@ export default function FeedbackBanner({ onClose, }: FeedbackBannerProps) { const [rating, setRating] = useState(0); - const [comment, setComment] = useState(''); - const [showComment, setShowComment] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false); const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info'; } | null>(null); - const textareaRefDesktop = useRef(null); - const textareaRefMobile = useRef(null); - const [isInitialLoad, setIsInitialLoad] = useState(true); - const [commentOpenedByUser, setCommentOpenedByUser] = useState(false); + const [showDetailedModal, setShowDetailedModal] = useState(false); + const [detailedRatings, setDetailedRatings] = useState({ + userInterface: 0, + performance: 0, + features: 0, + easeOfUse: 0, + overall: 0, + }); + const [comment, setComment] = useState(''); + const [hoveredCategory, setHoveredCategory] = useState(null); + const [hoveredRating, setHoveredRating] = useState(0); + const [hoveredBannerRating, setHoveredBannerRating] = useState(0); + const [overallManuallySet, setOverallManuallySet] = useState(false); + const [isCommentExpanded, setIsCommentExpanded] = useState(false); + const desktopTextareaRef = useRef(null); + const mobileTextareaRef = useRef(null); - const getActiveTextarea = () => - textareaRefMobile.current ?? textareaRefDesktop.current; + useEffect(() => { + if (isCommentExpanded) { + const textarea = desktopTextareaRef.current || mobileTextareaRef.current; + if (textarea) { + setTimeout(() => { + textarea.focus(); + const length = textarea.value.length; + textarea.setSelectionRange(length, length); + }, 0); + } + } + }, [isCommentExpanded]); useEffect(() => { - const timer = setTimeout(() => setIsInitialLoad(false), 100); - return () => clearTimeout(timer); - }, []); + if (showDetailedModal) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [showDetailedModal]); useEffect(() => { - if (showComment && commentOpenedByUser && !isInitialLoad) { - const textarea = getActiveTextarea(); - if (textarea) { - textarea.focus({ preventScroll: true }); + if (!overallManuallySet) { + const sum = + detailedRatings.userInterface + + detailedRatings.performance + + detailedRatings.features + + detailedRatings.easeOfUse; + const avg = sum / 4; + const rounded = Math.round(avg); + // Only update if the value actually changed + if (detailedRatings.overall !== rounded) { + setDetailedRatings((prev) => ({ ...prev, overall: rounded })); } } - }, [showComment, commentOpenedByUser, isInitialLoad]); + }, [ + detailedRatings.userInterface, + detailedRatings.performance, + detailedRatings.features, + detailedRatings.easeOfUse, + detailedRatings.overall, + overallManuallySet, + ]); + + + const handleRatingClick = ( + categoryKey: + | 'userInterface' + | 'performance' + | 'features' + | 'easeOfUse' + | 'overall', + rating: number + ) => { + if (categoryKey === 'overall') { + setOverallManuallySet(true); + } + setDetailedRatings((prev) => ({ ...prev, [categoryKey]: rating })); + }; + + const handleSubmitDetailed = async () => { + if (detailedRatings.overall === 0) return; + + try { + setIsSubmitting(true); + const categoriesText = `UI: ${detailedRatings.userInterface}/5, Performance: ${detailedRatings.performance}/5, Features: ${detailedRatings.features}/5, Ease of Use: ${detailedRatings.easeOfUse}/5, Overall: ${detailedRatings.overall}/5`; + const fullComment = comment + ? `${categoriesText}\n\n${comment}` + : categoriesText; + await submitFeedback(detailedRatings.overall, fullComment); + setIsSubmitted(true); + + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + 30); + document.cookie = `feedback_submitted=true; expires=${expiryDate.toUTCString()}; path=/`; + + setTimeout(() => { + onClose(); + setTimeout(() => { + setRating(0); + setIsSubmitted(false); + setIsSubmitting(false); + setShowDetailedModal(false); + setDetailedRatings({ + userInterface: 0, + performance: 0, + features: 0, + easeOfUse: 0, + overall: 0, + }); + setComment(''); + setIsCommentExpanded(false); + }, 300); + }, 1500); + } catch (error) { + console.error('Error submitting feedback:', error); + setToast({ + message: 'Failed to submit feedback. Please try again later.', + type: 'error', + }); + setIsSubmitting(false); + } + }; const handleSubmit = async () => { if (rating === 0) return; - const commentValue = (getActiveTextarea()?.value || '').trim(); try { setIsSubmitting(true); - await submitFeedback(rating, commentValue || undefined); + await submitFeedback(rating, undefined); setIsSubmitted(true); const expiryDate = new Date(); @@ -62,12 +168,8 @@ export default function FeedbackBanner({ onClose(); setTimeout(() => { setRating(0); - setComment(''); - setShowComment(false); setIsSubmitted(false); setIsSubmitting(false); - if (textareaRefDesktop.current) textareaRefDesktop.current.value = ''; - if (textareaRefMobile.current) textareaRefMobile.current.value = ''; }, 300); }, 1500); } catch (error) { @@ -90,272 +192,587 @@ export default function FeedbackBanner({ onClose(); setTimeout(() => { setRating(0); - setComment(''); - setShowComment(false); - if (textareaRefDesktop.current) textareaRefDesktop.current.value = ''; - if (textareaRefMobile.current) textareaRefMobile.current.value = ''; }, 300); }; - const handleShowComment = () => { - setShowComment((prev) => { - if (!prev) setCommentOpenedByUser(true); - return !prev; - }); + const handleShowDetailedFeedback = () => { + setShowDetailedModal(true); + setOverallManuallySet(false); + setIsCommentExpanded(false); }; - if (!isOpen) return null; - - const DesktopFeedback = () => ( -
-
-
-
- {/* Main feedback section */} -
- {isSubmitted ? ( -
-
- -
- - Thanks for your feedback! - -
- ) : ( -
- {/* Left */} -
- - How's your experience? - - - You can leave a comment with the chat icon. - -
+ const handleCloseDetailed = () => { + if (isSubmitting) return; + setShowDetailedModal(false); + setOverallManuallySet(false); + setIsCommentExpanded(false); + setTimeout(() => { + setDetailedRatings({ + userInterface: 0, + performance: 0, + features: 0, + easeOfUse: 0, + overall: 0, + }); + setComment(''); + setHoveredCategory(null); + setHoveredRating(0); + }, 300); + }; - {/* Middle */} -
- {[1, 2, 3, 4, 5].map((star) => ( - - ))} -
+ const categories = [ + { + key: 'userInterface' as const, + label: 'User Interface', + description: 'Design and visual appeal', + }, + { + key: 'performance' as const, + label: 'Performance', + description: 'Speed and reliability', + }, + { + key: 'features' as const, + label: 'Global Chat and ACARS', + description: 'Communication features', + }, + { + key: 'easeOfUse' as const, + label: 'Ease of Use', + description: 'Intuitiveness and simplicity', + }, + { + key: 'overall' as const, + label: 'Overall Experience', + description: 'Your overall satisfaction', + }, + ]; - {/* Right */} -
- - - - - + const DesktopFeedback = useMemo(() => ( +
+ {showDetailedModal ? ( +
+ {/* Info banner */} +
+

+ Help us improve! Rate + different aspects of PFControl and let us know what you think. +

+
+ + {/* Category Ratings - hidden when comment is expanded */} + {!isCommentExpanded && ( +
+ {categories.map((category) => { + const currentRating = detailedRatings[category.key]; + const displayRating = + hoveredCategory === category.key && hoveredRating > 0 + ? hoveredRating + : currentRating; + + return ( +
+
+
+ + {category.label} + {category.key === 'overall' && ( + * + )} + + + {category.description} + +
+
{ + setHoveredCategory(null); + setHoveredRating(0); + }} + > + {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+
-
- )} + ); + })}
+ )} - {/* Comment section - always rendered */} -
-
-
- - -
+ {/* Comment Section */} +
+ {!isCommentExpanded ? ( + + ) : ( + <> +