From 0976850c0ca4dfb1ab4c347627a4c72b4dd2573e Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:26:54 +0100 Subject: [PATCH] feat: add comment replies and liking functionality - Introduced support for nested comment replies in both frontend and backend. - Added comment liking and unliking features, including like count and "isLiked" state tracking. - Updated database schema with `parentId` and new `comment_likes` table. - Enhanced UI for threaded comments and implemented display of like counts and reply actions. - Refactored APIs and repositories to support replies, likes, and enriched comment data. --- backend/src/comments/comments.controller.ts | 45 ++- backend/src/comments/comments.module.ts | 6 +- backend/src/comments/comments.service.ts | 61 +++- .../src/comments/dto/create-comment.dto.ts | 12 +- .../repositories/comment-likes.repository.ts | 42 +++ .../repositories/comments.repository.ts | 9 +- backend/src/database/schemas/comment_likes.ts | 21 ++ backend/src/database/schemas/comments.ts | 4 + backend/src/database/schemas/index.ts | 1 + frontend/src/components/comment-section.tsx | 278 +++++++++++++----- frontend/src/services/comment.service.ts | 18 +- 11 files changed, 405 insertions(+), 92 deletions(-) create mode 100644 backend/src/comments/repositories/comment-likes.repository.ts create mode 100644 backend/src/database/schemas/comment_likes.ts diff --git a/backend/src/comments/comments.controller.ts b/backend/src/comments/comments.controller.ts index 0082066..618cf0f 100644 --- a/backend/src/comments/comments.controller.ts +++ b/backend/src/comments/comments.controller.ts @@ -8,18 +8,45 @@ import { Req, UseGuards, } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { getIronSession } from "iron-session"; import { AuthGuard } from "../auth/guards/auth.guard"; +import { getSessionOptions } from "../auth/session.config"; import type { AuthenticatedRequest } from "../common/interfaces/request.interface"; +import { JwtService } from "../crypto/services/jwt.service"; import { CommentsService } from "./comments.service"; import { CreateCommentDto } from "./dto/create-comment.dto"; @Controller() export class CommentsController { - constructor(private readonly commentsService: CommentsService) {} + constructor( + private readonly commentsService: CommentsService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} @Get("contents/:contentId/comments") - findAllByContentId(@Param("contentId") contentId: string) { - return this.commentsService.findAllByContentId(contentId); + async findAllByContentId( + @Param("contentId") contentId: string, + @Req() req: any, + ) { + // Tentative de récupération de l'utilisateur pour isLiked (optionnel) + let userId: string | undefined; + try { + const session = await getIronSession( + req, + req.res, + getSessionOptions(this.configService.get("SESSION_PASSWORD") as string), + ); + if (session.accessToken) { + const payload = await this.jwtService.verifyJwt(session.accessToken); + userId = payload.sub; + } + } catch (_e) { + // Ignorer les erreurs de session + } + + return this.commentsService.findAllByContentId(contentId, userId); } @Post("contents/:contentId/comments") @@ -38,4 +65,16 @@ export class CommentsController { const isAdmin = req.user.role === "admin" || req.user.role === "moderator"; return this.commentsService.remove(req.user.sub, id, isAdmin); } + + @Post("comments/:id/like") + @UseGuards(AuthGuard) + like(@Req() req: AuthenticatedRequest, @Param("id") id: string) { + return this.commentsService.like(req.user.sub, id); + } + + @Delete("comments/:id/like") + @UseGuards(AuthGuard) + unlike(@Req() req: AuthenticatedRequest, @Param("id") id: string) { + return this.commentsService.unlike(req.user.sub, id); + } } diff --git a/backend/src/comments/comments.module.ts b/backend/src/comments/comments.module.ts index c6a2ab1..2913b98 100644 --- a/backend/src/comments/comments.module.ts +++ b/backend/src/comments/comments.module.ts @@ -1,13 +1,15 @@ import { Module } from "@nestjs/common"; import { AuthModule } from "../auth/auth.module"; +import { S3Module } from "../s3/s3.module"; import { CommentsController } from "./comments.controller"; import { CommentsService } from "./comments.service"; +import { CommentLikesRepository } from "./repositories/comment-likes.repository"; import { CommentsRepository } from "./repositories/comments.repository"; @Module({ - imports: [AuthModule], + imports: [AuthModule, S3Module], controllers: [CommentsController], - providers: [CommentsService, CommentsRepository], + providers: [CommentsService, CommentsRepository, CommentLikesRepository], exports: [CommentsService], }) export class CommentsModule {} diff --git a/backend/src/comments/comments.service.ts b/backend/src/comments/comments.service.ts index d86e3c1..e1ce823 100644 --- a/backend/src/comments/comments.service.ts +++ b/backend/src/comments/comments.service.ts @@ -3,23 +3,60 @@ import { Injectable, NotFoundException, } from "@nestjs/common"; +import { S3Service } from "../s3/s3.service"; import type { CreateCommentDto } from "./dto/create-comment.dto"; +import { CommentLikesRepository } from "./repositories/comment-likes.repository"; import { CommentsRepository } from "./repositories/comments.repository"; @Injectable() export class CommentsService { - constructor(private readonly commentsRepository: CommentsRepository) {} + constructor( + private readonly commentsRepository: CommentsRepository, + private readonly commentLikesRepository: CommentLikesRepository, + private readonly s3Service: S3Service, + ) {} async create(userId: string, contentId: string, dto: CreateCommentDto) { - return this.commentsRepository.create({ + const comment = await this.commentsRepository.create({ userId, contentId, text: dto.text, + parentId: dto.parentId, }); + + // Enrichir le commentaire créé (pour le retour API) + return { + ...comment, + likesCount: 0, + isLiked: false, + }; } - async findAllByContentId(contentId: string) { - return this.commentsRepository.findAllByContentId(contentId); + async findAllByContentId(contentId: string, userId?: string) { + const comments = await this.commentsRepository.findAllByContentId(contentId); + + return Promise.all( + comments.map(async (comment) => { + const [likesCount, isLiked] = await Promise.all([ + this.commentLikesRepository.countByCommentId(comment.id), + userId + ? this.commentLikesRepository.isLikedByUser(comment.id, userId) + : Promise.resolve(false), + ]); + + return { + ...comment, + likesCount, + isLiked, + user: { + ...comment.user, + avatarUrl: comment.user.avatarUrl + ? this.s3Service.getPublicUrl(comment.user.avatarUrl) + : null, + }, + }; + }), + ); } async remove(userId: string, commentId: string, isAdmin = false) { @@ -34,4 +71,20 @@ export class CommentsService { await this.commentsRepository.delete(commentId); } + + async like(userId: string, commentId: string) { + const comment = await this.commentsRepository.findOne(commentId); + if (!comment) { + throw new NotFoundException("Comment not found"); + } + await this.commentLikesRepository.addLike(commentId, userId); + } + + async unlike(userId: string, commentId: string) { + const comment = await this.commentsRepository.findOne(commentId); + if (!comment) { + throw new NotFoundException("Comment not found"); + } + await this.commentLikesRepository.removeLike(commentId, userId); + } } diff --git a/backend/src/comments/dto/create-comment.dto.ts b/backend/src/comments/dto/create-comment.dto.ts index e215dcc..7b45df6 100644 --- a/backend/src/comments/dto/create-comment.dto.ts +++ b/backend/src/comments/dto/create-comment.dto.ts @@ -1,8 +1,18 @@ -import { IsNotEmpty, IsString, MaxLength } from "class-validator"; +import { + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from "class-validator"; export class CreateCommentDto { @IsString() @IsNotEmpty() @MaxLength(1000) text!: string; + + @IsOptional() + @IsUUID() + parentId?: string; } diff --git a/backend/src/comments/repositories/comment-likes.repository.ts b/backend/src/comments/repositories/comment-likes.repository.ts new file mode 100644 index 0000000..35f85f7 --- /dev/null +++ b/backend/src/comments/repositories/comment-likes.repository.ts @@ -0,0 +1,42 @@ +import { Injectable } from "@nestjs/common"; +import { and, eq, sql } from "drizzle-orm"; +import { DatabaseService } from "../../database/database.service"; +import { commentLikes } from "../../database/schemas/comment_likes"; + +@Injectable() +export class CommentLikesRepository { + constructor(private readonly databaseService: DatabaseService) {} + + async addLike(commentId: string, userId: string) { + await this.databaseService.db + .insert(commentLikes) + .values({ commentId, userId }) + .onConflictDoNothing(); + } + + async removeLike(commentId: string, userId: string) { + await this.databaseService.db + .delete(commentLikes) + .where( + and(eq(commentLikes.commentId, commentId), eq(commentLikes.userId, userId)), + ); + } + + async countByCommentId(commentId: string) { + const results = await this.databaseService.db + .select({ count: sql`count(*)` }) + .from(commentLikes) + .where(eq(commentLikes.commentId, commentId)); + return Number(results[0]?.count || 0); + } + + async isLikedByUser(commentId: string, userId: string) { + const results = await this.databaseService.db + .select() + .from(commentLikes) + .where( + and(eq(commentLikes.commentId, commentId), eq(commentLikes.userId, userId)), + ); + return !!results[0]; + } +} diff --git a/backend/src/comments/repositories/comments.repository.ts b/backend/src/comments/repositories/comments.repository.ts index 1f33414..4459c0f 100644 --- a/backend/src/comments/repositories/comments.repository.ts +++ b/backend/src/comments/repositories/comments.repository.ts @@ -9,11 +9,11 @@ export class CommentsRepository { constructor(private readonly databaseService: DatabaseService) {} async create(data: NewCommentInDb) { - const [comment] = await this.databaseService.db + const results = await this.databaseService.db .insert(comments) .values(data) .returning(); - return comment; + return results[0]; } async findAllByContentId(contentId: string) { @@ -21,6 +21,7 @@ export class CommentsRepository { .select({ id: comments.id, text: comments.text, + parentId: comments.parentId, createdAt: comments.createdAt, updatedAt: comments.updatedAt, user: { @@ -37,11 +38,11 @@ export class CommentsRepository { } async findOne(id: string) { - const [comment] = await this.databaseService.db + const results = await this.databaseService.db .select() .from(comments) .where(and(eq(comments.id, id), isNull(comments.deletedAt))); - return comment; + return results[0]; } async delete(id: string) { diff --git a/backend/src/database/schemas/comment_likes.ts b/backend/src/database/schemas/comment_likes.ts new file mode 100644 index 0000000..18ce633 --- /dev/null +++ b/backend/src/database/schemas/comment_likes.ts @@ -0,0 +1,21 @@ +import { pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core"; +import { comments } from "./comments"; +import { users } from "./users"; + +export const commentLikes = pgTable( + "comment_likes", + { + commentId: uuid("comment_id") + .notNull() + .references(() => comments.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => users.uuid, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.commentId, t.userId] }), + }), +); diff --git a/backend/src/database/schemas/comments.ts b/backend/src/database/schemas/comments.ts index f949ac7..a6f1be2 100644 --- a/backend/src/database/schemas/comments.ts +++ b/backend/src/database/schemas/comments.ts @@ -12,6 +12,9 @@ export const comments = pgTable( userId: uuid("user_id") .notNull() .references(() => users.uuid, { onDelete: "cascade" }), + parentId: uuid("parent_id").references(() => comments.id, { + onDelete: "cascade", + }), text: text("text").notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() @@ -24,6 +27,7 @@ export const comments = pgTable( (table) => ({ contentIdIdx: index("comments_content_id_idx").on(table.contentId), userIdIdx: index("comments_user_id_idx").on(table.userId), + parentIdIdx: index("comments_parent_id_idx").on(table.parentId), }), ); diff --git a/backend/src/database/schemas/index.ts b/backend/src/database/schemas/index.ts index ed13f59..cd32f8d 100644 --- a/backend/src/database/schemas/index.ts +++ b/backend/src/database/schemas/index.ts @@ -1,6 +1,7 @@ export * from "./api_keys"; export * from "./audit_logs"; export * from "./categories"; +export * from "./comment_likes"; export * from "./comments"; export * from "./content"; export * from "./favorites"; diff --git a/frontend/src/components/comment-section.tsx b/frontend/src/components/comment-section.tsx index 3ed95fc..5a6f306 100644 --- a/frontend/src/components/comment-section.tsx +++ b/frontend/src/components/comment-section.tsx @@ -2,7 +2,7 @@ import { formatDistanceToNow } from "date-fns"; import { fr } from "date-fns/locale"; -import { MoreHorizontal, Send, Trash2 } from "lucide-react"; +import { Heart, MoreHorizontal, Send, Trash2 } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -14,6 +14,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; import { useAuth } from "@/providers/auth-provider"; import { type Comment, CommentService } from "@/services/comment.service"; @@ -25,6 +26,7 @@ export function CommentSection({ contentId }: CommentSectionProps) { const { user, isAuthenticated } = useAuth(); const [comments, setComments] = React.useState([]); const [newComment, setNewComment] = React.useState(""); + const [replyingTo, setReplyingTo] = React.useState(null); const [isSubmitting, setIsSubmitting] = React.useState(false); const [isLoading, setIsLoading] = React.useState(true); @@ -49,9 +51,14 @@ export function CommentSection({ contentId }: CommentSectionProps) { setIsSubmitting(true); try { - const comment = await CommentService.create(contentId, newComment.trim()); + const comment = await CommentService.create( + contentId, + newComment.trim(), + replyingTo?.id, + ); setComments((prev) => [comment, ...prev]); setNewComment(""); + setReplyingTo(null); toast.success("Commentaire publié !"); } catch (_error) { toast.error("Erreur lors de la publication du commentaire"); @@ -70,97 +77,214 @@ export function CommentSection({ contentId }: CommentSectionProps) { } }; - return ( -
-

Commentaires ({comments.length})

+ const handleLike = async (comment: Comment) => { + if (!isAuthenticated) { + toast.error("Vous devez être connecté pour liker"); + return; + } - {isAuthenticated ? ( -
- - - {user?.username[0].toUpperCase()} + try { + if (comment.isLiked) { + await CommentService.unlike(comment.id); + setComments((prev) => + prev.map((c) => + c.id === comment.id + ? { ...c, isLiked: false, likesCount: c.likesCount - 1 } + : c, + ), + ); + } else { + await CommentService.like(comment.id); + setComments((prev) => + prev.map((c) => + c.id === comment.id + ? { ...c, isLiked: true, likesCount: c.likesCount + 1 } + : c, + ), + ); + } + } catch (_error) { + toast.error("Une erreur est survenue"); + } + }; + + // Organiser les commentaires : Parents d'abord + const rootComments = comments.filter((c) => !c.parentId); + + const renderComment = (comment: Comment, depth = 0) => { + const replies = comments.filter((c) => c.parentId === comment.id); + + return ( +
0 && "ml-10")}> +
+ + + {comment.user.username[0].toUpperCase()} -
-