## Sprint 1 (P0-P1) - P0-02: Submit en Emparejamiento y DragDrop - P0-03: Visualización mecánicas manuales (10 tipos) - P0-04: NotificationService en alertas - P1-01: RLS en teacher_notes - P1-02: Índices críticos para queries - P1-04: Habilitar páginas Communication y Content ## Sprint 2 (P1) - P1-03: Vista classroom_progress_overview - P1-05: Resolver TODOs StudentProgressService - P1-06: Hook useMissionStats - P1-07: Hook useMasteryTracking - P1-08: Cache invalidation en AnalyticsService ## Sprint 3 (P2) - P2-01: WebSocket para monitoreo real-time - P2-02: RubricEvaluator componente - P2-03: Reproductor multimedia (video/audio/image) - P2-04: Tabla teacher_interventions - P2-05: Vista teacher_pending_reviews Total: 17 tareas, 28 archivos 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
348 lines
10 KiB
TypeScript
348 lines
10 KiB
TypeScript
/**
|
|
* Notifications Gateway
|
|
*
|
|
* WebSocket gateway for real-time notifications
|
|
*/
|
|
|
|
import {
|
|
WebSocketGateway,
|
|
WebSocketServer,
|
|
SubscribeMessage,
|
|
OnGatewayConnection,
|
|
OnGatewayDisconnect,
|
|
OnGatewayInit,
|
|
ConnectedSocket,
|
|
MessageBody,
|
|
} from '@nestjs/websockets';
|
|
import { Logger, UseGuards } from '@nestjs/common';
|
|
import { Server } from 'socket.io';
|
|
import { WsJwtGuard, AuthenticatedSocket } from './guards/ws-jwt.guard';
|
|
import {
|
|
SocketEvent,
|
|
StudentActivityPayload,
|
|
ClassroomUpdatePayload,
|
|
NewSubmissionPayload,
|
|
AlertTriggeredPayload,
|
|
StudentOnlineStatusPayload,
|
|
} from './types/websocket.types';
|
|
|
|
@WebSocketGateway({
|
|
cors: {
|
|
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3005', 'http://localhost:5173'],
|
|
credentials: true,
|
|
methods: ['GET', 'POST'],
|
|
},
|
|
path: '/socket.io/',
|
|
transports: ['websocket', 'polling'],
|
|
})
|
|
export class NotificationsGateway
|
|
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
|
@WebSocketServer()
|
|
server!: Server;
|
|
|
|
private readonly logger = new Logger(NotificationsGateway.name);
|
|
|
|
private userSockets = new Map<string, Set<string>>(); // userId -> Set of socketIds
|
|
|
|
afterInit(_server: Server) {
|
|
this.logger.log('WebSocket Gateway initialized');
|
|
}
|
|
|
|
@UseGuards(WsJwtGuard)
|
|
async handleConnection(client: AuthenticatedSocket) {
|
|
const userId = client.userData?.userId;
|
|
const userEmail = client.userData?.email;
|
|
|
|
if (!userId) {
|
|
this.logger.warn('Connection rejected: no user data');
|
|
client.disconnect();
|
|
return;
|
|
}
|
|
|
|
this.logger.log(`Client connected: ${userEmail} (${client.id})`);
|
|
|
|
// Register socket for user
|
|
if (!this.userSockets.has(userId)) {
|
|
this.userSockets.set(userId, new Set());
|
|
}
|
|
this.userSockets.get(userId)!.add(client.id);
|
|
|
|
// Join user's personal room
|
|
await client.join(`user:${userId}`);
|
|
this.logger.debug(`Socket ${client.id} joined room: user:${userId}`);
|
|
|
|
// Emit authenticated event
|
|
client.emit(SocketEvent.AUTHENTICATED, {
|
|
success: true,
|
|
userId,
|
|
email: userEmail,
|
|
socketId: client.id,
|
|
});
|
|
}
|
|
|
|
async handleDisconnect(client: AuthenticatedSocket) {
|
|
const userId = client.userData?.userId;
|
|
const userEmail = client.userData?.email;
|
|
|
|
if (userId) {
|
|
const sockets = this.userSockets.get(userId);
|
|
if (sockets) {
|
|
sockets.delete(client.id);
|
|
if (sockets.size === 0) {
|
|
this.userSockets.delete(userId);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.logger.log(`Client disconnected: ${userEmail} (${client.id})`);
|
|
}
|
|
|
|
/**
|
|
* Handle client marking notification as read
|
|
*/
|
|
@UseGuards(WsJwtGuard)
|
|
@SubscribeMessage(SocketEvent.MARK_AS_READ)
|
|
async handleMarkAsRead(
|
|
@ConnectedSocket() client: AuthenticatedSocket,
|
|
@MessageBody() data: { notificationId: string },
|
|
) {
|
|
try {
|
|
const userId = client.userData!.userId;
|
|
const { notificationId } = data;
|
|
|
|
this.logger.debug(`User ${userId} marking notification ${notificationId} as read via WebSocket`);
|
|
|
|
// Acknowledge to client
|
|
client.emit(SocketEvent.NOTIFICATION_READ, {
|
|
notificationId,
|
|
success: true,
|
|
});
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
this.logger.error('Error handling mark as read:', error);
|
|
client.emit(SocketEvent.ERROR, {
|
|
message: 'Failed to mark notification as read',
|
|
});
|
|
return { success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit notification to specific user
|
|
*/
|
|
emitToUser(userId: string, event: SocketEvent, data: any) {
|
|
const room = `user:${userId}`;
|
|
this.server.to(room).emit(event, {
|
|
...data,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
this.logger.debug(`Emitted ${event} to user ${userId}`);
|
|
}
|
|
|
|
/**
|
|
* Emit notification to multiple users
|
|
*/
|
|
emitToUsers(userIds: string[], event: SocketEvent, data: any) {
|
|
userIds.forEach((userId) => {
|
|
this.emitToUser(userId, event, data);
|
|
});
|
|
this.logger.debug(`Emitted ${event} to ${userIds.length} users`);
|
|
}
|
|
|
|
/**
|
|
* Broadcast to all connected users
|
|
*/
|
|
broadcast(event: SocketEvent, data: any) {
|
|
this.server.emit(event, {
|
|
...data,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
this.logger.debug(`Broadcasted ${event} to all connected users`);
|
|
}
|
|
|
|
/**
|
|
* Get connected users count
|
|
*/
|
|
getConnectedUsersCount(): number {
|
|
return this.userSockets.size;
|
|
}
|
|
|
|
/**
|
|
* Check if user is connected
|
|
*/
|
|
isUserConnected(userId: string): boolean {
|
|
return this.userSockets.has(userId) && this.userSockets.get(userId)!.size > 0;
|
|
}
|
|
|
|
/**
|
|
* Get user's socket count
|
|
*/
|
|
getUserSocketCount(userId: string): number {
|
|
return this.userSockets.get(userId)?.size || 0;
|
|
}
|
|
|
|
// ==========================================================================
|
|
// TEACHER PORTAL METHODS (P2-01: 2025-12-18)
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Subscribe teacher to classroom updates
|
|
*/
|
|
@UseGuards(WsJwtGuard)
|
|
@SubscribeMessage('teacher:subscribe_classroom')
|
|
async handleSubscribeClassroom(
|
|
@ConnectedSocket() client: AuthenticatedSocket,
|
|
@MessageBody() data: { classroomId: string },
|
|
) {
|
|
try {
|
|
const userId = client.userData!.userId;
|
|
const { classroomId } = data;
|
|
const room = `classroom:${classroomId}`;
|
|
|
|
await client.join(room);
|
|
this.logger.debug(`Teacher ${userId} subscribed to classroom ${classroomId}`);
|
|
|
|
return { success: true, room };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
this.logger.error('Error subscribing to classroom:', error);
|
|
return { success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe teacher from classroom updates
|
|
*/
|
|
@UseGuards(WsJwtGuard)
|
|
@SubscribeMessage('teacher:unsubscribe_classroom')
|
|
async handleUnsubscribeClassroom(
|
|
@ConnectedSocket() client: AuthenticatedSocket,
|
|
@MessageBody() data: { classroomId: string },
|
|
) {
|
|
try {
|
|
const userId = client.userData!.userId;
|
|
const { classroomId } = data;
|
|
const room = `classroom:${classroomId}`;
|
|
|
|
await client.leave(room);
|
|
this.logger.debug(`Teacher ${userId} unsubscribed from classroom ${classroomId}`);
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
this.logger.error('Error unsubscribing from classroom:', error);
|
|
return { success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit student activity to classroom teachers
|
|
*/
|
|
emitStudentActivity(classroomId: string, payload: Omit<StudentActivityPayload, 'timestamp'>) {
|
|
const room = `classroom:${classroomId}`;
|
|
this.server.to(room).emit(SocketEvent.STUDENT_ACTIVITY, {
|
|
...payload,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
this.logger.debug(`Student activity emitted to classroom ${classroomId}`);
|
|
}
|
|
|
|
/**
|
|
* Emit classroom update to subscribed teachers
|
|
*/
|
|
emitClassroomUpdate(classroomId: string, payload: Omit<ClassroomUpdatePayload, 'timestamp'>) {
|
|
const room = `classroom:${classroomId}`;
|
|
this.server.to(room).emit(SocketEvent.CLASSROOM_UPDATE, {
|
|
...payload,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
this.logger.debug(`Classroom update emitted to ${classroomId}`);
|
|
}
|
|
|
|
/**
|
|
* Emit new submission notification to classroom teachers
|
|
*/
|
|
emitNewSubmission(classroomId: string, payload: Omit<NewSubmissionPayload, 'timestamp'>) {
|
|
const room = `classroom:${classroomId}`;
|
|
this.server.to(room).emit(SocketEvent.NEW_SUBMISSION, {
|
|
...payload,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
this.logger.debug(`New submission emitted to classroom ${classroomId}`);
|
|
}
|
|
|
|
/**
|
|
* Emit alert to specific teacher and classroom
|
|
*/
|
|
emitAlertTriggered(
|
|
teacherId: string,
|
|
classroomId: string,
|
|
payload: Omit<AlertTriggeredPayload, 'timestamp'>,
|
|
) {
|
|
// Emit to teacher's personal room
|
|
this.emitToUser(teacherId, SocketEvent.ALERT_TRIGGERED, payload);
|
|
// Also emit to classroom room for other subscribed teachers
|
|
const room = `classroom:${classroomId}`;
|
|
this.server.to(room).emit(SocketEvent.ALERT_TRIGGERED, {
|
|
...payload,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
this.logger.debug(`Alert triggered for teacher ${teacherId} in classroom ${classroomId}`);
|
|
}
|
|
|
|
/**
|
|
* Emit student online/offline status to classroom
|
|
*/
|
|
emitStudentOnlineStatus(classroomId: string, payload: Omit<StudentOnlineStatusPayload, 'timestamp'>) {
|
|
const room = `classroom:${classroomId}`;
|
|
const event = payload.isOnline ? SocketEvent.STUDENT_ONLINE : SocketEvent.STUDENT_OFFLINE;
|
|
this.server.to(room).emit(event, {
|
|
...payload,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
this.logger.debug(`Student ${payload.studentId} is now ${payload.isOnline ? 'online' : 'offline'}`);
|
|
}
|
|
|
|
/**
|
|
* Emit progress update for a student
|
|
*/
|
|
emitProgressUpdate(
|
|
teacherIds: string[],
|
|
classroomId: string,
|
|
data: {
|
|
studentId: string;
|
|
studentName: string;
|
|
progressType: 'module_complete' | 'exercise_complete' | 'level_up' | 'achievement';
|
|
details: Record<string, unknown>;
|
|
},
|
|
) {
|
|
const payload = {
|
|
...data,
|
|
classroomId,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
// Emit to all relevant teachers
|
|
teacherIds.forEach((teacherId) => {
|
|
this.emitToUser(teacherId, SocketEvent.PROGRESS_UPDATE, payload);
|
|
});
|
|
|
|
// Also emit to classroom room
|
|
const room = `classroom:${classroomId}`;
|
|
this.server.to(room).emit(SocketEvent.PROGRESS_UPDATE, payload);
|
|
|
|
this.logger.debug(`Progress update emitted for student ${data.studentId}`);
|
|
}
|
|
|
|
/**
|
|
* Get count of teachers subscribed to a classroom
|
|
*/
|
|
async getClassroomSubscriberCount(classroomId: string): Promise<number> {
|
|
const room = `classroom:${classroomId}`;
|
|
const sockets = await this.server.in(room).fetchSockets();
|
|
return sockets.length;
|
|
}
|
|
}
|