brief-20/WEBSOCKET_IMPLEMENTATION_PLAN.md
Avnyr ef934a8599 docs: add implementation plans for authentication and database schema
Documented comprehensive implementation plans for the authentication system and database schema, including architecture, module structure, API integration, security measures, and GDPR compliance details.
2025-05-15 13:10:00 +02:00

25 KiB

Plan d'Implémentation des WebSockets

Ce document détaille le plan d'implémentation du système de communication en temps réel via WebSockets pour l'application de création de groupes, basé sur les spécifications du cahier des charges.

1. Vue d'Ensemble

L'application utilisera Socket.IO pour établir une communication bidirectionnelle en temps réel entre le client et le serveur. Cette fonctionnalité permettra :

  • La mise à jour instantanée des groupes
  • Les notifications en temps réel
  • La collaboration simultanée entre utilisateurs

2. Architecture WebSocket

sequenceDiagram
    participant Client as Client (Next.js)
    participant Gateway as WebSocket Gateway (NestJS)
    participant Service as Services NestJS
    participant DB as Base de données

    Client->>Gateway: Connexion WebSocket
    Gateway->>Gateway: Authentification (JWT)
    Gateway->>Client: Connexion établie
    
    Client->>Gateway: Rejoindre une salle (projet)
    Gateway->>Client: Confirmation d'adhésion à la salle
    
    Note over Client,Gateway: Communication bidirectionnelle
    
    Client->>Gateway: Événement (ex: modification groupe)
    Gateway->>Service: Traitement de l'événement
    Service->>DB: Mise à jour des données
    Service->>Gateway: Résultat de l'opération
    Gateway->>Client: Diffusion aux clients concernés

3. Structure des Modules

3.1 Module WebSockets

// src/modules/websockets/websockets.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ProjectsModule } from '../projects/projects.module';
import { GroupsModule } from '../groups/groups.module';
import { UsersModule } from '../users/users.module';
import { ProjectsGateway } from './gateways/projects.gateway';
import { GroupsGateway } from './gateways/groups.gateway';
import { NotificationsGateway } from './gateways/notifications.gateway';
import { WebSocketService } from './services/websocket.service';

@Module({
  imports: [
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_ACCESS_SECRET'),
      }),
    }),
    ProjectsModule,
    GroupsModule,
    UsersModule,
  ],
  providers: [
    ProjectsGateway,
    GroupsGateway,
    NotificationsGateway,
    WebSocketService,
  ],
  exports: [WebSocketService],
})
export class WebSocketsModule {}

3.2 Gateways WebSocket

3.2.1 Gateway de Base

// src/modules/websockets/gateways/base.gateway.ts
import {
  OnGatewayConnection,
  OnGatewayDisconnect,
  OnGatewayInit,
  WebSocketServer,
} from '@nestjs/websockets';
import { Logger, UseGuards } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { WsJwtGuard } from '../guards/ws-jwt.guard';
import { WebSocketService } from '../services/websocket.service';

export abstract class BaseGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer() server: Server;
  protected logger = new Logger(this.constructor.name);

  constructor(
    protected readonly jwtService: JwtService,
    protected readonly webSocketService: WebSocketService,
  ) {}

  afterInit(server: Server) {
    this.webSocketService.setServer(server);
    this.logger.log('WebSocket Gateway initialized');
  }

  @UseGuards(WsJwtGuard)
  async handleConnection(client: Socket) {
    try {
      const token = this.extractTokenFromHeader(client);
      if (!token) {
        this.disconnect(client);
        return;
      }

      const payload = this.jwtService.verify(token);
      const userId = payload.sub;

      // Associer l'ID utilisateur au socket
      client.data.userId = userId;
      
      // Ajouter le client à la liste des clients connectés
      this.webSocketService.addClient(userId, client.id);
      
      this.logger.log(`Client connected: ${client.id}, User: ${userId}`);
    } catch (error) {
      this.logger.error(`Connection error: ${error.message}`);
      this.disconnect(client);
    }
  }

  handleDisconnect(client: Socket) {
    const userId = client.data.userId;
    if (userId) {
      this.webSocketService.removeClient(userId, client.id);
    }
    this.logger.log(`Client disconnected: ${client.id}`);
  }

  private extractTokenFromHeader(client: Socket): string | undefined {
    const auth = client.handshake.auth.token || client.handshake.headers.authorization;
    if (!auth) return undefined;
    
    const parts = auth.split(' ');
    if (parts.length === 2 && parts[0] === 'Bearer') {
      return parts[1];
    }
    return undefined;
  }

  private disconnect(client: Socket) {
    client.disconnect();
  }
}

3.2.2 Gateway des Projets

// src/modules/websockets/gateways/projects.gateway.ts
import {
  WebSocketGateway,
  SubscribeMessage,
  MessageBody,
  ConnectedSocket,
} from '@nestjs/websockets';
import { UseGuards } from '@nestjs/common';
import { Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { ProjectsService } from '../../projects/services/projects.service';
import { BaseGateway } from './base.gateway';
import { WsJwtGuard } from '../guards/ws-jwt.guard';
import { WebSocketService } from '../services/websocket.service';
import { JoinProjectDto } from '../dto/join-project.dto';
import { ProjectUpdatedEvent } from '../events/project-updated.event';

@WebSocketGateway({
  cors: {
    origin: '*',
  },
  namespace: 'projects',
})
export class ProjectsGateway extends BaseGateway {
  constructor(
    protected readonly jwtService: JwtService,
    protected readonly webSocketService: WebSocketService,
    private readonly projectsService: ProjectsService,
  ) {
    super(jwtService, webSocketService);
  }

  @UseGuards(WsJwtGuard)
  @SubscribeMessage('joinProject')
  async handleJoinProject(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: JoinProjectDto,
  ) {
    try {
      const { projectId } = data;
      const userId = client.data.userId;

      // Vérifier si l'utilisateur a accès au projet
      const hasAccess = await this.projectsService.checkUserAccess(projectId, userId);
      if (!hasAccess) {
        return { error: 'Access denied to this project' };
      }

      // Rejoindre la salle du projet
      const roomName = `project:${projectId}`;
      await client.join(roomName);
      
      // Enregistrer l'association utilisateur-projet
      this.webSocketService.addUserToProject(userId, projectId, client.id);
      
      this.logger.log(`User ${userId} joined project ${projectId}`);
      
      return { success: true, message: `Joined project ${projectId}` };
    } catch (error) {
      this.logger.error(`Error joining project: ${error.message}`);
      return { error: 'Failed to join project' };
    }
  }

  @UseGuards(WsJwtGuard)
  @SubscribeMessage('leaveProject')
  async handleLeaveProject(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: JoinProjectDto,
  ) {
    try {
      const { projectId } = data;
      const userId = client.data.userId;
      
      // Quitter la salle du projet
      const roomName = `project:${projectId}`;
      await client.leave(roomName);
      
      // Supprimer l'association utilisateur-projet
      this.webSocketService.removeUserFromProject(userId, projectId, client.id);
      
      this.logger.log(`User ${userId} left project ${projectId}`);
      
      return { success: true, message: `Left project ${projectId}` };
    } catch (error) {
      this.logger.error(`Error leaving project: ${error.message}`);
      return { error: 'Failed to leave project' };
    }
  }

  @UseGuards(WsJwtGuard)
  @SubscribeMessage('projectUpdated')
  async handleProjectUpdated(
    @ConnectedSocket() client: Socket,
    @MessageBody() event: ProjectUpdatedEvent,
  ) {
    try {
      const { projectId, data } = event;
      const userId = client.data.userId;
      
      // Vérifier si l'utilisateur a accès au projet
      const hasAccess = await this.projectsService.checkUserAccess(projectId, userId);
      if (!hasAccess) {
        return { error: 'Access denied to this project' };
      }
      
      // Diffuser la mise à jour à tous les clients dans la salle du projet
      const roomName = `project:${projectId}`;
      this.server.to(roomName).emit('projectUpdated', {
        projectId,
        data,
        updatedBy: userId,
        timestamp: new Date().toISOString(),
      });
      
      this.logger.log(`Project ${projectId} updated by user ${userId}`);
      
      return { success: true };
    } catch (error) {
      this.logger.error(`Error updating project: ${error.message}`);
      return { error: 'Failed to update project' };
    }
  }
}

3.2.3 Gateway des Groupes

// src/modules/websockets/gateways/groups.gateway.ts
import {
  WebSocketGateway,
  SubscribeMessage,
  MessageBody,
  ConnectedSocket,
} from '@nestjs/websockets';
import { UseGuards } from '@nestjs/common';
import { Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { GroupsService } from '../../groups/services/groups.service';
import { ProjectsService } from '../../projects/services/projects.service';
import { BaseGateway } from './base.gateway';
import { WsJwtGuard } from '../guards/ws-jwt.guard';
import { WebSocketService } from '../services/websocket.service';
import { GroupCreatedEvent } from '../events/group-created.event';
import { GroupUpdatedEvent } from '../events/group-updated.event';
import { GroupDeletedEvent } from '../events/group-deleted.event';
import { PersonMovedEvent } from '../events/person-moved.event';

@WebSocketGateway({
  cors: {
    origin: '*',
  },
  namespace: 'groups',
})
export class GroupsGateway extends BaseGateway {
  constructor(
    protected readonly jwtService: JwtService,
    protected readonly webSocketService: WebSocketService,
    private readonly groupsService: GroupsService,
    private readonly projectsService: ProjectsService,
  ) {
    super(jwtService, webSocketService);
  }

  @UseGuards(WsJwtGuard)
  @SubscribeMessage('groupCreated')
  async handleGroupCreated(
    @ConnectedSocket() client: Socket,
    @MessageBody() event: GroupCreatedEvent,
  ) {
    try {
      const { projectId, group } = event;
      const userId = client.data.userId;
      
      // Vérifier si l'utilisateur a accès au projet
      const hasAccess = await this.projectsService.checkUserAccess(projectId, userId);
      if (!hasAccess) {
        return { error: 'Access denied to this project' };
      }
      
      // Diffuser la création du groupe à tous les clients dans la salle du projet
      const roomName = `project:${projectId}`;
      this.server.to(roomName).emit('groupCreated', {
        projectId,
        group,
        createdBy: userId,
        timestamp: new Date().toISOString(),
      });
      
      this.logger.log(`Group created in project ${projectId} by user ${userId}`);
      
      return { success: true };
    } catch (error) {
      this.logger.error(`Error creating group: ${error.message}`);
      return { error: 'Failed to create group' };
    }
  }

  @UseGuards(WsJwtGuard)
  @SubscribeMessage('groupUpdated')
  async handleGroupUpdated(
    @ConnectedSocket() client: Socket,
    @MessageBody() event: GroupUpdatedEvent,
  ) {
    try {
      const { projectId, groupId, data } = event;
      const userId = client.data.userId;
      
      // Vérifier si l'utilisateur a accès au projet
      const hasAccess = await this.projectsService.checkUserAccess(projectId, userId);
      if (!hasAccess) {
        return { error: 'Access denied to this project' };
      }
      
      // Diffuser la mise à jour du groupe à tous les clients dans la salle du projet
      const roomName = `project:${projectId}`;
      this.server.to(roomName).emit('groupUpdated', {
        projectId,
        groupId,
        data,
        updatedBy: userId,
        timestamp: new Date().toISOString(),
      });
      
      this.logger.log(`Group ${groupId} updated in project ${projectId} by user ${userId}`);
      
      return { success: true };
    } catch (error) {
      this.logger.error(`Error updating group: ${error.message}`);
      return { error: 'Failed to update group' };
    }
  }

  @UseGuards(WsJwtGuard)
  @SubscribeMessage('groupDeleted')
  async handleGroupDeleted(
    @ConnectedSocket() client: Socket,
    @MessageBody() event: GroupDeletedEvent,
  ) {
    try {
      const { projectId, groupId } = event;
      const userId = client.data.userId;
      
      // Vérifier si l'utilisateur a accès au projet
      const hasAccess = await this.projectsService.checkUserAccess(projectId, userId);
      if (!hasAccess) {
        return { error: 'Access denied to this project' };
      }
      
      // Diffuser la suppression du groupe à tous les clients dans la salle du projet
      const roomName = `project:${projectId}`;
      this.server.to(roomName).emit('groupDeleted', {
        projectId,
        groupId,
        deletedBy: userId,
        timestamp: new Date().toISOString(),
      });
      
      this.logger.log(`Group ${groupId} deleted from project ${projectId} by user ${userId}`);
      
      return { success: true };
    } catch (error) {
      this.logger.error(`Error deleting group: ${error.message}`);
      return { error: 'Failed to delete group' };
    }
  }

  @UseGuards(WsJwtGuard)
  @SubscribeMessage('personMoved')
  async handlePersonMoved(
    @ConnectedSocket() client: Socket,
    @MessageBody() event: PersonMovedEvent,
  ) {
    try {
      const { projectId, personId, fromGroupId, toGroupId } = event;
      const userId = client.data.userId;
      
      // Vérifier si l'utilisateur a accès au projet
      const hasAccess = await this.projectsService.checkUserAccess(projectId, userId);
      if (!hasAccess) {
        return { error: 'Access denied to this project' };
      }
      
      // Diffuser le déplacement de la personne à tous les clients dans la salle du projet
      const roomName = `project:${projectId}`;
      this.server.to(roomName).emit('personMoved', {
        projectId,
        personId,
        fromGroupId,
        toGroupId,
        movedBy: userId,
        timestamp: new Date().toISOString(),
      });
      
      this.logger.log(`Person ${personId} moved from group ${fromGroupId} to group ${toGroupId} in project ${projectId} by user ${userId}`);
      
      return { success: true };
    } catch (error) {
      this.logger.error(`Error moving person: ${error.message}`);
      return { error: 'Failed to move person' };
    }
  }
}

3.2.4 Gateway des Notifications

// src/modules/websockets/gateways/notifications.gateway.ts
import {
  WebSocketGateway,
  SubscribeMessage,
  MessageBody,
  ConnectedSocket,
} from '@nestjs/websockets';
import { UseGuards } from '@nestjs/common';
import { Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { BaseGateway } from './base.gateway';
import { WsJwtGuard } from '../guards/ws-jwt.guard';
import { WebSocketService } from '../services/websocket.service';
import { NotificationEvent } from '../events/notification.event';

@WebSocketGateway({
  cors: {
    origin: '*',
  },
  namespace: 'notifications',
})
export class NotificationsGateway extends BaseGateway {
  constructor(
    protected readonly jwtService: JwtService,
    protected readonly webSocketService: WebSocketService,
  ) {
    super(jwtService, webSocketService);
  }

  @UseGuards(WsJwtGuard)
  @SubscribeMessage('subscribeToNotifications')
  async handleSubscribeToNotifications(@ConnectedSocket() client: Socket) {
    try {
      const userId = client.data.userId;
      
      // Rejoindre la salle des notifications personnelles
      const roomName = `user:${userId}:notifications`;
      await client.join(roomName);
      
      this.logger.log(`User ${userId} subscribed to notifications`);
      
      return { success: true, message: 'Subscribed to notifications' };
    } catch (error) {
      this.logger.error(`Error subscribing to notifications: ${error.message}`);
      return { error: 'Failed to subscribe to notifications' };
    }
  }

  @UseGuards(WsJwtGuard)
  @SubscribeMessage('unsubscribeFromNotifications')
  async handleUnsubscribeFromNotifications(@ConnectedSocket() client: Socket) {
    try {
      const userId = client.data.userId;
      
      // Quitter la salle des notifications personnelles
      const roomName = `user:${userId}:notifications`;
      await client.leave(roomName);
      
      this.logger.log(`User ${userId} unsubscribed from notifications`);
      
      return { success: true, message: 'Unsubscribed from notifications' };
    } catch (error) {
      this.logger.error(`Error unsubscribing from notifications: ${error.message}`);
      return { error: 'Failed to unsubscribe from notifications' };
    }
  }

  @UseGuards(WsJwtGuard)
  @SubscribeMessage('sendNotification')
  async handleSendNotification(
    @ConnectedSocket() client: Socket,
    @MessageBody() event: NotificationEvent,
  ) {
    try {
      const { recipientId, type, data } = event;
      const senderId = client.data.userId;
      
      // Diffuser la notification à l'utilisateur spécifique
      const roomName = `user:${recipientId}:notifications`;
      this.server.to(roomName).emit('notification', {
        type,
        data,
        senderId,
        timestamp: new Date().toISOString(),
      });
      
      this.logger.log(`Notification sent from user ${senderId} to user ${recipientId}`);
      
      return { success: true };
    } catch (error) {
      this.logger.error(`Error sending notification: ${error.message}`);
      return { error: 'Failed to send notification' };
    }
  }
}

3.3 Service WebSocket

// src/modules/websockets/services/websocket.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Server } from 'socket.io';

@Injectable()
export class WebSocketService {
  private server: Server;
  private readonly logger = new Logger(WebSocketService.name);
  
  // Map des clients connectés par utilisateur
  private readonly connectedClients = new Map<string, Set<string>>();
  
  // Map des projets par utilisateur
  private readonly userProjects = new Map<string, Set<string>>();
  
  // Map des utilisateurs par projet
  private readonly projectUsers = new Map<string, Set<string>>();
  
  // Map des sockets par projet
  private readonly projectSockets = new Map<string, Set<string>>();

  setServer(server: Server) {
    this.server = server;
  }

  getServer(): Server {
    return this.server;
  }

  // Gestion des clients connectés
  addClient(userId: string, socketId: string) {
    if (!this.connectedClients.has(userId)) {
      this.connectedClients.set(userId, new Set());
    }
    this.connectedClients.get(userId).add(socketId);
    this.logger.debug(`Client ${socketId} added for user ${userId}`);
  }

  removeClient(userId: string, socketId: string) {
    if (this.connectedClients.has(userId)) {
      this.connectedClients.get(userId).delete(socketId);
      if (this.connectedClients.get(userId).size === 0) {
        this.connectedClients.delete(userId);
      }
    }
    
    // Nettoyer les associations projet-utilisateur
    this.cleanupUserProjects(userId, socketId);
    
    this.logger.debug(`Client ${socketId} removed for user ${userId}`);
  }

  isUserConnected(userId: string): boolean {
    return this.connectedClients.has(userId) && this.connectedClients.get(userId).size > 0;
  }

  getUserSocketIds(userId: string): string[] {
    if (!this.connectedClients.has(userId)) {
      return [];
    }
    return Array.from(this.connectedClients.get(userId));
  }

  // Gestion des associations utilisateur-projet
  addUserToProject(userId: string, projectId: string, socketId: string) {
    // Ajouter le projet à l'utilisateur
    if (!this.userProjects.has(userId)) {
      this.userProjects.set(userId, new Set());
    }
    this.userProjects.get(userId).add(projectId);
    
    // Ajouter l'utilisateur au projet
    if (!this.projectUsers.has(projectId)) {
      this.projectUsers.set(projectId, new Set());
    }
    this.projectUsers.get(projectId).add(userId);
    
    // Ajouter le socket au projet
    if (!this.projectSockets.has(projectId)) {
      this.projectSockets.set(projectId, new Set());
    }
    this.projectSockets.get(projectId).add(socketId);
    
    this.logger.debug(`User ${userId} added to project ${projectId} with socket ${socketId}`);
  }

  removeUserFromProject(userId: string, projectId: string, socketId: string) {
    // Supprimer le socket du projet
    if (this.projectSockets.has(projectId)) {
      this.projectSockets.get(projectId).delete(socketId);
      if (this.projectSockets.get(projectId).size === 0) {
        this.projectSockets.delete(projectId);
      }
    }
    
    // Vérifier si l'utilisateur a d'autres sockets connectés au projet
    const userSocketIds = this.getUserSocketIds(userId);
    const hasOtherSocketsInProject = userSocketIds.some(sid => 
      sid !== socketId && this.projectSockets.has(projectId) && this.projectSockets.get(projectId).has(sid)
    );
    
    // Si l'utilisateur n'a plus de sockets connectés au projet, supprimer l'association
    if (!hasOtherSocketsInProject) {
      // Supprimer le projet de l'utilisateur
      if (this.userProjects.has(userId)) {
        this.userProjects.get(userId).delete(projectId);
        if (this.userProjects.get(userId).size === 0) {
          this.userProjects.delete(userId);
        }
      }
      
      // Supprimer l'utilisateur du projet
      if (this.projectUsers.has(projectId)) {
        this.projectUsers.get(projectId).delete(userId);
        if (this.projectUsers.get(projectId).size === 0) {
          this.projectUsers.delete(projectId);
        }
      }
    }
    
    this.logger.debug(`User ${userId} removed from project ${projectId} with socket ${socketId}`);
  }

  getUserProjects(userId: string): string[] {
    if (!this.userProjects.has(userId)) {
      return [];
    }
    return Array.from(this.userProjects.get(userId));
  }

  getProjectUsers(projectId: string): string[] {
    if (!this.projectUsers.has(projectId)) {
      return [];
    }
    return Array.from(this.projectUsers.get(projectId));
  }

  // Nettoyage des associations lors de la déconnexion
  private cleanupUserProjects(userId: string, socketId: string) {
    const projectIds = this.getUserProjects(userId);
    for (const projectId of projectIds) {
      this.removeUserFromProject(userId, projectId, socketId);
    }
  }

  // Méthodes pour envoyer des messages
  sendToUser(userId: string, event: string, data: any) {
    const socketIds = this.getUserSocketIds(userId);
    for (const socketId of socketIds) {
      this.server.to(socketId).emit(event, data);
    }
    this.logger.debug(`Event ${event} sent to user ${userId}`);
  }

  sendToProject(projectId: string, event: string, data: any) {
    const roomName = `project:${projectId}`;
    this.server.to(roomName).emit(event, data);
    this.logger.debug(`Event ${event} sent to project ${projectId}`);
  }

  broadcastToAll(event: string, data: any) {
    this.server.emit(event, data);
    this.logger.debug(`Event ${event} broadcasted to all connected clients`);
  }
}

3.4 Guard WebSocket JWT

// src/modules/websockets/guards/ws-jwt.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';

@Injectable()
export class WsJwtGuard implements CanActivate {
  constructor(private readonly jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    try {
      const client: Socket = context.switchToWs().getClient();
      const token = this.extractTokenFromHeader(client);
      
      if (!token) {
        throw new WsException('Unauthorized');
      }
      
      const payload = this.jwtService.verify(token);
      client.data.userId = payload.sub;
      
      return true;
    } catch (error) {
      throw new WsException('Unauthorized');
    }
  }

  private extractTokenFromHeader(client: Socket): string | undefined {
    const auth = client.handshake.auth.token || client.handshake.headers.authorization;
    if (!auth) return undefined;
    
    const parts = auth.split(' ');
    if (parts.length === 2 && parts[0] === 'Bearer') {
      return parts[1];
    }
    return undefined;
  }
}

3.5 DTOs et Événements

3.5.1 DTO JoinProject

// src/modules/websockets/dto/join-project.dto.ts
import { IsUUID, IsNotEmpty } from 'class-validator';

export class JoinProjectDto {
  @IsUUID()
  @IsNotEmpty()
  projectId: string;
}

3.5.2 Événement ProjectUpdated