const { Server } = require('socket.io'); const { verifyToken } = require('../utils/auth'); const { prisma } = require('../utils/db'); // Track active users in each event room const activeUsers = new Map(); // eventId -> Set of { socketId, userId, username, avatar } // Global Socket.IO instance let io; function getIO() { if (!io) { throw new Error('Socket.IO not initialized'); } return io; } function initializeSocket(httpServer) { io = new Server(httpServer, { cors: { origin: process.env.CORS_ORIGIN || 'http://localhost:8080', credentials: true, }, }); // Authentication middleware for Socket.IO io.use(async (socket, next) => { try { const token = socket.handshake.auth.token; if (!token) { return next(new Error('Authentication required')); } const decoded = verifyToken(token); if (!decoded) { return next(new Error('Invalid token')); } // Get user from database const user = await prisma.user.findUnique({ where: { id: decoded.userId }, select: { id: true, username: true, email: true, avatar: true, }, }); if (!user) { return next(new Error('User not found')); } socket.user = user; next(); } catch (error) { console.error('Socket auth error:', error); next(new Error('Authentication failed')); } }); io.on('connection', (socket) => { console.log(`✅ User connected: ${socket.user.username} (${socket.id})`); // Join user's personal room for notifications const userRoom = `user_${socket.user.id}`; socket.join(userRoom); console.log(`📬 ${socket.user.username} joined personal room: ${userRoom}`); // Join event room socket.on('join_event_room', async ({ slug }) => { try { // Find event by slug const event = await prisma.event.findUnique({ where: { slug }, select: { id: true, slug: true }, }); if (!event) { socket.emit('error', { message: 'Event not found' }); return; } const eventId = event.id; const roomName = `event_${eventId}`; // Verify that user has checked in to this event const participant = await prisma.eventParticipant.findUnique({ where: { userId_eventId: { userId: socket.user.id, eventId: eventId, }, }, }); if (!participant) { socket.emit('error', { message: 'You must check in to this event first' }); return; } socket.join(roomName); socket.currentEventRoom = roomName; socket.currentEventId = eventId; socket.currentEventSlug = slug; // Add user to active users if (!activeUsers.has(eventId)) { activeUsers.set(eventId, new Set()); } const userInfo = { socketId: socket.id, userId: socket.user.id, username: socket.user.username, avatar: socket.user.avatar, }; activeUsers.get(eventId).add(JSON.stringify(userInfo)); console.log(`👤 ${socket.user.username} joined event room ${slug} (ID: ${eventId})`); // Load last 20 messages from database const chatRoom = await prisma.chatRoom.findFirst({ where: { eventId: parseInt(eventId), type: 'event', }, }); if (chatRoom) { const messages = await prisma.message.findMany({ where: { roomId: chatRoom.id }, include: { user: { select: { id: true, username: true, avatar: true, }, }, }, orderBy: { createdAt: 'desc' }, take: 20, }); // Send message history to the joining user (reverse to chronological order) socket.emit('message_history', messages.reverse().map(msg => ({ id: msg.id, roomId: msg.roomId, userId: msg.user.id, username: msg.user.username, avatar: msg.user.avatar, content: msg.content, type: msg.type, createdAt: msg.createdAt, }))); } // Broadcast updated active users list const users = Array.from(activeUsers.get(eventId)).map(u => JSON.parse(u)); io.to(roomName).emit('active_users', users); // Notify room about new user socket.to(roomName).emit('user_joined', { userId: socket.user.id, username: socket.user.username, avatar: socket.user.avatar, }); } catch (error) { console.error('Join event room error:', error); socket.emit('error', { message: 'Failed to join room' }); } }); // Leave event room socket.on('leave_event_room', () => { if (!socket.currentEventId || !socket.currentEventRoom) { return; } const eventId = socket.currentEventId; const roomName = socket.currentEventRoom; socket.leave(roomName); // Remove from active users if (activeUsers.has(eventId)) { const users = activeUsers.get(eventId); const userInfo = JSON.stringify({ socketId: socket.id, userId: socket.user.id, username: socket.user.username, avatar: socket.user.avatar, }); users.delete(userInfo); // Broadcast updated list const updatedUsers = Array.from(users).map(u => JSON.parse(u)); io.to(roomName).emit('active_users', updatedUsers); } console.log(`👤 ${socket.user.username} left event room ${socket.currentEventSlug}`); // Clear current event data socket.currentEventId = null; socket.currentEventRoom = null; socket.currentEventSlug = null; }); // Send message to event room socket.on('send_event_message', async ({ content }) => { try { if (!socket.currentEventId || !socket.currentEventRoom) { return socket.emit('error', { message: 'Not in an event room' }); } const eventId = socket.currentEventId; const roomName = socket.currentEventRoom; // Save message to database const chatRoom = await prisma.chatRoom.findFirst({ where: { eventId: eventId, type: 'event', }, }); if (!chatRoom) { return socket.emit('error', { message: 'Chat room not found' }); } const message = await prisma.message.create({ data: { roomId: chatRoom.id, userId: socket.user.id, content, type: 'text', }, include: { user: { select: { id: true, username: true, avatar: true, }, }, }, }); // Broadcast message to room io.to(roomName).emit('event_message', { id: message.id, roomId: message.roomId, userId: message.user.id, username: message.user.username, avatar: message.user.avatar, content: message.content, type: message.type, createdAt: message.createdAt, }); console.log(`💬 Message in event ${socket.currentEventSlug} from ${socket.user.username}`); } catch (error) { console.error('Send message error:', error); socket.emit('error', { message: 'Failed to send message' }); } }); // Join private match room socket.on('join_match_room', ({ matchId }) => { const roomName = `match_${matchId}`; socket.join(roomName); socket.currentMatchRoom = roomName; console.log(`👥 ${socket.user.username} joined match room ${matchId}`); }); // Send message to match room socket.on('send_match_message', async ({ matchId, content }) => { try { const roomName = `match_${matchId}`; // Get match and its room const match = await prisma.match.findUnique({ where: { id: parseInt(matchId) }, include: { room: true }, }); if (!match || !match.room) { return socket.emit('error', { message: 'Match room not found' }); } // Save message const message = await prisma.message.create({ data: { roomId: match.room.id, userId: socket.user.id, content, type: 'text', }, include: { user: { select: { id: true, username: true, avatar: true, }, }, }, }); // Broadcast to match room io.to(roomName).emit('match_message', { id: message.id, roomId: message.roomId, userId: message.user.id, username: message.user.username, avatar: message.user.avatar, content: message.content, type: message.type, createdAt: message.createdAt, }); console.log(`💬 Private message in match ${matchId} from ${socket.user.username}`); } catch (error) { console.error('Send match message error:', error); socket.emit('error', { message: 'Failed to send message' }); } }); // WebRTC Signaling Events // Send WebRTC offer socket.on('webrtc_offer', async ({ matchId, offer }) => { try { const roomName = `match_${matchId}`; // Verify user is part of this match const match = await prisma.match.findUnique({ where: { id: parseInt(matchId) }, select: { user1Id: true, user2Id: true }, }); if (!match) { return socket.emit('error', { message: 'Match not found' }); } if (match.user1Id !== socket.user.id && match.user2Id !== socket.user.id) { return socket.emit('error', { message: 'Not authorized for this match' }); } // Forward offer to the other user in the match room socket.to(roomName).emit('webrtc_offer', { from: socket.user.id, offer, }); console.log(`📡 WebRTC offer sent in match ${matchId} from ${socket.user.username}`); } catch (error) { console.error('WebRTC offer error:', error); socket.emit('error', { message: 'Failed to send WebRTC offer' }); } }); // Send WebRTC answer socket.on('webrtc_answer', async ({ matchId, answer }) => { try { const roomName = `match_${matchId}`; // Verify user is part of this match const match = await prisma.match.findUnique({ where: { id: parseInt(matchId) }, select: { user1Id: true, user2Id: true }, }); if (!match) { return socket.emit('error', { message: 'Match not found' }); } if (match.user1Id !== socket.user.id && match.user2Id !== socket.user.id) { return socket.emit('error', { message: 'Not authorized for this match' }); } // Forward answer to the other user in the match room socket.to(roomName).emit('webrtc_answer', { from: socket.user.id, answer, }); console.log(`📡 WebRTC answer sent in match ${matchId} from ${socket.user.username}`); } catch (error) { console.error('WebRTC answer error:', error); socket.emit('error', { message: 'Failed to send WebRTC answer' }); } }); // Send ICE candidate socket.on('webrtc_ice_candidate', async ({ matchId, candidate }) => { try { const roomName = `match_${matchId}`; // Verify user is part of this match const match = await prisma.match.findUnique({ where: { id: parseInt(matchId) }, select: { user1Id: true, user2Id: true }, }); if (!match) { return socket.emit('error', { message: 'Match not found' }); } if (match.user1Id !== socket.user.id && match.user2Id !== socket.user.id) { return socket.emit('error', { message: 'Not authorized for this match' }); } // Forward ICE candidate to the other user in the match room socket.to(roomName).emit('webrtc_ice_candidate', { from: socket.user.id, candidate, }); console.log(`🧊 ICE candidate sent in match ${matchId} from ${socket.user.username}`); } catch (error) { console.error('ICE candidate error:', error); socket.emit('error', { message: 'Failed to send ICE candidate' }); } }); // Handle disconnection socket.on('disconnect', () => { console.log(`❌ User disconnected: ${socket.user.username} (${socket.id})`); // Remove from active users in all event rooms if (socket.currentEventId) { const eventId = socket.currentEventId; if (activeUsers.has(eventId)) { const users = activeUsers.get(eventId); const userInfo = JSON.stringify({ socketId: socket.id, userId: socket.user.id, username: socket.user.username, avatar: socket.user.avatar, }); users.delete(userInfo); // Broadcast updated list const updatedUsers = Array.from(users).map(u => JSON.parse(u)); io.to(socket.currentEventRoom).emit('active_users', updatedUsers); // Notify about user leaving socket.to(socket.currentEventRoom).emit('user_left', { userId: socket.user.id, username: socket.user.username, }); } } }); }); console.log('🔌 Socket.IO initialized'); return io; } module.exports = { initializeSocket, getIO };