Sistema NEXUS v3.4 migrado con: Estructura principal: - core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles) - core/catalog: Catalogo de funcionalidades reutilizables - shared/knowledge-base: Base de conocimiento compartida - devtools/scripts: Herramientas de desarrollo - control-plane/registries: Control de servicios y CI/CD - orchestration/: Configuracion de orquestacion de agentes Proyectos incluidos (11): - gamilit (submodule -> GitHub) - trading-platform (OrbiquanTIA) - erp-suite con 5 verticales: - erp-core, construccion, vidrio-templado - mecanicas-diesel, retail, clinicas - betting-analytics - inmobiliaria-analytics - platform_marketing_content - pos-micro, erp-basico Configuracion: - .gitignore completo para Node.js/Python/Docker - gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git) - Sistema de puertos estandarizado (3005-3199) Generated with NEXUS v3.4 Migration System EPIC-010: Configuracion Git y Repositorios
19 KiB
19 KiB
Guía de Implementación: WebSocket
Versión: 1.0.0 Tiempo estimado: 1-2 horas Complejidad: Media
Pre-requisitos
- Proyecto NestJS existente
- Sistema de autenticación JWT funcionando
- (Opcional) Redis para escalabilidad horizontal
Paso 1: Instalar Dependencias
npm install @nestjs/websockets @nestjs/platform-socket.io socket.io
# Ya deberías tener @nestjs/jwt del módulo de auth
Paso 2: Crear Estructura de Directorios
mkdir -p src/modules/websocket/gateways
mkdir -p src/modules/websocket/services
mkdir -p src/modules/websocket/guards
mkdir -p src/modules/websocket/types
Paso 3: Definir Tipos y Eventos
// src/modules/websocket/types/websocket.types.ts
/**
* Enum de eventos WebSocket
* Usar namespace:action para claridad
*/
export enum SocketEvent {
// Conexión
AUTHENTICATED = 'authenticated',
ERROR = 'error',
// Notificaciones
NEW_NOTIFICATION = 'notification:new',
NOTIFICATION_READ = 'notification:read',
NOTIFICATION_DELETED = 'notification:deleted',
UNREAD_COUNT_UPDATED = 'notification:unread_count',
MARK_AS_READ = 'notification:mark_read',
// Genéricos
DATA_UPDATED = 'data:updated',
USER_ONLINE = 'user:online',
USER_OFFLINE = 'user:offline',
}
/**
* Datos del usuario en el socket
*/
export interface SocketUserData {
userId: string;
email: string;
role: string;
tenantId?: string;
}
/**
* Payload base con timestamp
*/
export interface BasePayload {
timestamp: string;
}
/**
* Payload de notificación
*/
export interface NotificationPayload extends BasePayload {
notification: {
id: string;
title: string;
message: string;
type: string;
data?: Record<string, any>;
};
}
Paso 4: Crear Guard de Autenticación JWT
// src/modules/websocket/guards/ws-jwt.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';
/**
* Socket autenticado con datos del usuario
*/
export interface AuthenticatedSocket extends Socket {
userData?: {
userId: string;
email: string;
role: string;
tenantId?: string;
};
}
@Injectable()
export class WsJwtGuard implements CanActivate {
private readonly logger = new Logger(WsJwtGuard.name);
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
try {
const client: AuthenticatedSocket = context.switchToWs().getClient();
// Extraer token de auth o query params
const token =
client.handshake.auth?.token ||
client.handshake.query?.token;
if (!token || typeof token !== 'string') {
this.logger.warn('WebSocket: conexión sin token');
throw new WsException('Token de autenticación requerido');
}
// Verificar JWT
const payload = await this.jwtService.verifyAsync(token);
// Adjuntar datos al socket
client.userData = {
userId: payload.sub,
email: payload.email,
role: payload.role,
tenantId: payload.tenant_id,
};
this.logger.log(`WebSocket autenticado: ${client.userData.email}`);
return true;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Error desconocido';
this.logger.warn(`WebSocket auth falló: ${msg}`);
throw new WsException('Autenticación fallida');
}
}
}
Paso 5: Crear Gateway Principal
// src/modules/websocket/gateways/notifications.gateway.ts
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 } from '../types/websocket.types';
@WebSocketGateway({
cors: {
origin: process.env.CORS_ORIGIN?.split(',') || [
'http://localhost:3000',
'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);
// Map: userId -> Set de socketIds
private userSockets = new Map<string, Set<string>>();
/**
* Lifecycle: Gateway inicializado
*/
afterInit(server: Server) {
this.logger.log('WebSocket Gateway inicializado');
}
/**
* Lifecycle: Cliente conectado
*/
@UseGuards(WsJwtGuard)
async handleConnection(client: AuthenticatedSocket) {
const userId = client.userData?.userId;
const userEmail = client.userData?.email;
if (!userId) {
this.logger.warn('Conexión rechazada: sin datos de usuario');
client.disconnect();
return;
}
this.logger.log(`Cliente conectado: ${userEmail} (${client.id})`);
// Registrar socket del usuario
if (!this.userSockets.has(userId)) {
this.userSockets.set(userId, new Set());
}
this.userSockets.get(userId)!.add(client.id);
// Unirse a room personal
await client.join(`user:${userId}`);
this.logger.debug(`Socket ${client.id} unido a room: user:${userId}`);
// Emitir evento de autenticación exitosa
client.emit(SocketEvent.AUTHENTICATED, {
success: true,
userId,
email: userEmail,
socketId: client.id,
});
}
/**
* Lifecycle: Cliente desconectado
*/
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(`Cliente desconectado: ${userEmail} (${client.id})`);
}
/**
* Handler: Marcar notificación como leída
*/
@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(
`Usuario ${userId} marca notificación ${notificationId} como leída`,
);
// Aquí podrías llamar a NotificationService.markAsRead(notificationId, userId)
// await this.notificationService.markAsRead(notificationId, userId);
// Confirmar al cliente
client.emit(SocketEvent.NOTIFICATION_READ, {
notificationId,
success: true,
});
return { success: true };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Error desconocido';
this.logger.error('Error marcando como leída:', error);
client.emit(SocketEvent.ERROR, {
message: 'Error al marcar notificación como leída',
});
return { success: false, error: errorMessage };
}
}
// =====================================================
// Métodos públicos para emitir eventos
// =====================================================
/**
* Emitir a un usuario específico (todos sus dispositivos)
*/
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(`Emitido ${event} a usuario ${userId}`);
}
/**
* Emitir a múltiples usuarios
*/
emitToUsers(userIds: string[], event: SocketEvent, data: any) {
userIds.forEach((userId) => {
this.emitToUser(userId, event, data);
});
this.logger.debug(`Emitido ${event} a ${userIds.length} usuarios`);
}
/**
* Broadcast a todos los conectados
*/
broadcast(event: SocketEvent, data: any) {
this.server.emit(event, {
...data,
timestamp: new Date().toISOString(),
});
this.logger.debug(`Broadcast ${event} a todos los usuarios`);
}
/**
* Emitir a una room específica
*/
emitToRoom(room: string, event: SocketEvent, data: any) {
this.server.to(room).emit(event, {
...data,
timestamp: new Date().toISOString(),
});
this.logger.debug(`Emitido ${event} a room ${room}`);
}
// =====================================================
// Métodos de utilidad
// =====================================================
/**
* Obtener cantidad de usuarios conectados
*/
getConnectedUsersCount(): number {
return this.userSockets.size;
}
/**
* Verificar si usuario está conectado
*/
isUserConnected(userId: string): boolean {
return (
this.userSockets.has(userId) && this.userSockets.get(userId)!.size > 0
);
}
/**
* Obtener cantidad de sockets de un usuario
*/
getUserSocketCount(userId: string): number {
return this.userSockets.get(userId)?.size || 0;
}
}
Paso 6: Crear Servicio de WebSocket
// src/modules/websocket/services/websocket.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { NotificationsGateway } from '../gateways/notifications.gateway';
import { SocketEvent } from '../types/websocket.types';
/**
* WebSocketService
*
* API limpia para que otros módulos emitan eventos en tiempo real
*/
@Injectable()
export class WebSocketService {
private readonly logger = new Logger(WebSocketService.name);
constructor(private readonly gateway: NotificationsGateway) {}
/**
* Emitir notificación a un usuario
*/
emitNotificationToUser(userId: string, notification: any) {
this.gateway.emitToUser(userId, SocketEvent.NEW_NOTIFICATION, {
notification,
});
this.logger.debug(`Notificación enviada a usuario ${userId}`);
}
/**
* Emitir notificación a múltiples usuarios
*/
emitNotificationToUsers(userIds: string[], notification: any) {
this.gateway.emitToUsers(userIds, SocketEvent.NEW_NOTIFICATION, {
notification,
});
this.logger.debug(`Notificación enviada a ${userIds.length} usuarios`);
}
/**
* Actualizar contador de no leídas
*/
emitUnreadCountUpdate(userId: string, unreadCount: number) {
this.gateway.emitToUser(userId, SocketEvent.UNREAD_COUNT_UPDATED, {
unreadCount,
});
this.logger.debug(`Contador (${unreadCount}) enviado a usuario ${userId}`);
}
/**
* Notificación eliminada
*/
emitNotificationDeleted(userId: string, notificationId: string) {
this.gateway.emitToUser(userId, SocketEvent.NOTIFICATION_DELETED, {
notificationId,
});
this.logger.debug(
`Eliminación ${notificationId} enviada a usuario ${userId}`,
);
}
/**
* Emitir datos actualizados genérico
*/
emitDataUpdated(userId: string, dataType: string, data: any) {
this.gateway.emitToUser(userId, SocketEvent.DATA_UPDATED, {
type: dataType,
data,
});
}
/**
* Broadcast a todos los usuarios
*/
broadcast(event: SocketEvent, data: any) {
this.gateway.broadcast(event, data);
}
/**
* Emitir a room específica
*/
emitToRoom(room: string, event: SocketEvent, data: any) {
this.gateway.emitToRoom(room, event, data);
}
/**
* Verificar si usuario está conectado
*/
isUserConnected(userId: string): boolean {
return this.gateway.isUserConnected(userId);
}
/**
* Obtener usuarios conectados
*/
getConnectedUsersCount(): number {
return this.gateway.getConnectedUsersCount();
}
/**
* Obtener cantidad de sockets de un usuario
*/
getUserSocketCount(userId: string): number {
return this.gateway.getUserSocketCount(userId);
}
}
Paso 7: Crear Módulo
// src/modules/websocket/websocket.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { NotificationsGateway } from './gateways/notifications.gateway';
import { WebSocketService } from './services/websocket.service';
import { WsJwtGuard } from './guards/ws-jwt.guard';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: config.get<string>('JWT_EXPIRES_IN', '7d'),
},
}),
}),
],
providers: [NotificationsGateway, WebSocketService, WsJwtGuard],
exports: [WebSocketService],
})
export class WebSocketModule {}
// Barrel export
// src/modules/websocket/index.ts
export * from './websocket.module';
export * from './services/websocket.service';
export * from './types/websocket.types';
export * from './guards/ws-jwt.guard';
Paso 8: Registrar en AppModule
// src/app.module.ts
import { Module } from '@nestjs/common';
import { WebSocketModule } from './modules/websocket';
@Module({
imports: [
// ... otros módulos
WebSocketModule,
],
})
export class AppModule {}
Paso 9: Uso en Otros Servicios
// src/modules/notifications/services/notification.service.ts
import { Injectable } from '@nestjs/common';
import { WebSocketService } from '@/modules/websocket';
@Injectable()
export class NotificationService {
constructor(
private readonly notificationRepo: Repository<Notification>,
private readonly wsService: WebSocketService,
) {}
async create(userId: string, dto: CreateNotificationDto) {
// 1. Guardar en BD
const notification = this.notificationRepo.create({
...dto,
user_id: userId,
});
await this.notificationRepo.save(notification);
// 2. Emitir por WebSocket si está conectado
if (this.wsService.isUserConnected(userId)) {
this.wsService.emitNotificationToUser(userId, notification);
}
// 3. Actualizar contador
const unread = await this.countUnread(userId);
this.wsService.emitUnreadCountUpdate(userId, unread);
return notification;
}
async markAsRead(notificationId: string, userId: string) {
await this.notificationRepo.update(notificationId, { status: 'read' });
// Actualizar contador
const unread = await this.countUnread(userId);
this.wsService.emitUnreadCountUpdate(userId, unread);
}
}
Paso 10: Cliente Frontend (React)
Instalación
npm install socket.io-client
Hook de WebSocket
// src/hooks/useSocket.ts
import { useEffect, useRef, useCallback, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { useAuth } from './useAuth';
interface UseSocketReturn {
socket: Socket | null;
isConnected: boolean;
on: (event: string, handler: (data: any) => void) => () => void;
emit: (event: string, data: any) => void;
}
export function useSocket(): UseSocketReturn {
const { token } = useAuth();
const socketRef = useRef<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
if (!token) return;
const socket = io(import.meta.env.VITE_API_URL || 'http://localhost:3000', {
path: '/socket.io/',
transports: ['websocket', 'polling'],
auth: { token },
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
socket.on('connect', () => {
console.log('Socket conectado');
setIsConnected(true);
});
socket.on('disconnect', (reason) => {
console.log('Socket desconectado:', reason);
setIsConnected(false);
});
socket.on('authenticated', (data) => {
console.log('Autenticado:', data.email);
});
socket.on('error', (error) => {
console.error('Error de socket:', error);
});
socketRef.current = socket;
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [token]);
const on = useCallback(
(event: string, handler: (data: any) => void) => {
socketRef.current?.on(event, handler);
return () => {
socketRef.current?.off(event, handler);
};
},
[],
);
const emit = useCallback((event: string, data: any) => {
socketRef.current?.emit(event, data);
}, []);
return {
socket: socketRef.current,
isConnected,
on,
emit,
};
}
Uso en Componente
// src/components/NotificationBell.tsx
import { useEffect, useState } from 'react';
import { useSocket } from '@/hooks/useSocket';
import { Badge, IconButton } from '@mui/material';
import NotificationsIcon from '@mui/icons-material/Notifications';
export function NotificationBell() {
const { on, isConnected } = useSocket();
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
// Escuchar actualizaciones del contador
const unsubscribe = on('notification:unread_count', (data) => {
setUnreadCount(data.unreadCount);
});
return unsubscribe;
}, [on]);
useEffect(() => {
// Escuchar nuevas notificaciones
const unsubscribe = on('notification:new', (data) => {
// Mostrar toast o actualizar lista
console.log('Nueva notificación:', data.notification);
setUnreadCount((prev) => prev + 1);
});
return unsubscribe;
}, [on]);
return (
<IconButton color={isConnected ? 'primary' : 'default'}>
<Badge badgeContent={unreadCount} color="error">
<NotificationsIcon />
</Badge>
</IconButton>
);
}
Variables de Entorno
# Backend
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=7d
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
# Frontend
VITE_API_URL=http://localhost:3000
Checklist de Implementación
- Dependencias instaladas (@nestjs/websockets, socket.io)
- Tipos y eventos definidos
- WsJwtGuard creado
- NotificationsGateway implementado
- WebSocketService creado
- Módulo registrado en AppModule
- Variables de entorno configuradas
- Build pasa sin errores
- Test: conectar desde frontend
- Test: recibir evento de notificación
Verificar Funcionamiento
Backend
# Ver logs al iniciar
npm run start:dev
# Debería mostrar: WebSocket Gateway inicializado
Frontend
// En consola del navegador
const socket = io('http://localhost:3000', {
path: '/socket.io/',
auth: { token: 'your-jwt-token' },
});
socket.on('authenticated', (data) => console.log('Auth:', data));
socket.on('notification:new', (data) => console.log('Notif:', data));
Troubleshooting
"Authentication token required"
- Verificar que el token se envía en
auth: { token } - Verificar que el token JWT es válido
"CORS error"
- Verificar que
CORS_ORIGINincluya el origen del frontend - Verificar que
credentials: trueestá configurado
Conexión se desconecta inmediatamente
- Verificar que el guard no está rechazando la conexión
- Revisar logs del backend para ver el motivo
Eventos no llegan al cliente
- Verificar que el cliente está en el room correcto (
user:{userId}) - Verificar que el evento se emite con el nombre correcto
Versión: 1.0.0 Sistema: SIMCO Catálogo