From fafdaee668f4400bfd9ade68efc6f407cce9914e Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:34:22 +0100 Subject: [PATCH] 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. --- backend/src/database/schemas/messages.ts | 66 +++++ .../src/messages/dto/create-message.dto.ts | 11 + backend/src/messages/messages.controller.ts | 40 +++ backend/src/messages/messages.module.ts | 14 + backend/src/messages/messages.service.ts | 60 ++++ .../repositories/messages.repository.ts | 141 +++++++++ backend/src/realtime/events.gateway.ts | 82 ++++++ backend/src/realtime/realtime.module.ts | 10 + .../src/app/(dashboard)/messages/page.tsx | 277 ++++++++++++++++++ frontend/src/components/comment-section.tsx | 162 ++++++++++ frontend/src/providers/socket-provider.tsx | 56 ++++ frontend/src/services/comment.service.ts | 32 ++ frontend/src/services/message.service.ts | 44 +++ 13 files changed, 995 insertions(+) create mode 100644 backend/src/database/schemas/messages.ts create mode 100644 backend/src/messages/dto/create-message.dto.ts create mode 100644 backend/src/messages/messages.controller.ts create mode 100644 backend/src/messages/messages.module.ts create mode 100644 backend/src/messages/messages.service.ts create mode 100644 backend/src/messages/repositories/messages.repository.ts create mode 100644 backend/src/realtime/events.gateway.ts create mode 100644 backend/src/realtime/realtime.module.ts create mode 100644 frontend/src/app/(dashboard)/messages/page.tsx create mode 100644 frontend/src/components/comment-section.tsx create mode 100644 frontend/src/providers/socket-provider.tsx create mode 100644 frontend/src/services/comment.service.ts create mode 100644 frontend/src/services/message.service.ts diff --git a/backend/src/database/schemas/messages.ts b/backend/src/database/schemas/messages.ts new file mode 100644 index 0000000..95429db --- /dev/null +++ b/backend/src/database/schemas/messages.ts @@ -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; diff --git a/backend/src/messages/dto/create-message.dto.ts b/backend/src/messages/dto/create-message.dto.ts new file mode 100644 index 0000000..2262a80 --- /dev/null +++ b/backend/src/messages/dto/create-message.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString, IsUUID, MaxLength } from "class-validator"; + +export class CreateMessageDto { + @IsUUID() + recipientId!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(2000) + text!: string; +} diff --git a/backend/src/messages/messages.controller.ts b/backend/src/messages/messages.controller.ts new file mode 100644 index 0000000..0283e5e --- /dev/null +++ b/backend/src/messages/messages.controller.ts @@ -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); + } +} diff --git a/backend/src/messages/messages.module.ts b/backend/src/messages/messages.module.ts new file mode 100644 index 0000000..85cee07 --- /dev/null +++ b/backend/src/messages/messages.module.ts @@ -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 {} diff --git a/backend/src/messages/messages.service.ts b/backend/src/messages/messages.service.ts new file mode 100644 index 0000000..5608044 --- /dev/null +++ b/backend/src/messages/messages.service.ts @@ -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); + } +} diff --git a/backend/src/messages/repositories/messages.repository.ts b/backend/src/messages/repositories/messages.repository.ts new file mode 100644 index 0000000..689f167 --- /dev/null +++ b/backend/src/messages/repositories/messages.repository.ts @@ -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)); + } +} diff --git a/backend/src/realtime/events.gateway.ts b/backend/src/realtime/events.gateway.ts new file mode 100644 index 0000000..89d8b84 --- /dev/null +++ b/backend/src/realtime/events.gateway.ts @@ -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( + 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); + } +} diff --git a/backend/src/realtime/realtime.module.ts b/backend/src/realtime/realtime.module.ts new file mode 100644 index 0000000..5dd7d9b --- /dev/null +++ b/backend/src/realtime/realtime.module.ts @@ -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 {} diff --git a/frontend/src/app/(dashboard)/messages/page.tsx b/frontend/src/app/(dashboard)/messages/page.tsx new file mode 100644 index 0000000..aad4fa4 --- /dev/null +++ b/frontend/src/app/(dashboard)/messages/page.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { formatDistanceToNow } from "date-fns"; +import { fr } from "date-fns/locale"; +import { Search, Send } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useAuth } from "@/providers/auth-provider"; +import { useSocket } from "@/providers/socket-provider"; +import { + MessageService, + type Conversation, + type Message, +} from "@/services/message.service"; + +export default function MessagesPage() { + const { user } = useAuth(); + const { socket } = useSocket(); + const [conversations, setConversations] = React.useState([]); + const [activeConv, setActiveConv] = React.useState(null); + const [messages, setMessages] = React.useState([]); + const [newMessage, setNewMessage] = React.useState(""); + const [isLoadingConvs, setIsLoadingConvs] = React.useState(true); + const [isLoadingMsgs, setIsLoadingMsgs] = React.useState(false); + const scrollRef = React.useRef(null); + + React.useEffect(() => { + const fetchConvs = async () => { + try { + const data = await MessageService.getConversations(); + setConversations(data); + } catch (_error) { + toast.error("Erreur lors du chargement des conversations"); + } finally { + setIsLoadingConvs(false); + } + }; + fetchConvs(); + }, []); + + React.useEffect(() => { + if (activeConv) { + const fetchMsgs = async () => { + setIsLoadingMsgs(true); + try { + const data = await MessageService.getMessages(activeConv.id); + setMessages(data.reverse()); // Plus ancien au plus récent + } catch (_error) { + toast.error("Erreur lors du chargement des messages"); + } finally { + setIsLoadingMsgs(false); + } + }; + fetchMsgs(); + } + }, [activeConv]); + + React.useEffect(() => { + if (socket) { + socket.on("new_message", (data: { conversationId: string; message: Message }) => { + if (activeConv?.id === data.conversationId) { + setMessages((prev) => [...prev, data.message]); + } + // Mettre à jour la liste des conversations + setConversations((prev) => { + const index = prev.findIndex((c) => c.id === data.conversationId); + if (index !== -1) { + const updated = [...prev]; + updated[index] = { + ...updated[index], + lastMessage: { + text: data.message.text, + createdAt: data.message.createdAt, + }, + updatedAt: data.message.createdAt, + }; + return updated.sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + } + return prev; + }); + }); + + return () => { + socket.off("new_message"); + }; + } + }, [socket, activeConv]); + + React.useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages]); + + const handleSendMessage = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newMessage.trim() || !activeConv) return; + + const text = newMessage.trim(); + setNewMessage(""); + + try { + const msg = await MessageService.sendMessage(activeConv.recipient.uuid, text); + setMessages((prev) => [...prev, msg]); + } catch (_error) { + toast.error("Erreur lors de l'envoi"); + } + }; + + return ( +
+ {/* Sidebar - Liste des conversations */} +
+
+

Messages

+
+ + +
+
+ +
+ {isLoadingConvs ? ( +
+ Chargement... +
+ ) : conversations.length === 0 ? ( +
+ Aucune conversation. +
+ ) : ( + conversations.map((conv) => ( + + )) + )} +
+
+
+ + {/* Zone de chat */} +
+ {activeConv ? ( + <> + {/* Header */} +
+ + + + {activeConv.recipient.username[0].toUpperCase()} + + +
+

+ {activeConv.recipient.displayName || activeConv.recipient.username} +

+ En ligne +
+
+ + {/* Messages */} + +
+ {isLoadingMsgs ? ( +
+ Chargement... +
+ ) : ( + messages.map((msg) => ( +
+
+

{msg.text}

+

+ {new Date(msg.createdAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +

+
+
+ )) + )} +
+
+ + {/* Input */} +
+
+ setNewMessage(e.target.value)} + className="rounded-full px-4" + /> + +
+
+ + ) : ( +
+
+ +
+

Vos messages

+

+ Sélectionnez une conversation ou démarrez-en une nouvelle pour commencer à + discuter. +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/comment-section.tsx b/frontend/src/components/comment-section.tsx new file mode 100644 index 0000000..82a2475 --- /dev/null +++ b/frontend/src/components/comment-section.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { formatDistanceToNow } from "date-fns"; +import { fr } from "date-fns/locale"; +import { MoreHorizontal, Send, Trash2 } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Textarea } from "@/components/ui/textarea"; +import { useAuth } from "@/providers/auth-provider"; +import { CommentService, type Comment } from "@/services/comment.service"; + +interface CommentSectionProps { + contentId: string; +} + +export function CommentSection({ contentId }: CommentSectionProps) { + const { user, isAuthenticated } = useAuth(); + const [comments, setComments] = React.useState([]); + const [newComment, setNewComment] = React.useState(""); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(true); + + const fetchComments = React.useCallback(async () => { + try { + const data = await CommentService.getByContentId(contentId); + setComments(data); + } catch (_error) { + toast.error("Impossible de charger les commentaires"); + } finally { + setIsLoading(false); + } + }, [contentId]); + + React.useEffect(() => { + fetchComments(); + }, [fetchComments]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newComment.trim() || isSubmitting) return; + + setIsSubmitting(true); + try { + const comment = await CommentService.create(contentId, newComment.trim()); + setComments((prev) => [comment, ...prev]); + setNewComment(""); + toast.success("Commentaire publié !"); + } catch (_error) { + toast.error("Erreur lors de la publication du commentaire"); + } finally { + setIsSubmitting(false); + } + }; + + const handleDelete = async (commentId: string) => { + try { + await CommentService.remove(commentId); + setComments((prev) => prev.filter((c) => c.id !== commentId)); + toast.success("Commentaire supprimé"); + } catch (_error) { + toast.error("Erreur lors de la suppression"); + } + }; + + return ( +
+

Commentaires ({comments.length})

+ + {isAuthenticated ? ( +
+ + + {user?.username[0].toUpperCase()} + +
+