From d7c2a965a0752051fd46312bf81ab7185a4dd2da Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:43:44 +0100 Subject: [PATCH] feat(contents): enhance user-specific data handling and admin content management Integrate user-specific fields (`isLiked`, `favoritesCount`) in content APIs and improve `ContentCard` through reactive updates. Add admin-only content deletion support. Refactor services and repository to enrich responses with additional data (author details, tags). --- backend/src/contents/contents.controller.ts | 42 ++++++++++++--- backend/src/contents/contents.service.ts | 39 ++++++++++++-- .../repositories/contents.repository.ts | 53 ++++++++++++++++++- frontend/src/components/content-card.tsx | 25 ++++++++- frontend/src/services/content.service.ts | 4 ++ frontend/src/types/content.ts | 3 +- 6 files changed, 152 insertions(+), 14 deletions(-) diff --git a/backend/src/contents/contents.controller.ts b/backend/src/contents/contents.controller.ts index cbdcf7b..e2dbe8d 100644 --- a/backend/src/contents/contents.controller.ts +++ b/backend/src/contents/contents.controller.ts @@ -21,6 +21,9 @@ import { import { FileInterceptor } from "@nestjs/platform-express"; import type { Request, Response } from "express"; import { AuthGuard } from "../auth/guards/auth.guard"; +import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard"; +import { RolesGuard } from "../auth/guards/roles.guard"; +import { Roles } from "../auth/decorators/roles.decorator"; import type { AuthenticatedRequest } from "../common/interfaces/request.interface"; import { ContentsService } from "./contents.service"; import { CreateContentDto } from "./dto/create-content.dto"; @@ -65,10 +68,12 @@ export class ContentsController { } @Get("explore") + @UseGuards(OptionalAuthGuard) @UseInterceptors(CacheInterceptor) @CacheTTL(60) @Header("Cache-Control", "public, max-age=60") explore( + @Req() req: AuthenticatedRequest, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, @Query("sort") sort?: "trend" | "recent", @@ -78,7 +83,7 @@ export class ContentsController { @Query("query") query?: string, @Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe) favoritesOnly?: boolean, - @Query("userId") userId?: string, + @Query("userId") userIdQuery?: string, ) { return this.contentsService.findAll({ limit, @@ -89,42 +94,60 @@ export class ContentsController { author, query, favoritesOnly, - userId, + userId: userIdQuery || req.user?.sub, }); } @Get("trends") + @UseGuards(OptionalAuthGuard) @UseInterceptors(CacheInterceptor) @CacheTTL(300) @Header("Cache-Control", "public, max-age=300") trends( + @Req() req: AuthenticatedRequest, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, ) { - return this.contentsService.findAll({ limit, offset, sortBy: "trend" }); + return this.contentsService.findAll({ + limit, + offset, + sortBy: "trend", + userId: req.user?.sub, + }); } @Get("recent") + @UseGuards(OptionalAuthGuard) @UseInterceptors(CacheInterceptor) @CacheTTL(60) @Header("Cache-Control", "public, max-age=60") recent( + @Req() req: AuthenticatedRequest, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, ) { - return this.contentsService.findAll({ limit, offset, sortBy: "recent" }); + return this.contentsService.findAll({ + limit, + offset, + sortBy: "recent", + userId: req.user?.sub, + }); } @Get(":idOrSlug") + @UseGuards(OptionalAuthGuard) @UseInterceptors(CacheInterceptor) @CacheTTL(3600) @Header("Cache-Control", "public, max-age=3600") async findOne( @Param("idOrSlug") idOrSlug: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, @Res() res: Response, ) { - const content = await this.contentsService.findOne(idOrSlug); + const content = await this.contentsService.findOne( + idOrSlug, + req.user?.sub, + ); if (!content) { throw new NotFoundException("Contenu non trouvé"); } @@ -158,4 +181,11 @@ export class ContentsController { remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) { return this.contentsService.remove(id, req.user.sub); } + + @Delete(":id/admin") + @UseGuards(AuthGuard, RolesGuard) + @Roles("admin") + removeAdmin(@Param("id") id: string) { + return this.contentsService.removeAdmin(id); + } } diff --git a/backend/src/contents/contents.service.ts b/backend/src/contents/contents.service.ts index c0f58ec..d933c66 100644 --- a/backend/src/contents/contents.service.ts +++ b/backend/src/contents/contents.service.ts @@ -126,7 +126,18 @@ export class ContentsService { this.contentsRepository.count(options), ]); - return { data, totalCount }; + const processedData = data.map((content) => ({ + ...content, + url: this.getFileUrl(content.storageKey), + author: { + ...content.author, + avatarUrl: content.author?.avatarUrl + ? this.getFileUrl(content.author.avatarUrl) + : null, + }, + })); + + return { data: processedData, totalCount }; } async create(userId: string, data: CreateContentDto) { @@ -162,8 +173,30 @@ export class ContentsService { return deleted; } - async findOne(idOrSlug: string) { - return this.contentsRepository.findOne(idOrSlug); + async removeAdmin(id: string) { + this.logger.log(`Removing content ${id} by admin`); + const deleted = await this.contentsRepository.softDeleteAdmin(id); + + if (deleted) { + await this.clearContentsCache(); + } + return deleted; + } + + async findOne(idOrSlug: string, userId?: string) { + const content = await this.contentsRepository.findOne(idOrSlug, userId); + if (!content) return null; + + return { + ...content, + url: this.getFileUrl(content.storageKey), + author: { + ...content.author, + avatarUrl: content.author?.avatarUrl + ? this.getFileUrl(content.author.avatarUrl) + : null, + }, + }; } generateBotHtml(content: { title: string; storageKey: string }): string { diff --git a/backend/src/contents/repositories/contents.repository.ts b/backend/src/contents/repositories/contents.repository.ts index 8fbc2d0..6041756 100644 --- a/backend/src/contents/repositories/contents.repository.ts +++ b/backend/src/contents/repositories/contents.repository.ts @@ -135,11 +135,19 @@ export class ContentsRepository { fileSize: contents.fileSize, views: contents.views, usageCount: contents.usageCount, + favoritesCount: sql`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith( + Number, + ), + isLiked: userId + ? sql`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})` + : sql`false`, createdAt: contents.createdAt, updatedAt: contents.updatedAt, author: { id: users.uuid, username: users.username, + displayName: users.displayName, + avatarUrl: users.avatarUrl, }, category: { id: categories.id, @@ -215,7 +223,7 @@ export class ContentsRepository { }); } - async findOne(idOrSlug: string) { + async findOne(idOrSlug: string, userId?: string) { const [result] = await this.databaseService.db .select({ id: contents.id, @@ -227,11 +235,30 @@ export class ContentsRepository { fileSize: contents.fileSize, views: contents.views, usageCount: contents.usageCount, + favoritesCount: sql`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith( + Number, + ), + isLiked: userId + ? sql`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})` + : sql`false`, createdAt: contents.createdAt, updatedAt: contents.updatedAt, userId: contents.userId, + author: { + id: users.uuid, + username: users.username, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }, + category: { + id: categories.id, + name: categories.name, + slug: categories.slug, + }, }) .from(contents) + .leftJoin(users, eq(contents.userId, users.uuid)) + .leftJoin(categories, eq(contents.categoryId, categories.id)) .where( and( isNull(contents.deletedAt), @@ -240,7 +267,20 @@ export class ContentsRepository { ) .limit(1); - return result; + if (!result) return null; + + const tagsForContent = await this.databaseService.db + .select({ + name: tags.name, + }) + .from(contentsToTags) + .innerJoin(tags, eq(contentsToTags.tagId, tags.id)) + .where(eq(contentsToTags.contentId, result.id)); + + return { + ...result, + tags: tagsForContent.map((t) => t.name), + }; } async count(options: { @@ -353,6 +393,15 @@ export class ContentsRepository { return deleted; } + async softDeleteAdmin(id: string) { + const [deleted] = await this.databaseService.db + .update(contents) + .set({ deletedAt: new Date() }) + .where(eq(contents.id, id)) + .returning(); + return deleted; + } + async findBySlug(slug: string) { const [result] = await this.databaseService.db .select() diff --git a/frontend/src/components/content-card.tsx b/frontend/src/components/content-card.tsx index ad6855f..9ae2151 100644 --- a/frontend/src/components/content-card.tsx +++ b/frontend/src/components/content-card.tsx @@ -26,9 +26,14 @@ interface ContentCardProps { export function ContentCard({ content }: ContentCardProps) { const { isAuthenticated } = useAuth(); const router = useRouter(); - const [isLiked, setIsLiked] = React.useState(false); + const [isLiked, setIsLiked] = React.useState(content.isLiked || false); const [likesCount, setLikesCount] = React.useState(content.favoritesCount); + React.useEffect(() => { + setIsLiked(content.isLiked || false); + setLikesCount(content.favoritesCount); + }, [content.isLiked, content.favoritesCount]); + const handleLike = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -54,6 +59,17 @@ export function ContentCard({ content }: ContentCardProps) { } }; + const handleUse = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + try { + await ContentService.incrementUsage(content.id); + toast.success("Mème prêt à être utilisé !"); + } catch (_error) { + toast.error("Une erreur est survenue"); + } + }; + return ( @@ -118,7 +134,12 @@ export function ContentCard({ content }: ContentCardProps) { - diff --git a/frontend/src/services/content.service.ts b/frontend/src/services/content.service.ts index c062dcd..3a84953 100644 --- a/frontend/src/services/content.service.ts +++ b/frontend/src/services/content.service.ts @@ -61,4 +61,8 @@ export const ContentService = { }); return data; }, + + async removeAdmin(id: string): Promise { + await api.delete(`/contents/${id}/admin`); + }, }; diff --git a/frontend/src/types/content.ts b/frontend/src/types/content.ts index c48bb0c..99f0eaa 100644 --- a/frontend/src/types/content.ts +++ b/frontend/src/types/content.ts @@ -16,6 +16,7 @@ export interface Content { views: number; usageCount: number; favoritesCount: number; + isLiked?: boolean; tags: (string | Tag)[]; category?: Category; authorId: string; @@ -39,7 +40,7 @@ export interface Category { export interface PaginatedResponse { data: T[]; - total: number; + totalCount: number; limit: number; offset: number; }