workspace/projects/gamilit/apps/backend/src/modules/websocket/notifications.gateway.ts
rckrdmrd 9660dfbe07 feat(teacher-portal): Implementar Sprint 1-3 completo (P0-P2)
## 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>
2025-12-18 21:41:56 -06:00

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;
}
}