// ═══════════════════════════════════════════════════════════════════════════ // SESSION MANAGER - In-Memory Session State Management // ═══════════════════════════════════════════════════════════════════════════ import { v4 as uuidv4 } from 'uuid'; import { Session, User, ChatMessage, Poll, Doubt, Reaction, ReactionType, WhiteboardStroke, SSEEvent, PresenceUser, PresenceUpdate } from './types'; import { storageService } from './storage'; // ═══════════════════════════════════════════════════════════════════════════ // IN-MEMORY STORES // ═══════════════════════════════════════════════════════════════════════════ class SessionManager { private sessions: Map = new Map(); private users: Map = new Map(); private sessionUsers: Map> = new Map(); // sessionId -> userIds private polls: Map> = new Map(); // sessionId -> pollId -> Poll private doubts: Map> = new Map(); // sessionId -> doubtId -> Doubt private reactions: Map> = new Map(); // sessionId -> emoji -> count private whiteboard: Map = new Map(); // sessionId -> strokes private currentSlide: Map = new Map(); // sessionId -> slideNumber // SSE clients for broadcasting private sseClients: Map void; }>> = new Map(); // sessionId -> clients // Event ID counter for SSE private eventIdCounter: number = 0; // ═══════════════════════════════════════════════════════════════════════════ // SESSION MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ async createSession( teacherId: string, teacherName: string, sessionName: string, slideUrls: string[] = [] ): Promise { const sessionId = uuidv4(); const session: Session = { id: sessionId, name: sessionName, teacherId, teacherName, createdAt: new Date().toISOString(), isActive: true, currentSlide: 0, totalSlides: slideUrls.length, slideUrls, whiteboardData: [], studentCount: 0, maxStudents: 100000 // 1 Lakh }; this.sessions.set(sessionId, session); this.sessionUsers.set(sessionId, new Set([teacherId])); this.polls.set(sessionId, new Map()); this.doubts.set(sessionId, new Map()); this.reactions.set(sessionId, new Map()); this.whiteboard.set(sessionId, []); this.currentSlide.set(sessionId, 0); this.sseClients.set(sessionId, new Set()); // Initialize reactions const reactionEmojis: ReactionType[] = ['👍', '❤️', '🎉', '😕', '🚀', '👏', '🔥']; const sessionReactions = this.reactions.get(sessionId)!; reactionEmojis.forEach(emoji => sessionReactions.set(emoji, 0)); // Save to storage await storageService.saveSessionData(sessionId, session); return session; } getSession(sessionId: string): Session | undefined { return this.sessions.get(sessionId); } async endSession(sessionId: string): Promise { const session = this.sessions.get(sessionId); if (session) { session.isActive = false; await this.broadcast(sessionId, 'session_ended', { sessionId }); // Export session data await storageService.exportSession(sessionId); } } // ═══════════════════════════════════════════════════════════════════════════ // USER MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ joinSession( sessionId: string, userId: string, userName: string, role: 'teacher' | 'student' ): User { const user: User = { id: userId, name: userName, role, sessionId, joinedAt: new Date().toISOString() }; this.users.set(userId, user); const sessionUsers = this.sessionUsers.get(sessionId); if (sessionUsers) { sessionUsers.add(userId); } const session = this.sessions.get(sessionId); if (session && role === 'student') { session.studentCount++; } // Broadcast to all this.broadcast(sessionId, 'student_joined', { userId, userName, role, studentCount: session?.studentCount || 0 }); return user; } leaveSession(userId: string): void { const user = this.users.get(userId); if (!user || !user.sessionId) return; const sessionUsers = this.sessionUsers.get(user.sessionId); if (sessionUsers) { sessionUsers.delete(userId); } const session = this.sessions.get(user.sessionId); if (session && user.role === 'student') { session.studentCount--; } this.users.delete(userId); // Broadcast to all if (user.sessionId) { this.broadcast(user.sessionId, 'student_left', { userId, userName: user.name, studentCount: session?.studentCount || 0 }); } } getOnlineUsers(sessionId: string): PresenceUser[] { const sessionUsers = this.sessionUsers.get(sessionId); if (!sessionUsers) return []; return Array.from(sessionUsers) .map(userId => this.users.get(userId)) .filter(Boolean) .map(user => ({ id: user!.id, name: user!.name, role: user!.role, lastSeen: new Date().toISOString(), isOnline: true })); } // ═══════════════════════════════════════════════════════════════════════════ // SSE CLIENT MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ addSSEClient(sessionId: string, userId: string, write: (data: string) => void): void { const clients = this.sseClients.get(sessionId); if (clients) { clients.add({ userId, write }); } } removeSSEClient(sessionId: string, userId: string): void { const clients = this.sseClients.get(sessionId); if (clients) { const clientToRemove = Array.from(clients).find(c => c.userId === userId); if (clientToRemove) { clients.delete(clientToRemove); } } } // ═══════════════════════════════════════════════════════════════════════════ // BROADCASTING (SSE) // ═══════════════════════════════════════════════════════════════════════════ async broadcast(sessionId: string, event: string, data: any): Promise { const eventId = `${++this.eventIdCounter}`; const timestamp = new Date().toISOString(); const clients = this.sseClients.get(sessionId); if (!clients) return eventId; const message = `id: ${eventId}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`; Array.from(clients).forEach(client => { try { client.write(message); } catch (error) { console.error(`Failed to send to client ${client.userId}:`, error); } }); return eventId; } // ═══════════════════════════════════════════════════════════════════════════ // SLIDE MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ async changeSlide(sessionId: string, slideNumber: number): Promise { const session = this.sessions.get(sessionId); if (!session) return; session.currentSlide = slideNumber; this.currentSlide.set(sessionId, slideNumber); await this.broadcast(sessionId, 'slide_change', { slideNumber, slideUrl: session.slideUrls[slideNumber] || null, totalSlides: session.totalSlides }); } getSlide(sessionId: string): number { return this.currentSlide.get(sessionId) || 0; } // ═══════════════════════════════════════════════════════════════════════════ // WHITEBOARD MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ async addWhiteboardStroke(sessionId: string, stroke: WhiteboardStroke): Promise { const strokes = this.whiteboard.get(sessionId); if (strokes) { strokes.push(stroke); } await this.broadcast(sessionId, 'whiteboard_draw', stroke); } async clearWhiteboard(sessionId: string): Promise { this.whiteboard.set(sessionId, []); await this.broadcast(sessionId, 'whiteboard_clear', { sessionId }); } getWhiteboardStrokes(sessionId: string): WhiteboardStroke[] { return this.whiteboard.get(sessionId) || []; } // ═══════════════════════════════════════════════════════════════════════════ // POLL MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ async createPoll( sessionId: string, question: string, options: string[], correctAnswer?: string, duration: number = 60 ): Promise { const pollId = uuidv4(); const now = new Date(); const endsAt = new Date(now.getTime() + duration * 1000).toISOString(); const poll: Poll = { id: pollId, sessionId, question, options: options.map((text, index) => ({ id: `option_${index}`, text, isCorrect: text === correctAnswer })), correctAnswer, duration, createdAt: now.toISOString(), endsAt, isActive: true, results: {}, totalVotes: 0, votedStudents: [] }; // Initialize results options.forEach(opt => { poll.results[opt] = 0; }); const sessionPolls = this.polls.get(sessionId); if (sessionPolls) { sessionPolls.set(pollId, poll); } // Save to storage await storageService.savePoll(sessionId, poll); // Broadcast new poll await this.broadcast(sessionId, 'new_poll', poll); // Auto-end poll after duration setTimeout(() => { this.endPoll(sessionId, pollId); }, duration * 1000); return poll; } async votePoll(sessionId: string, pollId: string, option: string, studentId: string): Promise { const sessionPolls = this.polls.get(sessionId); if (!sessionPolls) return null; const poll = sessionPolls.get(pollId); if (!poll || !poll.isActive) return null; // Check if already voted if (poll.votedStudents.includes(studentId)) { return poll; } // Record vote poll.results[option] = (poll.results[option] || 0) + 1; poll.totalVotes++; poll.votedStudents.push(studentId); // Save to storage await storageService.savePoll(sessionId, poll); // Broadcast update await this.broadcast(sessionId, 'poll_update', { pollId, results: poll.results, totalVotes: poll.totalVotes }); return poll; } async endPoll(sessionId: string, pollId: string): Promise { const sessionPolls = this.polls.get(sessionId); if (!sessionPolls) return; const poll = sessionPolls.get(pollId); if (!poll || !poll.isActive) return; poll.isActive = false; // Save to storage await storageService.savePoll(sessionId, poll); // Broadcast results await this.broadcast(sessionId, 'poll_ended', { pollId, results: poll.results, totalVotes: poll.totalVotes, correctAnswer: poll.correctAnswer }); } getActivePoll(sessionId: string): Poll | null { const sessionPolls = this.polls.get(sessionId); if (!sessionPolls) return null; let activePoll: Poll | null = null; sessionPolls.forEach(poll => { if (poll.isActive && !activePoll) { activePoll = poll; } }); return activePoll; } // ═══════════════════════════════════════════════════════════════════════════ // CHAT MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ async sendChatMessage( sessionId: string, userId: string, userName: string, message: string, isTeacher: boolean ): Promise { const chatMessage: ChatMessage = { id: uuidv4(), sessionId, userId, userName, message, isTeacher, timestamp: new Date().toISOString() }; // Save to storage await storageService.saveChatMessage(sessionId, chatMessage); // Broadcast to all await this.broadcast(sessionId, 'chat_message', chatMessage); return chatMessage; } // ═══════════════════════════════════════════════════════════════════════════ // DOUBT MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ async createDoubt( sessionId: string, studentId: string, studentName: string, question: string, image?: string, slideNumber?: number ): Promise { const doubtId = uuidv4(); const doubt: Doubt = { id: doubtId, sessionId, studentId, studentName, question, image, slideNumber: slideNumber ?? this.currentSlide.get(sessionId) ?? 0, status: 'pending', createdAt: new Date().toISOString() }; const sessionDoubts = this.doubts.get(sessionId); if (sessionDoubts) { sessionDoubts.set(doubtId, doubt); } // Save to storage await storageService.saveDoubt(sessionId, doubt); // Broadcast to teacher await this.broadcast(sessionId, 'new_doubt', doubt); return doubt; } async solveDoubt( sessionId: string, doubtId: string, annotatedImage?: string, teacherResponse?: string ): Promise { const sessionDoubts = this.doubts.get(sessionId); if (!sessionDoubts) return null; const doubt = sessionDoubts.get(doubtId); if (!doubt) return null; doubt.status = 'solved'; doubt.solvedAt = new Date().toISOString(); doubt.annotatedImage = annotatedImage; doubt.teacherResponse = teacherResponse; // Save to storage await storageService.saveDoubt(sessionId, doubt); // Broadcast solution await this.broadcast(sessionId, 'doubt_solved', doubt); return doubt; } getDoubts(sessionId: string): Doubt[] { const sessionDoubts = this.doubts.get(sessionId); if (!sessionDoubts) return []; return Array.from(sessionDoubts.values()); } // ═══════════════════════════════════════════════════════════════════════════ // REACTIONS MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ async addReaction(sessionId: string, emoji: ReactionType): Promise> { const sessionReactions = this.reactions.get(sessionId); if (!sessionReactions) return {} as Record; const currentCount = sessionReactions.get(emoji) || 0; sessionReactions.set(emoji, currentCount + 1); const allReactions = {} as Record; sessionReactions.forEach((count, em) => { allReactions[em as ReactionType] = count; }); // Broadcast update await this.broadcast(sessionId, 'reaction', { reactions: allReactions, lastReaction: emoji }); return allReactions; } getReactions(sessionId: string): Record { const sessionReactions = this.reactions.get(sessionId); if (!sessionReactions) return {} as Record; const allReactions = {} as Record; sessionReactions.forEach((count, emoji) => { allReactions[emoji as ReactionType] = count; }); return allReactions; } // ═══════════════════════════════════════════════════════════════════════════ // PRESENCE UPDATE // ═══════════════════════════════════════════════════════════════════════════ async sendPresenceUpdate(sessionId: string): Promise { const users = this.getOnlineUsers(sessionId); const session = this.sessions.get(sessionId); await this.broadcast(sessionId, 'presence_update', { sessionId, onlineCount: users.length, studentCount: session?.studentCount || 0, users: users.slice(0, 100) // Send only first 100 for bandwidth }); } } // Singleton instance export const sessionManager = new SessionManager();