| |
| |
| |
|
|
| import { v4 as uuidv4 } from 'uuid'; |
| import { |
| Session, |
| User, |
| ChatMessage, |
| Poll, |
| PollOption, |
| Doubt, |
| WhiteboardStroke, |
| ReactionType, |
| } from './types'; |
|
|
| |
| |
| |
|
|
| class SessionManager { |
| |
| private sessions: Map<string, Session> = new Map(); |
| private users: Map<string, User> = new Map(); |
| private sessionUsers: Map<string, Set<string>> = new Map(); |
|
|
| |
| private polls: Map<string, Map<string, Poll>> = new Map(); |
| private doubts: Map<string, Map<string, Doubt>> = new Map(); |
| private reactions: Map<string, Map<ReactionType, number>> = new Map(); |
| private whiteboard: Map<string, WhiteboardStroke[]> = new Map(); |
| private chatMessages: Map<string, ChatMessage[]> = new Map(); |
| private currentSlide: Map<string, number> = new Map(); |
|
|
| |
| private sseClients: Map<string, Set<{ |
| userId: string; |
| write: (data: string) => void; |
| }>> = new Map(); |
|
|
| |
| private roomCodeIndex: Map<string, string> = new Map(); |
|
|
| |
| private eventIdCounter: number = 0; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| createSession( |
| teacherId: string, |
| teacherName: string, |
| sessionName: string, |
| description?: string, |
| slideUrls: string[] = [] |
| ): Session { |
| const sessionId = uuidv4(); |
|
|
| |
| 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; |
|
|
| |
| 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()); |
|
|
| |
| const reactionEmojis: ReactionType[] = ['π', 'β€οΈ', 'π', 'π', 'π', 'π', 'π₯']; |
| const sessionReactions = this.reactions.get(sessionId)!; |
| reactionEmojis.forEach(emoji => sessionReactions.set(emoji, 0)); |
|
|
| return session; |
| } |
|
|
| |
| |
| |
| getSession(sessionId: string): Session | undefined { |
| return this.sessions.get(sessionId); |
| } |
|
|
| |
| |
| |
| getAllSessions(): Session[] { |
| return Array.from(this.sessions.values()).filter(s => s.isActive); |
| } |
|
|
| |
| |
| |
| getSessionByRoomCode(roomCode: string): Session | undefined { |
| const sessionId = this.roomCodeIndex.get(roomCode); |
| if (!sessionId) return undefined; |
| return this.sessions.get(sessionId); |
| } |
|
|
| |
| |
| |
| async endSession(sessionId: string): Promise<void> { |
| const session = this.sessions.get(sessionId); |
| if (session) { |
| |
| const exportUrlOnly = this.getSessionState(sessionId); |
|
|
| session.isActive = false; |
| await this.broadcast(sessionId, 'session_ended', { |
| sessionId, |
| endedAt: new Date().toISOString() |
| }); |
|
|
| |
| 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); |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| 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++; |
| } |
|
|
| |
| this.broadcast(sessionId, 'student_joined', { |
| userId, |
| userName, |
| role, |
| studentCount: session?.studentCount || 0, |
| timestamp: new Date().toISOString() |
| }); |
|
|
| 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); |
|
|
| |
| if (user.sessionId) { |
| this.broadcast(user.sessionId, 'student_left', { |
| userId, |
| userName: user.name, |
| studentCount: session?.studentCount || 0, |
| timestamp: new Date().toISOString() |
| }); |
| } |
| } |
|
|
| |
| |
| |
| 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[]; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| 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) { |
| for (const client of clients) { |
| if (client.userId === userId) { |
| clients.delete(client); |
| break; |
| } |
| } |
| } |
| } |
|
|
| |
| |
| |
| getClientCount(sessionId: string): number { |
| return this.sseClients.get(sessionId)?.size || 0; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| async broadcast(sessionId: string, event: string, data: any): Promise<string> { |
| 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; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| async changeSlide(sessionId: string, slideNumber: number): Promise<void> { |
| const session = this.sessions.get(sessionId); |
| if (!session) return; |
|
|
| |
| const validSlide = Math.max(0, slideNumber); |
| session.currentSlide = validSlide; |
| this.currentSlide.set(sessionId, validSlide); |
|
|
| |
| const imageBase64 = session.slideUrls[validSlide] || null; |
|
|
| await this.broadcast(sessionId, 'slide_change', { |
| slideNumber: validSlide, |
| imageBase64, |
| totalSlides: session.totalSlides, |
| timestamp: new Date().toISOString() |
| }); |
| } |
|
|
| |
| |
| |
| getSlide(sessionId: string): number { |
| return this.currentSlide.get(sessionId) || 0; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| async addWhiteboardStroke(sessionId: string, stroke: Omit<WhiteboardStroke, 'id' | 'timestamp'>): Promise<WhiteboardStroke> { |
| 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; |
| } |
|
|
| |
| |
| |
| async clearWhiteboard(sessionId: string): Promise<void> { |
| this.whiteboard.set(sessionId, []); |
| await this.broadcast(sessionId, 'whiteboard_clear', { |
| sessionId, |
| timestamp: new Date().toISOString() |
| }); |
| } |
|
|
| |
| |
| |
| getWhiteboardStrokes(sessionId: string): WhiteboardStroke[] { |
| return this.whiteboard.get(sessionId) || []; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| async createPoll( |
| sessionId: string, |
| question: string, |
| options: string[], |
| type: 'single' | 'multiple' = 'single', |
| correctAnswer?: string, |
| duration: number = 60 |
| ): Promise<Poll> { |
| 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: [] |
| }; |
|
|
| |
| options.forEach(opt => { |
| poll.results[opt] = 0; |
| }); |
|
|
| const sessionPolls = this.polls.get(sessionId); |
| if (sessionPolls) { |
| sessionPolls.set(pollId, poll); |
| } |
|
|
| |
| await this.broadcast(sessionId, 'new_poll', poll); |
|
|
| |
| if (duration > 0) { |
| setTimeout(() => { |
| this.endPoll(sessionId, pollId); |
| }, duration * 1000); |
| } |
|
|
| return poll; |
| } |
|
|
| |
| |
| |
| async votePoll(sessionId: string, pollId: string, option: string, studentId: string): Promise<Poll | null> { |
| const sessionPolls = this.polls.get(sessionId); |
| if (!sessionPolls) return null; |
|
|
| const poll = sessionPolls.get(pollId); |
| if (!poll || !poll.isActive) return null; |
|
|
| |
| if (poll.votedStudents.includes(studentId)) { |
| return poll; |
| } |
|
|
| |
| poll.results[option] = (poll.results[option] || 0) + 1; |
| poll.totalVotes++; |
| poll.votedStudents.push(studentId); |
|
|
| |
| const optIndex = poll.options.findIndex(o => o.text === option); |
| if (optIndex >= 0) { |
| poll.options[optIndex].votes++; |
| } |
|
|
| |
| await this.broadcast(sessionId, 'poll_update', { |
| pollId, |
| results: poll.results, |
| totalVotes: poll.totalVotes, |
| timestamp: new Date().toISOString() |
| }); |
|
|
| return poll; |
| } |
|
|
| |
| |
| |
| async endPoll(sessionId: string, pollId: string): Promise<void> { |
| const sessionPolls = this.polls.get(sessionId); |
| if (!sessionPolls) return; |
|
|
| const poll = sessionPolls.get(pollId); |
| if (!poll || !poll.isActive) return; |
|
|
| poll.isActive = false; |
|
|
| |
| await this.broadcast(sessionId, 'poll_ended', { |
| pollId, |
| results: poll.results, |
| totalVotes: poll.totalVotes, |
| correctAnswer: poll.correctAnswer, |
| timestamp: new Date().toISOString() |
| }); |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| getPolls(sessionId: string): Poll[] { |
| const sessionPolls = this.polls.get(sessionId); |
| if (!sessionPolls) return []; |
| return Array.from(sessionPolls.values()); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| async sendChatMessage( |
| sessionId: string, |
| userId: string, |
| userName: string, |
| message: string, |
| isTeacher: boolean |
| ): Promise<ChatMessage> { |
| 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); |
| |
| if (messages.length > 500) { |
| messages.shift(); |
| } |
| } |
|
|
| |
| await this.broadcast(sessionId, 'chat_message', chatMessage); |
|
|
| return chatMessage; |
| } |
|
|
| |
| |
| |
| getChatMessages(sessionId: string, limit: number = 100): ChatMessage[] { |
| const messages = this.chatMessages.get(sessionId) || []; |
| return messages.slice(-limit); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| async createDoubt( |
| sessionId: string, |
| studentId: string, |
| studentName: string, |
| question: string, |
| image?: string, |
| slideNumber?: number |
| ): Promise<Doubt> { |
| 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); |
| } |
|
|
| |
| await this.broadcast(sessionId, 'new_doubt', doubt); |
|
|
| return doubt; |
| } |
|
|
| |
| |
| |
| async solveDoubt( |
| sessionId: string, |
| doubtId: string, |
| annotatedImage?: string, |
| teacherResponse?: string |
| ): Promise<Doubt | null> { |
| 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; |
|
|
| |
| 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()); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| async addReaction(sessionId: string, emoji: ReactionType): Promise<Record<ReactionType, number>> { |
| const sessionReactions = this.reactions.get(sessionId); |
| if (!sessionReactions) return {} as Record<ReactionType, number>; |
|
|
| const currentCount = sessionReactions.get(emoji) || 0; |
| sessionReactions.set(emoji, currentCount + 1); |
|
|
| const allReactions = {} as Record<ReactionType, number>; |
| sessionReactions.forEach((count, em) => { |
| allReactions[em as ReactionType] = count; |
| }); |
|
|
| |
| await this.broadcast(sessionId, 'reaction', { |
| reactions: allReactions, |
| lastReaction: emoji, |
| timestamp: new Date().toISOString() |
| }); |
|
|
| return allReactions; |
| } |
|
|
| |
| |
| |
| getReactions(sessionId: string): Record<ReactionType, number> { |
| const sessionReactions = this.reactions.get(sessionId); |
| if (!sessionReactions) return {} as Record<ReactionType, number>; |
|
|
| const allReactions = {} as Record<ReactionType, number>; |
| sessionReactions.forEach((count, emoji) => { |
| allReactions[emoji as ReactionType] = count; |
| }); |
|
|
| return allReactions; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| 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) |
| }; |
| } |
| } |
|
|
| |
| export const sessionManager = new SessionManager(); |
|
|