Files
SerpentRace/SerpentRace_Backend/src/Application/Services/WebSocketService.ts
T

1411 lines
51 KiB
TypeScript

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<string, AuthenticatedSocket> = new Map();
private chatTimeout: number;
private maxMessagesPerUser: number;
private messageCleanupWeeks: number;
private userMessageCounts: Map<string, { count: number; lastReset: number }> = 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<void> {
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<string, Message[]>();
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<ChatAggregate | null> {
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<void> {
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<void> {
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<boolean> {
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);
}
}
}