feat: implement messaging functionality with real-time updates

- Introduced a messaging module on the backend using NestJS, including repository, service, controller, DTOs, and WebSocket Gateway.
- Developed a frontend messaging page with conversation management, real-time message handling, and chat UI.
- Implemented `MessageService` for API integrations and `SocketProvider` for real-time WebSocket updates.
- Enhanced database schema to support conversations, participants, and messages with Drizzle ORM.
This commit is contained in:
Mathis HERRIOT
2026-01-29 14:34:22 +01:00
parent 01117aad6d
commit fafdaee668
13 changed files with 995 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
import {
index,
pgTable,
primaryKey,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { users } from "./users";
export const conversations = pgTable("conversations", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export const conversationParticipants = pgTable(
"conversation_participants",
{
conversationId: uuid("conversation_id")
.notNull()
.references(() => conversations.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.uuid, { onDelete: "cascade" }),
joinedAt: timestamp("joined_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.conversationId, t.userId] }),
}),
);
export const messages = pgTable(
"messages",
{
id: uuid("id").primaryKey().defaultRandom(),
conversationId: uuid("conversation_id")
.notNull()
.references(() => conversations.id, { onDelete: "cascade" }),
senderId: uuid("sender_id")
.notNull()
.references(() => users.uuid, { onDelete: "cascade" }),
text: text("text").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
readAt: timestamp("read_at", { withTimezone: true }),
},
(table) => ({
conversationIdIdx: index("messages_conversation_id_idx").on(
table.conversationId,
),
senderIdIdx: index("messages_sender_id_idx").on(table.senderId),
}),
);
export type ConversationInDb = typeof conversations.$inferSelect;
export type NewConversationInDb = typeof conversations.$inferInsert;
export type MessageInDb = typeof messages.$inferSelect;
export type NewMessageInDb = typeof messages.$inferInsert;

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString, IsUUID, MaxLength } from "class-validator";
export class CreateMessageDto {
@IsUUID()
recipientId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(2000)
text!: string;
}

View File

@@ -0,0 +1,40 @@
import {
Body,
Controller,
Get,
Param,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import { AuthGuard } from "../auth/guards/auth.guard";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { CreateMessageDto } from "./dto/create-message.dto";
import { MessagesService } from "./messages.service";
@Controller("messages")
@UseGuards(AuthGuard)
export class MessagesController {
constructor(private readonly messagesService: MessagesService) {}
@Get("conversations")
getConversations(@Req() req: AuthenticatedRequest) {
return this.messagesService.getConversations(req.user.sub);
}
@Get("conversations/:id")
getMessages(
@Req() req: AuthenticatedRequest,
@Param("id") conversationId: string,
) {
return this.messagesService.getMessages(req.user.sub, conversationId);
}
@Post()
sendMessage(
@Req() req: AuthenticatedRequest,
@Body() dto: CreateMessageDto,
) {
return this.messagesService.sendMessage(req.user.sub, dto);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { MessagesController } from "./messages.controller";
import { MessagesService } from "./messages.service";
import { MessagesRepository } from "./repositories/messages.repository";
@Module({
imports: [AuthModule, RealtimeModule],
controllers: [MessagesController],
providers: [MessagesService, MessagesRepository],
exports: [MessagesService],
})
export class MessagesModule {}

View File

@@ -0,0 +1,60 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { EventsGateway } from "../realtime/events.gateway";
import type { CreateMessageDto } from "./dto/create-message.dto";
import { MessagesRepository } from "./repositories/messages.repository";
@Injectable()
export class MessagesService {
constructor(
private readonly messagesRepository: MessagesRepository,
private readonly eventsGateway: EventsGateway,
) {}
async sendMessage(senderId: string, dto: CreateMessageDto) {
let conversation = await this.messagesRepository.findConversationBetweenUsers(
senderId,
dto.recipientId,
);
if (!conversation) {
const newConv = await this.messagesRepository.createConversation();
await this.messagesRepository.addParticipant(newConv.id, senderId);
await this.messagesRepository.addParticipant(newConv.id, dto.recipientId);
conversation = newConv;
}
const message = await this.messagesRepository.createMessage({
conversationId: conversation.id,
senderId,
text: dto.text,
});
// Notify recipient via WebSocket
this.eventsGateway.sendToUser(dto.recipientId, "new_message", {
conversationId: conversation.id,
message,
});
return message;
}
async getConversations(userId: string) {
return this.messagesRepository.findAllConversations(userId);
}
async getMessages(userId: string, conversationId: string) {
const isParticipant = await this.messagesRepository.isParticipant(
conversationId,
userId,
);
if (!isParticipant) {
throw new ForbiddenException("You are not part of this conversation");
}
return this.messagesRepository.findMessagesByConversationId(conversationId);
}
}

View File

@@ -0,0 +1,141 @@
import { Injectable } from "@nestjs/common";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import {
conversationParticipants,
conversations,
messages,
users,
} from "../../database/schemas";
@Injectable()
export class MessagesRepository {
constructor(private readonly databaseService: DatabaseService) {}
async findConversationBetweenUsers(userId1: string, userId2: string) {
const results = await this.databaseService.db
.select({ id: conversations.id })
.from(conversations)
.innerJoin(
conversationParticipants,
eq(conversations.id, conversationParticipants.conversationId),
)
.where(
inArray(conversationParticipants.userId, [userId1, userId2])
)
.groupBy(conversations.id)
.having(sql`count(${conversations.id}) = 2`);
return results[0];
}
async createConversation() {
const [conv] = await this.databaseService.db
.insert(conversations)
.values({})
.returning();
return conv;
}
async addParticipant(conversationId: string, userId: string) {
await this.databaseService.db
.insert(conversationParticipants)
.values({ conversationId, userId });
}
async createMessage(data: {
conversationId: string;
senderId: string;
text: string;
}) {
const [msg] = await this.databaseService.db
.insert(messages)
.values(data)
.returning();
// Update conversation updatedAt
await this.databaseService.db
.update(conversations)
.set({ updatedAt: new Date() })
.where(eq(conversations.id, data.conversationId));
return msg;
}
async findAllConversations(userId: string) {
// Sous-requête pour trouver les IDs des conversations de l'utilisateur
const userConvs = this.databaseService.db
.select({ id: conversationParticipants.conversationId })
.from(conversationParticipants)
.where(eq(conversationParticipants.userId, userId));
return this.databaseService.db
.select({
id: conversations.id,
updatedAt: conversations.updatedAt,
lastMessage: {
text: messages.text,
createdAt: messages.createdAt,
},
recipient: {
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
})
.from(conversations)
.innerJoin(
conversationParticipants,
eq(conversations.id, conversationParticipants.conversationId),
)
.innerJoin(users, eq(conversationParticipants.userId, users.uuid))
.leftJoin(
messages,
eq(conversations.id, messages.conversationId)
)
.where(
and(
inArray(conversations.id, userConvs),
eq(conversationParticipants.userId, users.uuid),
sql`${users.uuid} != ${userId}`
)
)
.orderBy(desc(conversations.updatedAt));
}
async findMessagesByConversationId(conversationId: string, limit = 50) {
return this.databaseService.db
.select({
id: messages.id,
text: messages.text,
createdAt: messages.createdAt,
senderId: messages.senderId,
readAt: messages.readAt,
})
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(desc(messages.createdAt))
.limit(limit);
}
async isParticipant(conversationId: string, userId: string) {
const [participant] = await this.databaseService.db
.select()
.from(conversationParticipants)
.where(
and(
eq(conversationParticipants.conversationId, conversationId),
eq(conversationParticipants.userId, userId),
),
);
return !!participant;
}
async getParticipants(conversationId: string) {
return this.databaseService.db
.select({ userId: conversationParticipants.userId })
.from(conversationParticipants)
.where(eq(conversationParticipants.conversationId, conversationId));
}
}

View File

@@ -0,0 +1,82 @@
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
WebSocketGateway,
WebSocketServer,
} from "@nestjs/websockets";
import { getIronSession } from "iron-session";
import { Server, Socket } from "socket.io";
import { getSessionOptions, SessionData } from "../auth/session.config";
import { JwtService } from "../crypto/services/jwt.service";
@WebSocketGateway({
cors: {
origin: "*",
credentials: true,
},
})
export class EventsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(EventsGateway.name);
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
afterInit(_server: Server) {
this.logger.log("WebSocket Gateway initialized");
}
async handleConnection(client: Socket) {
try {
// Simuler un objet Request/Response pour iron-session
const req: any = {
headers: client.handshake.headers,
};
const res: any = {
setHeader: () => {},
getHeader: () => {},
};
const session = await getIronSession<SessionData>(
req,
res,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
if (!session.accessToken) {
this.logger.warn(`Client ${client.id} unauthorized connection`);
client.disconnect();
return;
}
const payload = await this.jwtService.verifyJwt(session.accessToken);
client.data.user = payload;
// Rejoindre une room personnelle pour les notifications
client.join(`user:${payload.sub}`);
this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`);
} catch (error) {
this.logger.error(`Connection error for client ${client.id}: ${error}`);
client.disconnect();
}
}
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}
// Méthode utilitaire pour envoyer des messages à un utilisateur spécifique
sendToUser(userId: string, event: string, data: any) {
this.server.to(`user:${userId}`).emit(event, data);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { CryptoModule } from "../crypto/crypto.module";
import { EventsGateway } from "./events.gateway";
@Module({
imports: [CryptoModule],
providers: [EventsGateway],
exports: [EventsGateway],
})
export class RealtimeModule {}