1411 lines
51 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|