// ═══════════════════════════════════════════════════════════════════════════ // SESSION MANAGER - In-Memory Session State Management with SSE Broadcasting // ═══════════════════════════════════════════════════════════════════════════ import { v4 as uuidv4 } from 'uuid'; import { Session, User, ChatMessage, Poll, PollOption, Doubt, WhiteboardStroke, ReactionType, } from './types'; // ═══════════════════════════════════════════════════════════════════════════ // IN-MEMORY STORES // ═══════════════════════════════════════════════════════════════════════════ class SessionManager { // Core data stores private sessions: Map = new Map(); private users: Map = new Map(); private sessionUsers: Map> = new Map(); // Feature data stores private polls: Map> = new Map(); private doubts: Map> = new Map(); private reactions: Map> = new Map(); private whiteboard: Map = new Map(); private chatMessages: Map = new Map(); private currentSlide: Map = new Map(); // SSE clients for broadcasting private sseClients: Map void; }>> = new Map(); // 4-digit room code index: roomCode -> sessionId private roomCodeIndex: Map = new Map(); // Event ID counter for SSE private eventIdCounter: number = 0; // ═══════════════════════════════════════════════════════════════════════════ // SESSION MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ /** * Create a new session * @param teacherId - Unique ID for the teacher * @param teacherName - Display name of the teacher * @param sessionName - Name of the session/class * @param description - Optional description * @param slideUrls - Optional array of slide URLs * @returns The created Session object */ createSession( teacherId: string, teacherName: string, sessionName: string, description?: string, slideUrls: string[] = [] ): Session { const sessionId = uuidv4(); // Generate unique 4-digit room code let roomCode: string; do { roomCode = String(Math.floor(1000 + Math.random() * 9000)); } while (this.roomCodeIndex.has(roomCode)); this.roomCodeIndex.set(roomCode, sessionId); const session: Session = { id: sessionId, name: sessionName, description, teacherId, teacherName, createdAt: new Date().toISOString(), isActive: true, currentSlide: 0, totalSlides: slideUrls.length, slideUrls, studentCount: 0, maxStudents: 100000, roomCode } as any; // Initialize session data 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.chatMessages.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)); return session; } /** * Get a session by ID */ getSession(sessionId: string): Session | undefined { return this.sessions.get(sessionId); } /** * Get all active sessions */ getAllSessions(): Session[] { return Array.from(this.sessions.values()).filter(s => s.isActive); } /** * Lookup session by 4-digit room code */ getSessionByRoomCode(roomCode: string): Session | undefined { const sessionId = this.roomCodeIndex.get(roomCode); if (!sessionId) return undefined; return this.sessions.get(sessionId); } /** * End a session */ async endSession(sessionId: string): Promise { const session = this.sessions.get(sessionId); if (session) { // Export session data const exportUrlOnly = this.getSessionState(sessionId); session.isActive = false; await this.broadcast(sessionId, 'session_ended', { sessionId, endedAt: new Date().toISOString() }); // Save to disk stringifying the object directly try { const fs = await import('fs/promises'); const path = await import('path'); const storageRoot = process.env.SPACE_ID ? '/data' : path.join(process.cwd(), 'data'); const sessionDir = path.join(storageRoot, 'sessions', sessionId); await fs.mkdir(sessionDir, { recursive: true }); await fs.writeFile( path.join(sessionDir, 'session_url.json'), JSON.stringify(exportUrlOnly, null, 2) ); } catch (err) { console.error('Failed to export session files:', err); } } } // ═══════════════════════════════════════════════════════════════════════════ // USER MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ /** * Join a session */ 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, timestamp: new Date().toISOString() }); return user; } /** * Leave a session */ 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, timestamp: new Date().toISOString() }); } } /** * Get online users in a session */ getOnlineUsers(sessionId: string): User[] { const sessionUsers = this.sessionUsers.get(sessionId); if (!sessionUsers) return []; return Array.from(sessionUsers) .map(userId => this.users.get(userId)) .filter(Boolean) as User[]; } // ═══════════════════════════════════════════════════════════════════════════ // SSE CLIENT MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ /** * Add an SSE client */ addSSEClient(sessionId: string, userId: string, write: (data: string) => void): void { const clients = this.sseClients.get(sessionId); if (clients) { clients.add({ userId, write }); } } /** * Remove an SSE client */ removeSSEClient(sessionId: string, userId: string): void { const clients = this.sseClients.get(sessionId); if (clients) { for (const client of clients) { if (client.userId === userId) { clients.delete(client); break; } } } } /** * Get connected client count */ getClientCount(sessionId: string): number { return this.sseClients.get(sessionId)?.size || 0; } // ═══════════════════════════════════════════════════════════════════════════ // BROADCASTING (SSE) // ═══════════════════════════════════════════════════════════════════════════ /** * Broadcast an event to all clients in a session */ async broadcast(sessionId: string, event: string, data: any): Promise { const eventId = `${++this.eventIdCounter}`; const clients = this.sseClients.get(sessionId); if (!clients || clients.size === 0) return eventId; const message = `id: ${eventId}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`; for (const client of clients) { try { client.write(message); } catch (error) { console.error(`Failed to send to client ${client.userId}:`, error); } } return eventId; } // ═══════════════════════════════════════════════════════════════════════════ // SLIDE MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ /** * Change slide */ async changeSlide(sessionId: string, slideNumber: number): Promise { const session = this.sessions.get(sessionId); if (!session) return; // Allow any non-negative slide number (slides uploaded dynamically) const validSlide = Math.max(0, slideNumber); session.currentSlide = validSlide; this.currentSlide.set(sessionId, validSlide); // Include the stored imageBase64 if we have it const imageBase64 = session.slideUrls[validSlide] || null; await this.broadcast(sessionId, 'slide_change', { slideNumber: validSlide, imageBase64, totalSlides: session.totalSlides, timestamp: new Date().toISOString() }); } /** * Get current slide */ getSlide(sessionId: string): number { return this.currentSlide.get(sessionId) || 0; } // ═══════════════════════════════════════════════════════════════════════════ // WHITEBOARD MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ /** * Add a whiteboard stroke */ async addWhiteboardStroke(sessionId: string, stroke: Omit): Promise { const strokes = this.whiteboard.get(sessionId); const newStroke: WhiteboardStroke = { ...stroke, id: uuidv4(), timestamp: new Date().toISOString() }; if (strokes) { strokes.push(newStroke); } await this.broadcast(sessionId, 'whiteboard_draw', newStroke); return newStroke; } /** * Clear whiteboard */ async clearWhiteboard(sessionId: string): Promise { this.whiteboard.set(sessionId, []); await this.broadcast(sessionId, 'whiteboard_clear', { sessionId, timestamp: new Date().toISOString() }); } /** * Get whiteboard strokes */ getWhiteboardStrokes(sessionId: string): WhiteboardStroke[] { return this.whiteboard.get(sessionId) || []; } // ═══════════════════════════════════════════════════════════════════════════ // POLL MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ /** * Create a new poll */ async createPoll( sessionId: string, question: string, options: string[], type: 'single' | 'multiple' = 'single', 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, votes: 0 })), type, 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); } // Broadcast new poll await this.broadcast(sessionId, 'new_poll', poll); // Auto-end poll after duration (only if duration > 0) if (duration > 0) { setTimeout(() => { this.endPoll(sessionId, pollId); }, duration * 1000); } return poll; } /** * Vote on a 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); // Update option votes const optIndex = poll.options.findIndex(o => o.text === option); if (optIndex >= 0) { poll.options[optIndex].votes++; } // Broadcast update await this.broadcast(sessionId, 'poll_update', { pollId, results: poll.results, totalVotes: poll.totalVotes, timestamp: new Date().toISOString() }); return poll; } /** * End a 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; // Broadcast results await this.broadcast(sessionId, 'poll_ended', { pollId, results: poll.results, totalVotes: poll.totalVotes, correctAnswer: poll.correctAnswer, timestamp: new Date().toISOString() }); } /** * Get active poll */ getActivePoll(sessionId: string): Poll | null { const sessionPolls = this.polls.get(sessionId); if (!sessionPolls) return null; for (const poll of sessionPolls.values()) { if (poll.isActive) return poll; } return null; } /** * Get all polls for a session */ getPolls(sessionId: string): Poll[] { const sessionPolls = this.polls.get(sessionId); if (!sessionPolls) return []; return Array.from(sessionPolls.values()); } // ═══════════════════════════════════════════════════════════════════════════ // CHAT MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ /** * Send a chat message */ 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() }; const messages = this.chatMessages.get(sessionId); if (messages) { messages.push(chatMessage); // Keep only last 500 messages if (messages.length > 500) { messages.shift(); } } // Broadcast to all await this.broadcast(sessionId, 'chat_message', chatMessage); return chatMessage; } /** * Get chat messages */ getChatMessages(sessionId: string, limit: number = 100): ChatMessage[] { const messages = this.chatMessages.get(sessionId) || []; return messages.slice(-limit); } // ═══════════════════════════════════════════════════════════════════════════ // DOUBT MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ /** * Create a doubt */ 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); } // Broadcast to teacher await this.broadcast(sessionId, 'new_doubt', doubt); return doubt; } /** * Solve a 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; // Broadcast solution await this.broadcast(sessionId, 'doubt_solved', doubt); return doubt; } /** * Get doubts */ getDoubts(sessionId: string): Doubt[] { const sessionDoubts = this.doubts.get(sessionId); if (!sessionDoubts) return []; return Array.from(sessionDoubts.values()); } // ═══════════════════════════════════════════════════════════════════════════ // REACTIONS MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ /** * Add a reaction */ 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, timestamp: new Date().toISOString() }); return allReactions; } /** * Get reactions */ 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; } // ═══════════════════════════════════════════════════════════════════════════ // SESSION STATE // ═══════════════════════════════════════════════════════════════════════════ /** * Get complete session state (for new connections/reconnection) */ getSessionState(sessionId: string) { const sessionObj = this.sessions.get(sessionId); if (!sessionObj) return null; return { session: sessionObj, currentSlide: this.getSlide(sessionId), whiteboardStrokes: this.getWhiteboardStrokes(sessionId), reactions: this.getReactions(sessionId), activePoll: this.getActivePoll(sessionId), allPolls: this.getPolls(sessionId), recentChats: this.getChatMessages(sessionId, 5000), doubts: this.getDoubts(sessionId), onlineUsers: this.getOnlineUsers(sessionId) }; } } // Singleton instance export const sessionManager = new SessionManager();