From 2697c7ebddc868ae390deab0904098a271c4fe07 Mon Sep 17 00:00:00 2001 From: Avnyr Date: Fri, 16 May 2025 16:42:15 +0200 Subject: [PATCH] feat: add WebSocket module for real-time functionalities - Implemented `WebSocketsGateway` for handling Socket.IO connections, events, and rooms. - Added `WebSocketsService` to act as a facade for emitting WebSocket events (projects, groups, notifications). - Registered `WebSocketsModule` and integrated it into `AppModule`. - Enabled real-time updates in `ProjectsService` and `GroupsService` with relevant WebSocket events (create, update, delete, collaborator/group actions). --- backend/src/app.module.ts | 2 + .../modules/groups/services/groups.service.ts | 85 +++++++--- .../projects/services/projects.service.ts | 42 ++++- .../modules/websockets/websockets.gateway.ts | 152 ++++++++++++++++++ .../modules/websockets/websockets.module.ts | 15 ++ .../modules/websockets/websockets.service.ts | 69 ++++++++ 6 files changed, 345 insertions(+), 20 deletions(-) create mode 100644 backend/src/modules/websockets/websockets.gateway.ts create mode 100644 backend/src/modules/websockets/websockets.module.ts create mode 100644 backend/src/modules/websockets/websockets.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7915b79..235393d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { ProjectsModule } from './modules/projects/projects.module'; import { AuthModule } from './modules/auth/auth.module'; import { GroupsModule } from './modules/groups/groups.module'; import { TagsModule } from './modules/tags/tags.module'; +import { WebSocketsModule } from './modules/websockets/websockets.module'; import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'; @Module({ @@ -23,6 +24,7 @@ import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'; AuthModule, GroupsModule, TagsModule, + WebSocketsModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/modules/groups/services/groups.service.ts b/backend/src/modules/groups/services/groups.service.ts index 79890f9..deb9d0b 100644 --- a/backend/src/modules/groups/services/groups.service.ts +++ b/backend/src/modules/groups/services/groups.service.ts @@ -4,10 +4,14 @@ import { DRIZZLE } from '../../../database/database.module'; import * as schema from '../../../database/schema'; import { CreateGroupDto } from '../dto/create-group.dto'; import { UpdateGroupDto } from '../dto/update-group.dto'; +import { WebSocketsService } from '../../websockets/websockets.service'; @Injectable() export class GroupsService { - constructor(@Inject(DRIZZLE) private readonly db: any) {} + constructor( + @Inject(DRIZZLE) private readonly db: any, + private readonly websocketsService: WebSocketsService, + ) {} /** * Create a new group @@ -19,6 +23,13 @@ export class GroupsService { ...createGroupDto, }) .returning(); + + // Emit group created event + this.websocketsService.emitGroupCreated(group.projectId, { + action: 'created', + group, + }); + return group; } @@ -47,11 +58,11 @@ export class GroupsService { .select() .from(schema.groups) .where(eq(schema.groups.id, id)); - + if (!group) { throw new NotFoundException(`Group with ID ${id} not found`); } - + return group; } @@ -67,11 +78,17 @@ export class GroupsService { }) .where(eq(schema.groups.id, id)) .returning(); - + if (!group) { throw new NotFoundException(`Group with ID ${id} not found`); } - + + // Emit group updated event + this.websocketsService.emitGroupUpdated(group.projectId, { + action: 'updated', + group, + }); + return group; } @@ -83,11 +100,17 @@ export class GroupsService { .delete(schema.groups) .where(eq(schema.groups.id, id)) .returning(); - + if (!group) { throw new NotFoundException(`Group with ID ${id} not found`); } - + + // Emit group deleted event + this.websocketsService.emitGroupUpdated(group.projectId, { + action: 'deleted', + group, + }); + return group; } @@ -96,29 +119,29 @@ export class GroupsService { */ async addPersonToGroup(groupId: string, personId: string) { // Check if the group exists - await this.findById(groupId); - + const group = await this.findById(groupId); + // Check if the person exists const [person] = await this.db .select() .from(schema.persons) .where(eq(schema.persons.id, personId)); - + if (!person) { throw new NotFoundException(`Person with ID ${personId} not found`); } - + // Check if the person is already in the group const [existingRelation] = await this.db .select() .from(schema.personToGroup) .where(eq(schema.personToGroup.personId, personId)) .where(eq(schema.personToGroup.groupId, groupId)); - + if (existingRelation) { return existingRelation; } - + // Add the person to the group const [relation] = await this.db .insert(schema.personToGroup) @@ -127,7 +150,14 @@ export class GroupsService { groupId, }) .returning(); - + + // Emit person added to group event + this.websocketsService.emitPersonAddedToGroup(group.projectId, { + group, + person, + relation, + }); + return relation; } @@ -135,16 +165,35 @@ export class GroupsService { * Remove a person from a group */ async removePersonFromGroup(groupId: string, personId: string) { + // Get the group and person before deleting the relation + const group = await this.findById(groupId); + + const [person] = await this.db + .select() + .from(schema.persons) + .where(eq(schema.persons.id, personId)); + + if (!person) { + throw new NotFoundException(`Person with ID ${personId} not found`); + } + const [relation] = await this.db .delete(schema.personToGroup) .where(eq(schema.personToGroup.personId, personId)) .where(eq(schema.personToGroup.groupId, groupId)) .returning(); - + if (!relation) { throw new NotFoundException(`Person with ID ${personId} is not in group with ID ${groupId}`); } - + + // Emit person removed from group event + this.websocketsService.emitPersonRemovedFromGroup(group.projectId, { + group, + person, + relation, + }); + return relation; } @@ -154,7 +203,7 @@ export class GroupsService { async getPersonsInGroup(groupId: string) { // Check if the group exists await this.findById(groupId); - + // Get all persons in the group return this.db .select({ @@ -164,4 +213,4 @@ export class GroupsService { .innerJoin(schema.persons, eq(schema.personToGroup.personId, schema.persons.id)) .where(eq(schema.personToGroup.groupId, groupId)); } -} \ No newline at end of file +} diff --git a/backend/src/modules/projects/services/projects.service.ts b/backend/src/modules/projects/services/projects.service.ts index 0b0f3e2..2c33a2b 100644 --- a/backend/src/modules/projects/services/projects.service.ts +++ b/backend/src/modules/projects/services/projects.service.ts @@ -4,10 +4,14 @@ import { DRIZZLE } from '../../../database/database.module'; import * as schema from '../../../database/schema'; import { CreateProjectDto } from '../dto/create-project.dto'; import { UpdateProjectDto } from '../dto/update-project.dto'; +import { WebSocketsService } from '../../websockets/websockets.service'; @Injectable() export class ProjectsService { - constructor(@Inject(DRIZZLE) private readonly db: any) {} + constructor( + @Inject(DRIZZLE) private readonly db: any, + private readonly websocketsService: WebSocketsService, + ) {} /** * Create a new project @@ -17,6 +21,13 @@ export class ProjectsService { .insert(schema.projects) .values(createProjectDto) .returning(); + + // Emit project created event + this.websocketsService.emitProjectUpdated(project.id, { + action: 'created', + project, + }); + return project; } @@ -70,6 +81,12 @@ export class ProjectsService { throw new NotFoundException(`Project with ID ${id} not found`); } + // Emit project updated event + this.websocketsService.emitProjectUpdated(project.id, { + action: 'updated', + project, + }); + return project; } @@ -86,6 +103,12 @@ export class ProjectsService { throw new NotFoundException(`Project with ID ${id} not found`); } + // Emit project deleted event + this.websocketsService.emitProjectUpdated(project.id, { + action: 'deleted', + project, + }); + return project; } @@ -127,7 +150,7 @@ export class ProjectsService { */ async addCollaborator(projectId: string, userId: string) { // Check if the project exists - await this.findById(projectId); + const project = await this.findById(projectId); // Check if the user exists const [user] = await this.db @@ -163,6 +186,21 @@ export class ProjectsService { }) .returning(); + // Emit collaborator added event + this.websocketsService.emitCollaboratorAdded(projectId, { + project, + user, + collaboration, + }); + + // Emit notification to the user + this.websocketsService.emitNotification(userId, { + type: 'project_invitation', + message: `You have been added as a collaborator to the project "${project.name}"`, + projectId, + projectName: project.name, + }); + return collaboration; } diff --git a/backend/src/modules/websockets/websockets.gateway.ts b/backend/src/modules/websockets/websockets.gateway.ts new file mode 100644 index 0000000..01cdcf2 --- /dev/null +++ b/backend/src/modules/websockets/websockets.gateway.ts @@ -0,0 +1,152 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + OnGatewayConnection, + OnGatewayDisconnect, + OnGatewayInit, +} from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { Server, Socket } from 'socket.io'; + +/** + * WebSocketsGateway + * + * This gateway handles all WebSocket connections and events. + * It implements the events specified in the specifications: + * - project:updated + * - project:collaboratorAdded + * - group:created + * - group:updated + * - group:personAdded + * - group:personRemoved + * - notification:new + */ +@WebSocketGateway({ + cors: { + origin: process.env.FRONTEND_URL || 'http://localhost:3001', + credentials: true, + }, +}) +export class WebSocketsGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { + + @WebSocketServer() server: Server; + + private logger = new Logger('WebSocketsGateway'); + private connectedClients = new Map(); // socketId -> userId + + /** + * After gateway initialization + */ + afterInit(server: Server) { + this.logger.log('WebSocket Gateway initialized'); + } + + /** + * Handle new client connections + */ + handleConnection(client: Socket, ...args: any[]) { + const userId = client.handshake.query.userId as string; + + if (userId) { + this.connectedClients.set(client.id, userId); + client.join(`user:${userId}`); + this.logger.log(`Client connected: ${client.id}, User ID: ${userId}`); + } else { + this.logger.warn(`Client connected without user ID: ${client.id}`); + } + } + + /** + * Handle client disconnections + */ + handleDisconnect(client: Socket) { + this.connectedClients.delete(client.id); + this.logger.log(`Client disconnected: ${client.id}`); + } + + /** + * Join a project room to receive project-specific events + */ + @SubscribeMessage('project:join') + handleJoinProject(client: Socket, projectId: string) { + client.join(`project:${projectId}`); + this.logger.log(`Client ${client.id} joined project room: ${projectId}`); + return { success: true }; + } + + /** + * Leave a project room + */ + @SubscribeMessage('project:leave') + handleLeaveProject(client: Socket, projectId: string) { + client.leave(`project:${projectId}`); + this.logger.log(`Client ${client.id} left project room: ${projectId}`); + return { success: true }; + } + + /** + * Emit project updated event + */ + emitProjectUpdated(projectId: string, data: any) { + this.server.to(`project:${projectId}`).emit('project:updated', data); + this.logger.log(`Emitted project:updated for project ${projectId}`); + } + + /** + * Emit collaborator added event + */ + emitCollaboratorAdded(projectId: string, data: any) { + this.server.to(`project:${projectId}`).emit('project:collaboratorAdded', data); + this.logger.log(`Emitted project:collaboratorAdded for project ${projectId}`); + } + + /** + * Emit group created event + */ + emitGroupCreated(projectId: string, data: any) { + this.server.to(`project:${projectId}`).emit('group:created', data); + this.logger.log(`Emitted group:created for project ${projectId}`); + } + + /** + * Emit group updated event + */ + emitGroupUpdated(projectId: string, data: any) { + this.server.to(`project:${projectId}`).emit('group:updated', data); + this.logger.log(`Emitted group:updated for project ${projectId}`); + } + + /** + * Emit person added to group event + */ + emitPersonAddedToGroup(projectId: string, data: any) { + this.server.to(`project:${projectId}`).emit('group:personAdded', data); + this.logger.log(`Emitted group:personAdded for project ${projectId}`); + } + + /** + * Emit person removed from group event + */ + emitPersonRemovedFromGroup(projectId: string, data: any) { + this.server.to(`project:${projectId}`).emit('group:personRemoved', data); + this.logger.log(`Emitted group:personRemoved for project ${projectId}`); + } + + /** + * Emit notification to a specific user + */ + emitNotification(userId: string, data: any) { + this.server.to(`user:${userId}`).emit('notification:new', data); + this.logger.log(`Emitted notification:new for user ${userId}`); + } + + /** + * Emit notification to all users in a project + */ + emitProjectNotification(projectId: string, data: any) { + this.server.to(`project:${projectId}`).emit('notification:new', data); + this.logger.log(`Emitted notification:new for project ${projectId}`); + } +} \ No newline at end of file diff --git a/backend/src/modules/websockets/websockets.module.ts b/backend/src/modules/websockets/websockets.module.ts new file mode 100644 index 0000000..807da1c --- /dev/null +++ b/backend/src/modules/websockets/websockets.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { WebSocketsGateway } from './websockets.gateway'; +import { WebSocketsService } from './websockets.service'; + +/** + * WebSocketsModule + * + * This module provides real-time communication capabilities using Socket.IO. + * It exports the WebSocketsService which can be used by other modules to emit events. + */ +@Module({ + providers: [WebSocketsGateway, WebSocketsService], + exports: [WebSocketsService], +}) +export class WebSocketsModule {} \ No newline at end of file diff --git a/backend/src/modules/websockets/websockets.service.ts b/backend/src/modules/websockets/websockets.service.ts new file mode 100644 index 0000000..bea747f --- /dev/null +++ b/backend/src/modules/websockets/websockets.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; +import { WebSocketsGateway } from './websockets.gateway'; + +/** + * WebSocketsService + * + * This service provides methods for other services to emit WebSocket events. + * It acts as a facade for the WebSocketsGateway. + */ +@Injectable() +export class WebSocketsService { + constructor(private readonly websocketsGateway: WebSocketsGateway) {} + + /** + * Emit project updated event + */ + emitProjectUpdated(projectId: string, data: any) { + this.websocketsGateway.emitProjectUpdated(projectId, data); + } + + /** + * Emit collaborator added event + */ + emitCollaboratorAdded(projectId: string, data: any) { + this.websocketsGateway.emitCollaboratorAdded(projectId, data); + } + + /** + * Emit group created event + */ + emitGroupCreated(projectId: string, data: any) { + this.websocketsGateway.emitGroupCreated(projectId, data); + } + + /** + * Emit group updated event + */ + emitGroupUpdated(projectId: string, data: any) { + this.websocketsGateway.emitGroupUpdated(projectId, data); + } + + /** + * Emit person added to group event + */ + emitPersonAddedToGroup(projectId: string, data: any) { + this.websocketsGateway.emitPersonAddedToGroup(projectId, data); + } + + /** + * Emit person removed from group event + */ + emitPersonRemovedFromGroup(projectId: string, data: any) { + this.websocketsGateway.emitPersonRemovedFromGroup(projectId, data); + } + + /** + * Emit notification to a specific user + */ + emitNotification(userId: string, data: any) { + this.websocketsGateway.emitNotification(userId, data); + } + + /** + * Emit notification to all users in a project + */ + emitProjectNotification(projectId: string, data: any) { + this.websocketsGateway.emitProjectNotification(projectId, data); + } +} \ No newline at end of file