New projects created: - michangarrito (marketplace mobile) - template-saas (SaaS template) - clinica-dental (dental ERP) - clinica-veterinaria (veterinary ERP) Architecture updates: - Move catalog from core/ to shared/ - Add MCP servers structure and templates - Add git management scripts - Update SUBREPOSITORIOS.md with 15 new repos - Update .gitignore for new projects Repository infrastructure: - 4 main repositories - 11 subrepositorios - Gitea remotes configured 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
199 lines
5.2 KiB
TypeScript
199 lines
5.2 KiB
TypeScript
/**
|
|
* WEBSOCKET GATEWAY - REFERENCE IMPLEMENTATION
|
|
*
|
|
* @description Gateway WebSocket para comunicación en tiempo real.
|
|
* Soporta autenticación JWT, rooms y eventos tipados.
|
|
*
|
|
* @usage
|
|
* ```typescript
|
|
* // En el cliente
|
|
* const socket = io('http://localhost:3000', {
|
|
* auth: { token: 'jwt-token' }
|
|
* });
|
|
* socket.emit('join-room', { roomId: 'room-1' });
|
|
* socket.on('message', (data) => console.log(data));
|
|
* ```
|
|
*
|
|
* @origin gamilit (patrón base)
|
|
*/
|
|
|
|
import {
|
|
WebSocketGateway,
|
|
WebSocketServer,
|
|
SubscribeMessage,
|
|
OnGatewayConnection,
|
|
OnGatewayDisconnect,
|
|
ConnectedSocket,
|
|
MessageBody,
|
|
} from '@nestjs/websockets';
|
|
import { Server, Socket } from 'socket.io';
|
|
import { Logger, UseGuards } from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
|
|
// Adaptar imports según proyecto
|
|
// import { WsAuthGuard } from '../guards';
|
|
|
|
@WebSocketGateway({
|
|
cors: {
|
|
origin: process.env.CORS_ORIGIN || '*',
|
|
credentials: true,
|
|
},
|
|
namespace: '/ws',
|
|
})
|
|
export class AppWebSocketGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|
@WebSocketServer()
|
|
server: Server;
|
|
|
|
private readonly logger = new Logger(AppWebSocketGateway.name);
|
|
private readonly connectedClients = new Map<string, ConnectedClient>();
|
|
|
|
constructor(private readonly jwtService: JwtService) {}
|
|
|
|
/**
|
|
* Manejar nueva conexión
|
|
*/
|
|
async handleConnection(client: Socket) {
|
|
try {
|
|
// Extraer y validar token
|
|
const token = client.handshake.auth?.token || client.handshake.headers?.authorization?.replace('Bearer ', '');
|
|
|
|
if (!token) {
|
|
this.disconnect(client, 'No token provided');
|
|
return;
|
|
}
|
|
|
|
const payload = this.jwtService.verify(token);
|
|
|
|
// Almacenar cliente conectado
|
|
this.connectedClients.set(client.id, {
|
|
socketId: client.id,
|
|
userId: payload.sub,
|
|
role: payload.role,
|
|
connectedAt: new Date(),
|
|
});
|
|
|
|
// Auto-join a room del usuario
|
|
client.join(`user:${payload.sub}`);
|
|
|
|
this.logger.log(`Client connected: ${client.id} (user: ${payload.sub})`);
|
|
client.emit('connected', { message: 'Successfully connected' });
|
|
|
|
} catch (error) {
|
|
this.disconnect(client, 'Invalid token');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Manejar desconexión
|
|
*/
|
|
handleDisconnect(client: Socket) {
|
|
const clientInfo = this.connectedClients.get(client.id);
|
|
this.connectedClients.delete(client.id);
|
|
this.logger.log(`Client disconnected: ${client.id} (user: ${clientInfo?.userId || 'unknown'})`);
|
|
}
|
|
|
|
/**
|
|
* Unirse a una sala
|
|
*/
|
|
@SubscribeMessage('join-room')
|
|
handleJoinRoom(
|
|
@ConnectedSocket() client: Socket,
|
|
@MessageBody() data: { roomId: string },
|
|
) {
|
|
client.join(data.roomId);
|
|
this.logger.debug(`Client ${client.id} joined room: ${data.roomId}`);
|
|
return { event: 'room-joined', data: { roomId: data.roomId } };
|
|
}
|
|
|
|
/**
|
|
* Salir de una sala
|
|
*/
|
|
@SubscribeMessage('leave-room')
|
|
handleLeaveRoom(
|
|
@ConnectedSocket() client: Socket,
|
|
@MessageBody() data: { roomId: string },
|
|
) {
|
|
client.leave(data.roomId);
|
|
this.logger.debug(`Client ${client.id} left room: ${data.roomId}`);
|
|
return { event: 'room-left', data: { roomId: data.roomId } };
|
|
}
|
|
|
|
/**
|
|
* Enviar mensaje a una sala
|
|
*/
|
|
@SubscribeMessage('message')
|
|
handleMessage(
|
|
@ConnectedSocket() client: Socket,
|
|
@MessageBody() data: { roomId: string; message: string },
|
|
) {
|
|
const clientInfo = this.connectedClients.get(client.id);
|
|
|
|
// Broadcast a la sala (excepto al sender)
|
|
client.to(data.roomId).emit('message', {
|
|
senderId: clientInfo?.userId,
|
|
message: data.message,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
return { event: 'message-sent', data: { success: true } };
|
|
}
|
|
|
|
// ============ MÉTODOS PÚBLICOS PARA SERVICIOS ============
|
|
|
|
/**
|
|
* Enviar notificación a un usuario específico
|
|
*/
|
|
sendToUser(userId: string, event: string, data: any) {
|
|
this.server.to(`user:${userId}`).emit(event, data);
|
|
}
|
|
|
|
/**
|
|
* Enviar a una sala
|
|
*/
|
|
sendToRoom(roomId: string, event: string, data: any) {
|
|
this.server.to(roomId).emit(event, data);
|
|
}
|
|
|
|
/**
|
|
* Broadcast a todos los clientes conectados
|
|
*/
|
|
broadcast(event: string, data: any) {
|
|
this.server.emit(event, data);
|
|
}
|
|
|
|
/**
|
|
* Obtener clientes conectados en una sala
|
|
*/
|
|
async getClientsInRoom(roomId: string): Promise<string[]> {
|
|
const sockets = await this.server.in(roomId).fetchSockets();
|
|
return sockets.map(s => s.id);
|
|
}
|
|
|
|
/**
|
|
* Verificar si un usuario está conectado
|
|
*/
|
|
isUserConnected(userId: string): boolean {
|
|
for (const client of this.connectedClients.values()) {
|
|
if (client.userId === userId) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ============ HELPERS PRIVADOS ============
|
|
|
|
private disconnect(client: Socket, reason: string) {
|
|
client.emit('error', { message: reason });
|
|
client.disconnect(true);
|
|
this.logger.warn(`Client ${client.id} disconnected: ${reason}`);
|
|
}
|
|
}
|
|
|
|
// ============ TIPOS ============
|
|
|
|
interface ConnectedClient {
|
|
socketId: string;
|
|
userId: string;
|
|
role: string;
|
|
connectedAt: Date;
|
|
}
|