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).
This commit is contained in:
Mathis H (Avnyr) 2025-05-16 16:42:15 +02:00
parent ad6ef4c907
commit 2697c7ebdd
6 changed files with 345 additions and 20 deletions

View File

@ -9,6 +9,7 @@ import { ProjectsModule } from './modules/projects/projects.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { GroupsModule } from './modules/groups/groups.module'; import { GroupsModule } from './modules/groups/groups.module';
import { TagsModule } from './modules/tags/tags.module'; import { TagsModule } from './modules/tags/tags.module';
import { WebSocketsModule } from './modules/websockets/websockets.module';
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
@Module({ @Module({
@ -23,6 +24,7 @@ import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
AuthModule, AuthModule,
GroupsModule, GroupsModule,
TagsModule, TagsModule,
WebSocketsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

@ -4,10 +4,14 @@ import { DRIZZLE } from '../../../database/database.module';
import * as schema from '../../../database/schema'; import * as schema from '../../../database/schema';
import { CreateGroupDto } from '../dto/create-group.dto'; import { CreateGroupDto } from '../dto/create-group.dto';
import { UpdateGroupDto } from '../dto/update-group.dto'; import { UpdateGroupDto } from '../dto/update-group.dto';
import { WebSocketsService } from '../../websockets/websockets.service';
@Injectable() @Injectable()
export class GroupsService { 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 * Create a new group
@ -19,6 +23,13 @@ export class GroupsService {
...createGroupDto, ...createGroupDto,
}) })
.returning(); .returning();
// Emit group created event
this.websocketsService.emitGroupCreated(group.projectId, {
action: 'created',
group,
});
return group; return group;
} }
@ -47,11 +58,11 @@ export class GroupsService {
.select() .select()
.from(schema.groups) .from(schema.groups)
.where(eq(schema.groups.id, id)); .where(eq(schema.groups.id, id));
if (!group) { if (!group) {
throw new NotFoundException(`Group with ID ${id} not found`); throw new NotFoundException(`Group with ID ${id} not found`);
} }
return group; return group;
} }
@ -67,11 +78,17 @@ export class GroupsService {
}) })
.where(eq(schema.groups.id, id)) .where(eq(schema.groups.id, id))
.returning(); .returning();
if (!group) { if (!group) {
throw new NotFoundException(`Group with ID ${id} not found`); throw new NotFoundException(`Group with ID ${id} not found`);
} }
// Emit group updated event
this.websocketsService.emitGroupUpdated(group.projectId, {
action: 'updated',
group,
});
return group; return group;
} }
@ -83,11 +100,17 @@ export class GroupsService {
.delete(schema.groups) .delete(schema.groups)
.where(eq(schema.groups.id, id)) .where(eq(schema.groups.id, id))
.returning(); .returning();
if (!group) { if (!group) {
throw new NotFoundException(`Group with ID ${id} not found`); throw new NotFoundException(`Group with ID ${id} not found`);
} }
// Emit group deleted event
this.websocketsService.emitGroupUpdated(group.projectId, {
action: 'deleted',
group,
});
return group; return group;
} }
@ -96,29 +119,29 @@ export class GroupsService {
*/ */
async addPersonToGroup(groupId: string, personId: string) { async addPersonToGroup(groupId: string, personId: string) {
// Check if the group exists // Check if the group exists
await this.findById(groupId); const group = await this.findById(groupId);
// Check if the person exists // Check if the person exists
const [person] = await this.db const [person] = await this.db
.select() .select()
.from(schema.persons) .from(schema.persons)
.where(eq(schema.persons.id, personId)); .where(eq(schema.persons.id, personId));
if (!person) { if (!person) {
throw new NotFoundException(`Person with ID ${personId} not found`); throw new NotFoundException(`Person with ID ${personId} not found`);
} }
// Check if the person is already in the group // Check if the person is already in the group
const [existingRelation] = await this.db const [existingRelation] = await this.db
.select() .select()
.from(schema.personToGroup) .from(schema.personToGroup)
.where(eq(schema.personToGroup.personId, personId)) .where(eq(schema.personToGroup.personId, personId))
.where(eq(schema.personToGroup.groupId, groupId)); .where(eq(schema.personToGroup.groupId, groupId));
if (existingRelation) { if (existingRelation) {
return existingRelation; return existingRelation;
} }
// Add the person to the group // Add the person to the group
const [relation] = await this.db const [relation] = await this.db
.insert(schema.personToGroup) .insert(schema.personToGroup)
@ -127,7 +150,14 @@ export class GroupsService {
groupId, groupId,
}) })
.returning(); .returning();
// Emit person added to group event
this.websocketsService.emitPersonAddedToGroup(group.projectId, {
group,
person,
relation,
});
return relation; return relation;
} }
@ -135,16 +165,35 @@ export class GroupsService {
* Remove a person from a group * Remove a person from a group
*/ */
async removePersonFromGroup(groupId: string, personId: string) { 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 const [relation] = await this.db
.delete(schema.personToGroup) .delete(schema.personToGroup)
.where(eq(schema.personToGroup.personId, personId)) .where(eq(schema.personToGroup.personId, personId))
.where(eq(schema.personToGroup.groupId, groupId)) .where(eq(schema.personToGroup.groupId, groupId))
.returning(); .returning();
if (!relation) { if (!relation) {
throw new NotFoundException(`Person with ID ${personId} is not in group with ID ${groupId}`); 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; return relation;
} }
@ -154,7 +203,7 @@ export class GroupsService {
async getPersonsInGroup(groupId: string) { async getPersonsInGroup(groupId: string) {
// Check if the group exists // Check if the group exists
await this.findById(groupId); await this.findById(groupId);
// Get all persons in the group // Get all persons in the group
return this.db return this.db
.select({ .select({
@ -164,4 +213,4 @@ export class GroupsService {
.innerJoin(schema.persons, eq(schema.personToGroup.personId, schema.persons.id)) .innerJoin(schema.persons, eq(schema.personToGroup.personId, schema.persons.id))
.where(eq(schema.personToGroup.groupId, groupId)); .where(eq(schema.personToGroup.groupId, groupId));
} }
} }

View File

@ -4,10 +4,14 @@ import { DRIZZLE } from '../../../database/database.module';
import * as schema from '../../../database/schema'; import * as schema from '../../../database/schema';
import { CreateProjectDto } from '../dto/create-project.dto'; import { CreateProjectDto } from '../dto/create-project.dto';
import { UpdateProjectDto } from '../dto/update-project.dto'; import { UpdateProjectDto } from '../dto/update-project.dto';
import { WebSocketsService } from '../../websockets/websockets.service';
@Injectable() @Injectable()
export class ProjectsService { 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 * Create a new project
@ -17,6 +21,13 @@ export class ProjectsService {
.insert(schema.projects) .insert(schema.projects)
.values(createProjectDto) .values(createProjectDto)
.returning(); .returning();
// Emit project created event
this.websocketsService.emitProjectUpdated(project.id, {
action: 'created',
project,
});
return project; return project;
} }
@ -70,6 +81,12 @@ export class ProjectsService {
throw new NotFoundException(`Project with ID ${id} not found`); throw new NotFoundException(`Project with ID ${id} not found`);
} }
// Emit project updated event
this.websocketsService.emitProjectUpdated(project.id, {
action: 'updated',
project,
});
return project; return project;
} }
@ -86,6 +103,12 @@ export class ProjectsService {
throw new NotFoundException(`Project with ID ${id} not found`); throw new NotFoundException(`Project with ID ${id} not found`);
} }
// Emit project deleted event
this.websocketsService.emitProjectUpdated(project.id, {
action: 'deleted',
project,
});
return project; return project;
} }
@ -127,7 +150,7 @@ export class ProjectsService {
*/ */
async addCollaborator(projectId: string, userId: string) { async addCollaborator(projectId: string, userId: string) {
// Check if the project exists // Check if the project exists
await this.findById(projectId); const project = await this.findById(projectId);
// Check if the user exists // Check if the user exists
const [user] = await this.db const [user] = await this.db
@ -163,6 +186,21 @@ export class ProjectsService {
}) })
.returning(); .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; return collaboration;
} }

View File

@ -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<string, string>(); // 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}`);
}
}

View File

@ -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 {}

View File

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