| |
| |
| |
|
|
| import { v4 as uuidv4 } from 'uuid'; |
| import { |
| Session, |
| User, |
| ChatMessage, |
| Poll, |
| Doubt, |
| Reaction, |
| ReactionType, |
| WhiteboardStroke, |
| SSEEvent, |
| PresenceUser, |
| PresenceUpdate |
| } from './types'; |
| import { storageService } from './storage'; |
|
|
| |
| |
| |
|
|
| 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 currentSlide: Map<string, number> = new Map(); |
| |
| |
| private sseClients: Map<string, Set<{ |
| userId: string; |
| write: (data: string) => void; |
| }>> = new Map(); |
| |
| |
| private eventIdCounter: number = 0; |
|
|
| |
| |
| |
|
|
| async createSession( |
| teacherId: string, |
| teacherName: string, |
| sessionName: string, |
| slideUrls: string[] = [] |
| ): Promise<Session> { |
| 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 |
| }; |
| |
| 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()); |
| |
| |
| const reactionEmojis: ReactionType[] = ['π', 'β€οΈ', 'π', 'π', 'π', 'π', 'π₯']; |
| const sessionReactions = this.reactions.get(sessionId)!; |
| reactionEmojis.forEach(emoji => sessionReactions.set(emoji, 0)); |
| |
| |
| await storageService.saveSessionData(sessionId, session); |
| |
| return session; |
| } |
|
|
| getSession(sessionId: string): Session | undefined { |
| return this.sessions.get(sessionId); |
| } |
|
|
| async endSession(sessionId: string): Promise<void> { |
| const session = this.sessions.get(sessionId); |
| if (session) { |
| session.isActive = false; |
| await this.broadcast(sessionId, 'session_ended', { sessionId }); |
| |
| |
| await storageService.exportSession(sessionId); |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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 |
| }); |
| |
| 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 |
| }); |
| } |
| } |
|
|
| 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 |
| })); |
| } |
|
|
| |
| |
| |
|
|
| 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); |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| async broadcast(sessionId: string, event: string, data: any): Promise<string> { |
| 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; |
| } |
|
|
| |
| |
| |
|
|
| async changeSlide(sessionId: string, slideNumber: number): Promise<void> { |
| 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; |
| } |
|
|
| |
| |
| |
|
|
| async addWhiteboardStroke(sessionId: string, stroke: WhiteboardStroke): Promise<void> { |
| const strokes = this.whiteboard.get(sessionId); |
| if (strokes) { |
| strokes.push(stroke); |
| } |
| |
| await this.broadcast(sessionId, 'whiteboard_draw', stroke); |
| } |
|
|
| async clearWhiteboard(sessionId: string): Promise<void> { |
| this.whiteboard.set(sessionId, []); |
| |
| await this.broadcast(sessionId, 'whiteboard_clear', { sessionId }); |
| } |
|
|
| getWhiteboardStrokes(sessionId: string): WhiteboardStroke[] { |
| return this.whiteboard.get(sessionId) || []; |
| } |
|
|
| |
| |
| |
|
|
| async createPoll( |
| sessionId: string, |
| question: string, |
| options: string[], |
| 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 |
| })), |
| 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 storageService.savePoll(sessionId, poll); |
| |
| |
| await this.broadcast(sessionId, 'new_poll', poll); |
| |
| |
| 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); |
| |
| |
| await storageService.savePoll(sessionId, poll); |
| |
| |
| await this.broadcast(sessionId, 'poll_update', { |
| pollId, |
| results: poll.results, |
| totalVotes: poll.totalVotes |
| }); |
| |
| 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 storageService.savePoll(sessionId, poll); |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
|
|
| 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() |
| }; |
| |
| |
| await storageService.saveChatMessage(sessionId, chatMessage); |
| |
| |
| await this.broadcast(sessionId, 'chat_message', chatMessage); |
| |
| return chatMessage; |
| } |
|
|
| |
| |
| |
|
|
| 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 storageService.saveDoubt(sessionId, 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 storageService.saveDoubt(sessionId, doubt); |
| |
| |
| 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 |
| }); |
| |
| 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; |
| } |
|
|
| |
| |
| |
|
|
| async sendPresenceUpdate(sessionId: string): Promise<void> { |
| 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) |
| }); |
| } |
| } |
|
|
| |
| export const sessionManager = new SessionManager(); |
|
|