diff --git a/SerpentRace_Backend/src/Application/Services/LoggingService.ts b/SerpentRace_Backend/src/Application/Services/LoggingService.ts index 16617368..fcafc7ce 100644 --- a/SerpentRace_Backend/src/Application/Services/LoggingService.ts +++ b/SerpentRace_Backend/src/Application/Services/LoggingService.ts @@ -90,7 +90,9 @@ export class LoggingService { secretKey: process.env.MINIO_SECRET_KEY }); - this.ensureBucketExists(); + this.ensureBucketExists().catch(error => { + console.warn('MinIO bucket initialization failed:', error.message); + }); } else { console.warn('Minio configuration not found. Logs will only be stored locally and in console.'); } @@ -105,7 +107,9 @@ export class LoggingService { secretKey: process.env.MINIO_SECRET_KEY || 'serpentrace123!' }); - this.ensureBucketExists(); + this.ensureBucketExists().catch(error => { + console.warn('MinIO bucket initialization failed:', error.message); + }); } else { console.log('Development mode: MinIO disabled. Set ENABLE_MINIO=true to enable MinIO logging.'); this.minioClient = null; diff --git a/SerpentRace_Backend/src/Application/Services/WebSocketService.ts b/SerpentRace_Backend/src/Application/Services/WebSocketService.ts new file mode 100644 index 00000000..0ef9036e --- /dev/null +++ b/SerpentRace_Backend/src/Application/Services/WebSocketService.ts @@ -0,0 +1,1410 @@ +import { Server as HttpServer } from 'http'; +import { Server as SocketIOServer, Socket } from 'socket.io'; +import { JWTService, TokenPayload } from './JWTService'; +import { ChatRepository } from '../../Infrastructure/Repository/ChatRepository'; +import { ChatArchiveRepository } from '../../Infrastructure/Repository/ChatArchiveRepository'; +import { UserRepository } from '../../Infrastructure/Repository/UserRepository'; +import { ChatAggregate, ChatType, ChatTypeType, Message } from '../../Domain/Chat/ChatAggregate'; +import { UserState } from '../../Domain/User/UserAggregate'; +import { logAuth, logError, logRequest, logWarning } from './Logger'; +import { RedisService, ActiveChatData } from './RedisService'; +import { v4 as uuidv4 } from 'uuid'; + +interface AuthenticatedSocket extends Socket { + userId?: string; + authLevel?: 0 | 1; + userStatus?: UserState; + orgId?: string | null; +} + +interface JoinChatData { + chatId: string; +} + +interface SendMessageData { + chatId: string; + message: string; +} + +interface CreateGroupData { + name: string; + userIds: string[]; +} + +interface CreateDirectChatData { + targetUserId: string; +} + +interface CreateGameChatData { + gameId: string; + gameName: string; + playerIds: string[]; +} + +interface DeleteChatData { + chatId: string; +} + +interface DeleteChatArchiveData { + archiveId: string; +} + +interface DeleteMessageData { + chatId: string; + messageId: string; +} + +// Game-related WebSocket interfaces (prepared for future implementation) +interface JoinGameRoomData { + gameCode: string; +} + +interface LeaveGameRoomData { + gameCode: string; +} + +interface GameStateUpdateData { + gameId: string; + gameCode: string; + players: string[]; + state: string; + currentTurn?: string; +} + +interface GameActionData { + gameId: string; + gameCode: string; + playerId: string; + action: 'pick_card' | 'play_card' | 'end_turn' | 'leave_game'; + data?: any; +} + +export class WebSocketService { + private io: SocketIOServer; + private jwtService: JWTService; + private chatRepository: ChatRepository; + private chatArchiveRepository: ChatArchiveRepository; + private userRepository: UserRepository; + private redisService: RedisService; + private connectedUsers: Map = new Map(); + private chatTimeout: number; + private maxMessagesPerUser: number; + private messageCleanupWeeks: number; + private userMessageCounts: Map = new Map(); + + constructor(httpServer: HttpServer) { + this.io = new SocketIOServer(httpServer, { + cors: { + origin: ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:8080'], + methods: ['GET', 'POST'], + credentials: true + } + }); + + this.jwtService = new JWTService(); + this.chatRepository = new ChatRepository(); + this.chatArchiveRepository = new ChatArchiveRepository(); + this.userRepository = new UserRepository(); + this.redisService = RedisService.getInstance(); + this.chatTimeout = parseInt(process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES || '30'); + this.maxMessagesPerUser = parseInt(process.env.CHAT_MAX_MESSAGES_PER_USER || '100'); + this.messageCleanupWeeks = parseInt(process.env.CHAT_MESSAGE_CLEANUP_WEEKS || '4'); + + // Initialize Redis connection (handle async without await in constructor) + this.initializeRedis().catch(error => { + logError('Redis initialization failed in WebSocketService constructor', error as Error); + }); + + this.setupSocketHandlers(); + this.setupArchivingScheduler(); + + logRequest('WebSocket service initialized', undefined, undefined, { + chatTimeoutMinutes: this.chatTimeout + }); + } + + private async initializeRedis(): Promise { + try { + await this.redisService.connect(); + } catch (error) { + logError('Failed to initialize Redis connection', error as Error); + } + } + + private setupSocketHandlers() { + this.io.use(async (socket: AuthenticatedSocket, next) => { + try { + const token = socket.handshake.auth.token || socket.handshake.headers.cookie + ?.split(';') + .find(c => c.trim().startsWith('auth_token=')) + ?.split('=')[1]; + + if (!token) { + logWarning('WebSocket connection rejected - No token provided', { + socketId: socket.id, + ip: socket.handshake.address + }); + return next(new Error('Authentication required')); + } + + // Create a mock request object for JWT verification + const mockRequest = { + headers: { + authorization: `Bearer ${token}`, + cookie: `auth_token=${token}` + }, + cookies: { + auth_token: token + } + } as any; + + const payload = this.jwtService.verify(mockRequest); + if (!payload) { + logWarning('WebSocket connection rejected - Invalid token', { + socketId: socket.id, + ip: socket.handshake.address + }); + return next(new Error('Invalid token')); + } + + socket.userId = payload.userId; + socket.authLevel = payload.authLevel; + socket.userStatus = payload.userStatus; + socket.orgId = payload.orgId; + + logAuth('WebSocket connection authenticated', payload.userId, { + socketId: socket.id, + authLevel: payload.authLevel, + userStatus: payload.userStatus, + orgId: payload.orgId + }); + + next(); + } catch (error) { + logError('WebSocket authentication error', error as Error); + next(new Error('Authentication failed')); + } + }); + + this.io.on('connection', (socket: AuthenticatedSocket) => { + this.handleConnection(socket); + }); + } + + private async handleConnection(socket: AuthenticatedSocket) { + const userId = socket.userId!; + + // Store connected user + this.connectedUsers.set(userId, socket); + + // Load user's active chats and join rooms + try { + const userChats = await this.chatRepository.findActiveChatsForUser(userId); + const chatIds = userChats.map(chat => chat.id); + + // Join all chat rooms + chatIds.forEach(chatId => { + socket.join(chatId); + }); + + // Store user's chat memberships in Redis + await this.redisService.setActiveUser(userId, { + userId, + activeChatIds: chatIds, + lastActivity: new Date(), + isOnline: true + }); + + // Also store each active chat in Redis + for (const chat of userChats) { + await this.redisService.setActiveChat(chat.id, { + chatId: chat.id, + participants: chat.users, + lastActivity: chat.lastActivity || new Date(), + messageCount: chat.messages.length, + chatType: chat.type as 'direct' | 'group' | 'game', + gameId: chat.gameId || undefined, + name: chat.name || undefined + }); + } + + logAuth('User connected to WebSocket', userId, { + socketId: socket.id, + activeChats: chatIds.length + }); + + // Send user their active chats with unread counts + const chatsWithUnread = await Promise.all(userChats.map(async (chat) => ({ + id: chat.id, + type: chat.type, + name: chat.name, + gameId: chat.gameId, + users: chat.users, + lastActivity: chat.lastActivity, + unreadCount: this.calculateUnreadMessages(chat, userId), + isArchived: false + }))); + + socket.emit('chats:list', chatsWithUnread); + + } catch (error) { + logError('Error loading user chats on connection', error as Error, undefined, undefined); + socket.emit('error', { message: 'Failed to load chats' }); + } + + // Setup event handlers + socket.on('chat:join', (data: JoinChatData) => this.handleJoinChat(socket, data)); + socket.on('chat:leave', (data: JoinChatData) => this.handleLeaveChat(socket, data)); + socket.on('message:send', (data: SendMessageData) => this.handleSendMessage(socket, data)); + socket.on('group:create', (data: CreateGroupData) => this.handleCreateGroup(socket, data)); + socket.on('chat:direct', (data: CreateDirectChatData) => this.handleCreateDirectChat(socket, data)); + socket.on('game:chat:create', (data: CreateGameChatData) => this.handleCreateGameChat(socket, data)); + socket.on('chat:history', (data: JoinChatData) => this.handleGetChatHistory(socket, data)); + socket.on('chat:delete', (data: DeleteChatData) => this.handleDeleteChat(socket, data)); + socket.on('chat:archive:delete', (data: DeleteChatArchiveData) => this.handleDeleteChatArchive(socket, data)); + socket.on('message:delete', (data: DeleteMessageData) => this.handleDeleteMessage(socket, data)); + + socket.on('disconnect', () => this.handleDisconnection(socket)); + } + + private async handleJoinChat(socket: AuthenticatedSocket, data: JoinChatData) { + try { + const userId = socket.userId!; + const chat = await this.chatRepository.findById(data.chatId); + + if (!chat) { + socket.emit('error', { message: 'Chat not found' }); + return; + } + + // Check if user is member of this chat + if (!chat.users.includes(userId)) { + socket.emit('error', { message: 'Unauthorized to join this chat' }); + return; + } + + // Join the chat room + socket.join(data.chatId); + + // Add to user's active chats in Redis + await this.redisService.addUserToChat(userId, data.chatId); + + // Update chat activity in Redis + await this.redisService.updateChatActivity(data.chatId); + + // Update last activity in database + await this.chatRepository.update(data.chatId, { lastActivity: new Date() }); + + logAuth('User joined chat', userId, { + chatId: data.chatId, + chatType: chat.type + }); + + socket.emit('chat:joined', { + chatId: data.chatId, + messages: chat.messages.slice(-10) // Last 10 messages + }); + + } catch (error) { + logError('Error joining chat', error as Error); + socket.emit('error', { message: 'Failed to join chat' }); + } + } + + private async handleLeaveChat(socket: AuthenticatedSocket, data: JoinChatData) { + try { + const userId = socket.userId!; + + // Leave the chat room + socket.leave(data.chatId); + + // Remove from user's active chats in Redis + await this.redisService.removeUserFromChat(userId, data.chatId); + + logAuth('User left chat', userId, { + chatId: data.chatId + }); + + socket.emit('chat:left', { chatId: data.chatId }); + + } catch (error) { + logError('Error leaving chat', error as Error); + socket.emit('error', { message: 'Failed to leave chat' }); + } + } + + private async handleSendMessage(socket: AuthenticatedSocket, data: SendMessageData) { + try { + const userId = socket.userId!; + + // Rate limiting check + if (!this.checkMessageRateLimit(userId)) { + socket.emit('error', { message: `Rate limit exceeded. Maximum ${this.maxMessagesPerUser} messages per minute allowed.` }); + return; + } + + // Validate message is string and not empty + if (typeof data.message !== 'string' || !data.message.trim()) { + socket.emit('error', { message: 'Message must be a non-empty string' }); + return; + } + + const chat = await this.chatRepository.findById(data.chatId); + if (!chat) { + socket.emit('error', { message: 'Chat not found' }); + return; + } + + // Check if user is member of this chat + if (!chat.users.includes(userId)) { + socket.emit('error', { message: 'Unauthorized to send message to this chat' }); + return; + } + + // Create message + const message: Message = { + id: uuidv4(), + date: new Date(), + userid: userId, + text: data.message.trim() + }; + + // Manage message history based on chat type + let updatedMessages = [...chat.messages, message]; + updatedMessages = this.pruneMessages(updatedMessages, chat.type); + + // Update chat + await this.chatRepository.update(data.chatId, { + messages: updatedMessages, + lastActivity: new Date() + }); + + // Update chat activity in Redis with new message count + await this.redisService.updateChatActivity(data.chatId, updatedMessages.length); + + // Broadcast to all users in the chat room + this.io.to(data.chatId).emit('message:received', { + chatId: data.chatId, + message: message + }); + + // Send notifications to offline users + await this.notifyOfflineUsers(chat, message); + + logAuth('Message sent', userId, { + chatId: data.chatId, + messageLength: data.message.length, + chatType: chat.type + }); + + } catch (error) { + logError('Error sending message', error as Error); + socket.emit('error', { message: 'Failed to send message' }); + } + } + + private async handleCreateGroup(socket: AuthenticatedSocket, data: CreateGroupData) { + try { + const userId = socket.userId!; + + // Check if user is premium (required to create groups) + const user = await this.userRepository.findById(userId); + if (!user || user.state !== UserState.VERIFIED_PREMIUM) { + socket.emit('error', { message: 'Premium subscription required to create groups' }); + return; + } + + // Validate group data + if (!data.name?.trim()) { + socket.emit('error', { message: 'Group name is required' }); + return; + } + + if (!data.userIds || data.userIds.length === 0) { + socket.emit('error', { message: 'At least one member is required' }); + return; + } + + // Verify all users exist + const members = await Promise.all( + data.userIds.map(id => this.userRepository.findById(id)) + ); + + if (members.some(member => !member)) { + socket.emit('error', { message: 'One or more users not found' }); + return; + } + + // Create group chat + const groupChat = await this.chatRepository.create({ + type: ChatType.GROUP, + name: data.name.trim(), + createdBy: userId, + users: [userId, ...data.userIds], // Include creator + messages: [], + lastActivity: new Date() + }); + + // Add all members to the group room and store in Redis + const allMemberIds = data.userIds.concat(userId); + for (const memberId of allMemberIds) { + const memberSocket = this.connectedUsers.get(memberId); + if (memberSocket) { + memberSocket.join(groupChat.id); + } + + // Update user's chat list in Redis + await this.redisService.addUserToChat(memberId, groupChat.id); + } + + // Store the group chat in Redis + await this.redisService.setActiveChat(groupChat.id, { + chatId: groupChat.id, + participants: allMemberIds, + lastActivity: new Date(), + messageCount: 0, + chatType: 'group', + name: groupChat.name || undefined + }); + + // Notify all members + this.io.to(groupChat.id).emit('group:created', { + chat: { + id: groupChat.id, + type: groupChat.type, + name: groupChat.name, + createdBy: groupChat.createdBy, + users: groupChat.users, + messages: [] + } + }); + + logAuth('Group created', userId, { + groupId: groupChat.id, + groupName: data.name, + memberCount: groupChat.users.length + }); + + } catch (error) { + logError('Error creating group', error as Error); + socket.emit('error', { message: 'Failed to create group' }); + } + } + + private async handleCreateDirectChat(socket: AuthenticatedSocket, data: CreateDirectChatData) { + try { + const userId = socket.userId!; + + // Validate target user exists + const targetUser = await this.userRepository.findById(data.targetUserId); + if (!targetUser) { + socket.emit('error', { message: 'Target user not found' }); + return; + } + + // Check if direct chat already exists + const existingChats = await this.chatRepository.findByUserId(userId); + const existingDirectChat = existingChats.find(chat => + chat.type === ChatType.DIRECT && + chat.users.length === 2 && + chat.users.includes(data.targetUserId) + ); + + if (existingDirectChat) { + socket.emit('chat:direct:exists', { + chatId: existingDirectChat.id + }); + return; + } + + // Create direct chat + const directChat = await this.chatRepository.create({ + type: ChatType.DIRECT, + users: [userId, data.targetUserId], + messages: [], + lastActivity: new Date() + }); + + // Add both users to the chat room if they're online and store in Redis + const memberIds = [userId, data.targetUserId]; + for (const memberId of memberIds) { + const memberSocket = this.connectedUsers.get(memberId); + if (memberSocket) { + memberSocket.join(directChat.id); + } + + // Update user's chat list in Redis + await this.redisService.addUserToChat(memberId, directChat.id); + } + + // Store the direct chat in Redis + await this.redisService.setActiveChat(directChat.id, { + chatId: directChat.id, + participants: memberIds, + lastActivity: new Date(), + messageCount: 0, + chatType: 'direct' + }); + + // Notify both users + this.io.to(directChat.id).emit('chat:direct:created', { + chat: { + id: directChat.id, + type: directChat.type, + users: directChat.users, + messages: [] + } + }); + + logAuth('Direct chat created', userId, { + chatId: directChat.id, + targetUserId: data.targetUserId + }); + + } catch (error) { + logError('Error creating direct chat', error as Error); + socket.emit('error', { message: 'Failed to create direct chat' }); + } + } + + private async handleCreateGameChat(socket: AuthenticatedSocket, data: CreateGameChatData) { + try { + const userId = socket.userId!; + + // Check if game chat already exists + const existingGameChat = await this.chatRepository.findByGameId(data.gameId); + if (existingGameChat) { + socket.emit('game:chat:exists', { + chatId: existingGameChat.id + }); + return; + } + + // Create game chat + const gameChat = await this.chatRepository.create({ + type: ChatType.GAME, + name: data.gameName, + gameId: data.gameId, + users: data.playerIds, + messages: [], + lastActivity: new Date() + }); + + // Add all players to the game chat room if they're online and store in Redis + for (const playerId of data.playerIds) { + const playerSocket = this.connectedUsers.get(playerId); + if (playerSocket) { + playerSocket.join(gameChat.id); + } + + // Update user's chat list in Redis + await this.redisService.addUserToChat(playerId, gameChat.id); + } + + // Store the game chat in Redis + await this.redisService.setActiveChat(gameChat.id, { + chatId: gameChat.id, + participants: data.playerIds, + lastActivity: new Date(), + messageCount: 0, + chatType: 'game', + gameId: gameChat.gameId || undefined, + name: gameChat.name || undefined + }); + + // Notify all players + this.io.to(gameChat.id).emit('game:chat:created', { + chat: { + id: gameChat.id, + type: gameChat.type, + name: gameChat.name, + gameId: gameChat.gameId, + users: gameChat.users, + messages: [] + } + }); + + logAuth('Game chat created', userId, { + chatId: gameChat.id, + gameId: data.gameId, + gameName: data.gameName, + playerCount: data.playerIds.length + }); + + } catch (error) { + logError('Error creating game chat', error as Error); + socket.emit('error', { message: 'Failed to create game chat' }); + } + } + + private async handleGetChatHistory(socket: AuthenticatedSocket, data: JoinChatData) { + try { + const userId = socket.userId!; + const chat = await this.chatRepository.findById(data.chatId); + + if (!chat) { + // Check if it's archived + const archived = await this.chatRepository.getArchivedChat(data.chatId); + if (archived) { + socket.emit('chat:history:archived', { + chatId: data.chatId, + messages: archived.archivedMessages, + chatType: archived.chatType, + isGameChat: archived.chatType === ChatType.GAME + }); + } else { + socket.emit('error', { message: 'Chat not found' }); + } + return; + } + + // Check if user has access + if (!chat.users.includes(userId)) { + socket.emit('error', { message: 'Unauthorized to view this chat' }); + return; + } + + socket.emit('chat:history', { + chatId: data.chatId, + messages: chat.messages, + chatInfo: { + type: chat.type, + name: chat.name, + gameId: chat.gameId, + users: chat.users + } + }); + + } catch (error) { + logError('Error getting chat history', error as Error); + socket.emit('error', { message: 'Failed to get chat history' }); + } + } + + private async handleDeleteChat(socket: AuthenticatedSocket, data: DeleteChatData) { + try { + const userId = socket.userId!; + const chat = await this.chatRepository.findById(data.chatId); + + if (!chat) { + socket.emit('error', { message: 'Chat not found' }); + return; + } + + // Check if user is member of this chat + if (!chat.users.includes(userId)) { + socket.emit('error', { message: 'Unauthorized to delete this chat' }); + return; + } + + // Perform soft delete + const deletedChat = await this.chatRepository.softDelete(data.chatId); + if (!deletedChat) { + socket.emit('error', { message: 'Failed to delete chat' }); + return; + } + + // Remove from Redis active chats + await this.redisService.removeActiveChat(data.chatId); + + // Notify all participants that the chat has been deleted + this.io.to(data.chatId).emit('chat:deleted', { + chatId: data.chatId, + deletedBy: userId + }); + + // Remove all users from the chat room + for (const participantId of chat.users) { + const participantSocket = this.connectedUsers.get(participantId); + if (participantSocket) { + participantSocket.leave(data.chatId); + } + // Remove from user's active chats in Redis + await this.redisService.removeUserFromChat(participantId, data.chatId); + } + + logAuth('Chat deleted', userId, { + chatId: data.chatId, + chatType: chat.type, + participantCount: chat.users.length + }); + + socket.emit('chat:delete:success', { + chatId: data.chatId, + message: 'Chat deleted successfully' + }); + + } catch (error) { + logError('Error deleting chat', error as Error); + socket.emit('error', { message: 'Failed to delete chat' }); + } + } + + private async handleDeleteChatArchive(socket: AuthenticatedSocket, data: DeleteChatArchiveData) { + try { + const userId = socket.userId!; + const archive = await this.chatArchiveRepository.findById(data.archiveId); + + if (!archive) { + socket.emit('error', { message: 'Chat archive not found' }); + return; + } + + // Check if user was a participant in the archived chat + if (!archive.participants.includes(userId)) { + socket.emit('error', { message: 'Unauthorized to delete this chat archive' }); + return; + } + + // Hard delete the archive (since it's already archived) + await this.chatArchiveRepository.delete(data.archiveId); + + logAuth('Chat archive deleted', userId, { + archiveId: data.archiveId, + originalChatId: archive.chatId, + chatType: archive.chatType, + participantCount: archive.participants.length + }); + + socket.emit('chat:archive:delete:success', { + archiveId: data.archiveId, + message: 'Chat archive deleted successfully' + }); + + } catch (error) { + logError('Error deleting chat archive', error as Error); + socket.emit('error', { message: 'Failed to delete chat archive' }); + } + } + + private async handleDeleteMessage(socket: AuthenticatedSocket, data: DeleteMessageData) { + try { + const userId = socket.userId!; + + // Check if user has admin/moderator privileges + const user = await this.userRepository.findById(userId); + if (!user || user.state !== UserState.ADMIN) { // Check if user is admin + socket.emit('error', { message: 'Insufficient permissions to delete messages' }); + return; + } + + const success = await this.deleteMessage(data.chatId, data.messageId, userId); + if (success) { + socket.emit('message:delete:success', { + chatId: data.chatId, + messageId: data.messageId, + message: 'Message deleted successfully' + }); + } else { + socket.emit('error', { message: 'Failed to delete message or message not found' }); + } + + } catch (error) { + logError('Error handling delete message request', error as Error); + socket.emit('error', { message: 'Failed to delete message' }); + } + } + + private async handleDisconnection(socket: AuthenticatedSocket) { + const userId = socket.userId; + if (userId) { + this.connectedUsers.delete(userId); + + // Update user status in Redis + const userData = await this.redisService.getActiveUser(userId); + if (userData) { + userData.isOnline = false; + userData.lastActivity = new Date(); + await this.redisService.setActiveUser(userId, userData); + } + + logAuth('User disconnected from WebSocket', userId, { + socketId: socket.id + }); + } + } + + // Utility methods + private calculateUnreadMessages(chat: ChatAggregate, userId: string): number { + // Simple implementation - count messages after user's last seen + // In production, you'd store lastSeen timestamp per user per chat + return chat.messages.filter(msg => msg.userid !== userId).length; + } + + private pruneMessages(messages: Message[], chatType: ChatTypeType): Message[] { + const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000); + + // Remove messages older than 2 weeks + let prunedMessages = messages.filter(msg => new Date(msg.date) > twoWeeksAgo); + + // For group chats, only apply the 2-week time limit (unlimited messages per user) + if (chatType === ChatType.GROUP) { + return prunedMessages.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + } + + // For direct and game chats, apply both time limit and per-user message limit + // Group by user and keep last 10 messages per user + const messagesByUser = new Map(); + prunedMessages.forEach(msg => { + if (!messagesByUser.has(msg.userid)) { + messagesByUser.set(msg.userid, []); + } + messagesByUser.get(msg.userid)!.push(msg); + }); + + // Keep only last 10 messages per user + const finalMessages: Message[] = []; + messagesByUser.forEach((userMessages, userId) => { + const last10 = userMessages.slice(-10); + finalMessages.push(...last10); + }); + + // Sort by date + return finalMessages.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + } + + private async notifyOfflineUsers(chat: ChatAggregate, message: Message) { + // Find users who are not currently connected + const offlineUsers = chat.users.filter(userId => + userId !== message.userid && !this.connectedUsers.has(userId) + ); + + // In a real implementation, you would send push notifications or emails here + if (offlineUsers.length > 0) { + logRequest('Offline users to notify', undefined, undefined, { + chatId: chat.id, + offlineUserCount: offlineUsers.length, + messageFrom: message.userid + }); + } + } + + private setupArchivingScheduler() { + // Run every hour to check for inactive chats + setInterval(async () => { + try { + // First, cleanup inactive chats from Redis and get their IDs + const inactiveChatIds = await this.redisService.cleanupInactiveChats(this.chatTimeout); + + // Archive the inactive chats in the database + for (const chatId of inactiveChatIds) { + const chat = await this.chatRepository.findById(chatId); + if (chat) { + await this.chatRepository.archiveChat(chat); + logRequest('Chat archived due to inactivity', undefined, undefined, { + chatId: chat.id, + chatType: chat.type, + lastActivity: chat.lastActivity, + messageCount: chat.messages.length + }); + } + } + + // Also find inactive chats from database that might not be in Redis + const dbInactiveChats = await this.chatRepository.findInactiveChats(this.chatTimeout); + const additionalInactiveChats = dbInactiveChats.filter(chat => + !inactiveChatIds.includes(chat.id) + ); + + for (const chat of additionalInactiveChats) { + await this.chatRepository.archiveChat(chat); + logRequest('Chat archived due to inactivity (from DB)', undefined, undefined, { + chatId: chat.id, + chatType: chat.type, + lastActivity: chat.lastActivity, + messageCount: chat.messages.length + }); + } + + const totalArchived = inactiveChatIds.length + additionalInactiveChats.length; + if (totalArchived > 0) { + logRequest('Chat archiving completed', undefined, undefined, { + archivedCount: totalArchived, + redisCleanedUp: inactiveChatIds.length, + databaseCleanedUp: additionalInactiveChats.length, + timeoutMinutes: this.chatTimeout + }); + } + + // Cleanup old messages from archived chats based on messageCleanupWeeks + await this.cleanupOldMessages(); + + } catch (error) { + logError('Error in chat archiving scheduler', error as Error); + } + }, 60 * 60 * 1000); // 1 hour + + // Also run message count cleanup every 5 minutes + setInterval(() => { + this.cleanupMessageCounts(); + }, 5 * 60 * 1000); // 5 minutes + } + + // Public methods for game integration + public async createGameChat(gameId: string, gameName: string, playerIds: string[]): Promise { + try { + const existingGameChat = await this.chatRepository.findByGameId(gameId); + if (existingGameChat) { + return existingGameChat; + } + + const gameChat = await this.chatRepository.create({ + type: ChatType.GAME, + name: gameName, + gameId: gameId, + users: playerIds, + messages: [], + lastActivity: new Date() + }); + + // Notify connected players + playerIds.forEach(playerId => { + const playerSocket = this.connectedUsers.get(playerId); + if (playerSocket) { + playerSocket.join(gameChat.id); + playerSocket.emit('game:chat:created', { + chat: { + id: gameChat.id, + type: gameChat.type, + name: gameChat.name, + gameId: gameChat.gameId, + users: gameChat.users, + messages: [] + } + }); + } + }); + + return gameChat; + } catch (error) { + logError('Error creating game chat programmatically', error as Error); + return null; + } + } + + public getConnectedUserCount(): number { + return this.connectedUsers.size; + } + + public isUserConnected(userId: string): boolean { + return this.connectedUsers.has(userId); + } + + public async cleanup(): Promise { + try { + await this.redisService.disconnect(); + } catch (error) { + logError('Error during WebSocket service cleanup', error as Error); + } + } + + /** + * Manually trigger cleanup of old messages and chats + * This can be called by admin endpoints for maintenance + */ + public async triggerManualCleanup(): Promise<{ deletedArchives: number; deletedChats: number }> { + try { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - (this.messageCleanupWeeks * 7)); + + // Clean up old archived messages + const deletedArchivesCount = await this.chatArchiveRepository.cleanup(this.messageCleanupWeeks * 7); + + // Clean up soft-deleted chats + const softDeletedChats = await this.chatRepository.findByPageIncludingDeleted(0, 1000); + let deletedChatsCount = 0; + + for (const chat of softDeletedChats.chats) { + if (chat.state === 2 && chat.updateDate < cutoffDate) { // SOFT_DELETE state = 2 + await this.chatRepository.delete(chat.id); // Hard delete + deletedChatsCount++; + } + } + + logRequest('Manual cleanup triggered', undefined, undefined, { + cutoffDate: cutoffDate.toISOString(), + cleanupWeeks: this.messageCleanupWeeks, + deletedArchives: deletedArchivesCount, + deletedChats: deletedChatsCount, + triggeredBy: 'manual' + }); + + return { deletedArchives: deletedArchivesCount, deletedChats: deletedChatsCount }; + + } catch (error) { + logError('Error during manual cleanup', error as Error); + throw error; + } + } + + /** + * Clean up old messages from archived chats based on messageCleanupWeeks setting + */ + private async cleanupOldMessages(): Promise { + try { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - (this.messageCleanupWeeks * 7)); + + // Clean up old archived messages using ChatArchiveRepository + const deletedArchivesCount = await this.chatArchiveRepository.cleanup(this.messageCleanupWeeks * 7); + + // Also clean up soft-deleted chats from the main repository + // Get all soft-deleted chats that are older than the cleanup period + const softDeletedChats = await this.chatRepository.findByPageIncludingDeleted(0, 1000); + let deletedChatsCount = 0; + + for (const chat of softDeletedChats.chats) { + if (chat.state === 2 && chat.updateDate < cutoffDate) { // SOFT_DELETE state = 2 + await this.chatRepository.delete(chat.id); // Hard delete + deletedChatsCount++; + } + } + + logRequest('Old message cleanup completed', undefined, undefined, { + cutoffDate: cutoffDate.toISOString(), + cleanupWeeks: this.messageCleanupWeeks, + deletedArchives: deletedArchivesCount, + deletedChats: deletedChatsCount, + note: 'Cleanup completed using both ChatRepository and ChatArchiveRepository' + }); + + } catch (error) { + logError('Error cleaning up old messages', error as Error); + } + } + + /** + * Check if user has exceeded message rate limit + * @param userId User ID to check + * @returns true if within limit, false if exceeded + */ + private checkMessageRateLimit(userId: string): boolean { + const now = Date.now(); + const minute = 60 * 1000; // 1 minute in milliseconds + + const userStats = this.userMessageCounts.get(userId) || { count: 0, lastReset: now }; + + // Reset counter if more than a minute has passed + if (now - userStats.lastReset >= minute) { + userStats.count = 0; + userStats.lastReset = now; + } + + // Check if user is within limits + if (userStats.count >= this.maxMessagesPerUser) { + return false; + } + + // Increment counter + userStats.count++; + this.userMessageCounts.set(userId, userStats); + + return true; + } + + /** + * Delete a specific message from chat history + * This can be used for moderation purposes + */ + public async deleteMessage(chatId: string, messageId: string, moderatorUserId: string): Promise { + try { + // Get the chat + const chat = await this.chatRepository.findById(chatId); + if (!chat) { + // Check archived chats + const archivedChat = await this.chatRepository.getArchivedChat(chatId); + if (!archivedChat) { + logWarning('Chat not found for message deletion', { + chatId, + messageId, + moderatorUserId + }); + return false; + } + + // Remove message from archived chat + const updatedMessages = archivedChat.archivedMessages.filter(msg => msg.id !== messageId); + if (updatedMessages.length === archivedChat.archivedMessages.length) { + logWarning('Message not found in archived chat', { + chatId, + messageId, + moderatorUserId + }); + return false; + } + + // Update archived chat + await this.chatArchiveRepository.create({ + ...archivedChat, + archivedMessages: updatedMessages + }); + + logAuth('Message deleted from archived chat', moderatorUserId, { + chatId, + messageId, + originalMessageCount: archivedChat.archivedMessages.length, + newMessageCount: updatedMessages.length + }); + + return true; + } + + // Remove message from active chat + const updatedMessages = chat.messages.filter(msg => msg.id !== messageId); + if (updatedMessages.length === chat.messages.length) { + logWarning('Message not found in active chat', { + chatId, + messageId, + moderatorUserId + }); + return false; + } + + // Update active chat + await this.chatRepository.update(chatId, { + messages: updatedMessages + }); + + // Notify all users in the chat about message deletion + this.io.to(chatId).emit('message:deleted', { + chatId, + messageId, + deletedBy: moderatorUserId + }); + + logAuth('Message deleted from active chat', moderatorUserId, { + chatId, + messageId, + originalMessageCount: chat.messages.length, + newMessageCount: updatedMessages.length + }); + + return true; + + } catch (error) { + logError('Error deleting message', error as Error); + return false; + } + } + + /** + * Clean up old user message count entries (called periodically) + */ + private cleanupMessageCounts(): void { + const now = Date.now(); + const minute = 60 * 1000; + + for (const [userId, stats] of this.userMessageCounts.entries()) { + if (now - stats.lastReset >= minute * 5) { // Keep for 5 minutes + this.userMessageCounts.delete(userId); + } + } + } + + // Game-related WebSocket handlers (prepared for future implementation) + + /** + * Handle player joining a game room for real-time updates + * @param socket The authenticated socket + * @param data Game room data containing game code + */ + private async handleJoinGameRoom(socket: AuthenticatedSocket, data: JoinGameRoomData) { + try { + const userId = socket.userId!; + const gameRoom = `game_${data.gameCode}`; + + logAuth('Player joining game room', userId, { + gameCode: data.gameCode, + gameRoom, + socketId: socket.id + }); + + // Join the WebSocket room for this game + await socket.join(gameRoom); + + // Emit confirmation to the player + socket.emit('game:joined', { + gameCode: data.gameCode, + room: gameRoom, + message: 'Successfully joined game room' + }); + + // Notify other players in the game room + socket.to(gameRoom).emit('game:player_joined', { + playerId: userId, + gameCode: data.gameCode, + timestamp: new Date().toISOString() + }); + + logAuth('Player joined game room successfully', userId, { + gameCode: data.gameCode, + gameRoom + }); + + } catch (error) { + logError('Error joining game room', error as Error); + socket.emit('game:error', { + message: 'Failed to join game room', + gameCode: data.gameCode + }); + } + } + + /** + * Handle player leaving a game room + * @param socket The authenticated socket + * @param data Game room data containing game code + */ + private async handleLeaveGameRoom(socket: AuthenticatedSocket, data: LeaveGameRoomData) { + try { + const userId = socket.userId!; + const gameRoom = `game_${data.gameCode}`; + + logAuth('Player leaving game room', userId, { + gameCode: data.gameCode, + gameRoom, + socketId: socket.id + }); + + // Leave the WebSocket room + await socket.leave(gameRoom); + + // Notify other players in the game room + socket.to(gameRoom).emit('game:player_left', { + playerId: userId, + gameCode: data.gameCode, + timestamp: new Date().toISOString() + }); + + // Confirm to the leaving player + socket.emit('game:left', { + gameCode: data.gameCode, + message: 'Successfully left game room' + }); + + logAuth('Player left game room successfully', userId, { + gameCode: data.gameCode, + gameRoom + }); + + } catch (error) { + logError('Error leaving game room', error as Error); + socket.emit('game:error', { + message: 'Failed to leave game room', + gameCode: data.gameCode + }); + } + } + + /** + * Handle game actions (cards, turns, etc.) - prepared for future implementation + * @param socket The authenticated socket + * @param data Game action data + */ + private async handleGameAction(socket: AuthenticatedSocket, data: GameActionData) { + try { + const userId = socket.userId!; + const gameRoom = `game_${data.gameCode}`; + + logAuth('Game action received', userId, { + gameId: data.gameId, + gameCode: data.gameCode, + action: data.action, + socketId: socket.id + }); + + // Validate that the player is authorized to perform this action + if (data.playerId !== userId) { + socket.emit('game:error', { + message: 'Unauthorized action', + gameCode: data.gameCode + }); + return; + } + + // TODO: Implement specific game logic here + // This will be implemented when the game flow is discussed + + // For now, just broadcast the action to other players + socket.to(gameRoom).emit('game:action_performed', { + playerId: userId, + gameCode: data.gameCode, + action: data.action, + data: data.data, + timestamp: new Date().toISOString() + }); + + // Confirm action to the acting player + socket.emit('game:action_confirmed', { + gameCode: data.gameCode, + action: data.action, + message: 'Action processed successfully' + }); + + logAuth('Game action processed', userId, { + gameId: data.gameId, + gameCode: data.gameCode, + action: data.action + }); + + } catch (error) { + logError('Error processing game action', error as Error); + socket.emit('game:error', { + message: 'Failed to process game action', + gameCode: data.gameCode, + action: data.action + }); + } + } + + /** + * Broadcast game state updates to all players in a game + * @param gameCode The game code + * @param gameState The updated game state + */ + public broadcastGameStateUpdate(gameCode: string, gameState: GameStateUpdateData): void { + try { + const gameRoom = `game_${gameCode}`; + + this.io.to(gameRoom).emit('game:state_updated', { + ...gameState, + timestamp: new Date().toISOString() + }); + + logRequest('Game state broadcasted', undefined, undefined, { + gameCode, + gameRoom, + playerCount: gameState.players.length + }); + + } catch (error) { + logError('Error broadcasting game state', error as Error); + } + } + + /** + * Notify players when a game starts + * @param gameCode The game code + * @param players Array of player IDs + */ + public notifyGameStart(gameCode: string, players: string[]): void { + try { + const gameRoom = `game_${gameCode}`; + + this.io.to(gameRoom).emit('game:started', { + gameCode, + players, + message: 'Game has started!', + timestamp: new Date().toISOString() + }); + + logRequest('Game start notification sent', undefined, undefined, { + gameCode, + playerCount: players.length + }); + + } catch (error) { + logError('Error notifying game start', error as Error); + } + } +} diff --git a/serpentRaceDocker.tar b/serpentRaceDocker.tar new file mode 100644 index 00000000..3089737d Binary files /dev/null and b/serpentRaceDocker.tar differ