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); } } }