From edcb229c423227debae3beea0aa860e4a9df1097 Mon Sep 17 00:00:00 2001 From: streaper2 Date: Fri, 16 Oct 2026 07:23:00 +0200 Subject: [PATCH] feat: add moderation center for reports and ratings with user cleanup cron job --- admin/src/app/actions/moderation.ts | 54 ++++++++++++++++++- admin/src/app/api/cron/cleanup-users/route.ts | 17 ++++-- admin/src/app/moderation/page.tsx | 16 ++++-- .../components/RatingModerationButtons.tsx | 34 ++++++++++-- app/api/businesses/[id]/ratings/route.ts | 12 ++++- app/api/ratings/[id]/reply/route.ts | 2 +- prisma/schema.prisma | 8 +-- 7 files changed, 126 insertions(+), 17 deletions(-) diff --git a/admin/src/app/actions/moderation.ts b/admin/src/app/actions/moderation.ts index d55e7ac..c84b637 100644 --- a/admin/src/app/actions/moderation.ts +++ b/admin/src/app/actions/moderation.ts @@ -68,13 +68,65 @@ export async function getPendingRatings() { export async function updateRatingStatus(id: string, status: 'APPROVED' | 'REJECTED') { try { - await prisma.rating.update({ + const updated = await prisma.rating.update({ where: { id }, data: { status } }); + + if (updated.businessId) { + const stats = await prisma.rating.aggregate({ + where: { businessId: updated.businessId, status: 'APPROVED' }, + _avg: { value: true }, + _count: { value: true } + }); + + await prisma.business.update({ + where: { id: updated.businessId }, + data: { + rating: stats._avg.value || 0, + ratingCount: stats._count.value || 0 + } + }); + } + revalidatePath('/moderation'); return { success: true }; } catch (error) { return { success: false, error: "Erreur lors de la mise à jour de l'avis" }; } } + +export async function deleteRating(id: string) { + try { + const rating = await prisma.rating.findUnique({ + where: { id }, + select: { businessId: true } + }); + + await prisma.rating.delete({ + where: { id } + }); + + if (rating?.businessId) { + const stats = await prisma.rating.aggregate({ + where: { businessId: rating.businessId, status: 'APPROVED' }, + _avg: { value: true }, + _count: { value: true } + }); + + await prisma.business.update({ + where: { id: rating.businessId }, + data: { + rating: stats._avg.value || 0, + ratingCount: stats._count.value || 0 + } + }); + } + + revalidatePath('/moderation'); + return { success: true }; + } catch (error) { + return { success: false, error: "Erreur lors de la suppression de l'avis" }; + } +} + diff --git a/admin/src/app/api/cron/cleanup-users/route.ts b/admin/src/app/api/cron/cleanup-users/route.ts index 244b875..e230b0c 100644 --- a/admin/src/app/api/cron/cleanup-users/route.ts +++ b/admin/src/app/api/cron/cleanup-users/route.ts @@ -30,12 +30,23 @@ export async function GET(request: Request) { return NextResponse.json({ message: "Aucun compte à supprimer" }); } - // 2. Suppression définitive - // Prisma supprimera les données liées si onDelete: Cascade est configuré dans le schéma + const userIds = usersToDelete.map(u => u.id); + + // 2. Préserver les avis rédigés en les dissociant du compte (deviennent anonymes / "Utilisateur supprimé") + await prisma.rating.updateMany({ + where: { + userId: { in: userIds } + }, + data: { + userId: null + } + }); + + // 3. Suppression définitive des comptes utilisateurs const deleteResult = await prisma.user.deleteMany({ where: { id: { - in: usersToDelete.map(u => u.id) + in: userIds } } }); diff --git a/admin/src/app/moderation/page.tsx b/admin/src/app/moderation/page.tsx index 21052cd..d4ac943 100644 --- a/admin/src/app/moderation/page.tsx +++ b/admin/src/app/moderation/page.tsx @@ -131,10 +131,16 @@ export default async function ModerationPage({
Auteur
-
{r.user.name.charAt(0)}
+
+ {r.user?.name ? r.user.name.charAt(0) : '?'} +
-
{r.user.name}
-
{r.user.email}
+
+ {r.user?.name || "Utilisateur supprimé"} +
+
+ {r.user?.email || "Compte clôturé"} +
@@ -142,7 +148,9 @@ export default async function ModerationPage({
Entrepreneur Concerné
-
{r.business.name}
+
+ {r.business?.name || "Entreprise introuvable ou supprimée"} +
diff --git a/admin/src/components/RatingModerationButtons.tsx b/admin/src/components/RatingModerationButtons.tsx index 8610787..c65fb8e 100644 --- a/admin/src/components/RatingModerationButtons.tsx +++ b/admin/src/components/RatingModerationButtons.tsx @@ -1,8 +1,8 @@ "use client"; import { useState } from 'react'; -import { Check, X, Loader2 } from 'lucide-react'; -import { updateRatingStatus } from '@/app/actions/moderation'; +import { Check, X, Loader2, Trash2 } from 'lucide-react'; +import { updateRatingStatus, deleteRating } from '@/app/actions/moderation'; import { toast } from 'react-hot-toast'; interface RatingModerationButtonsProps { @@ -28,6 +28,25 @@ export default function RatingModerationButtons({ ratingId }: RatingModerationBu } }; + const handleDelete = async () => { + if (!window.confirm("Êtes-vous sûr de vouloir supprimer définitivement cet avis ?")) { + return; + } + setIsPending(true); + try { + const result = await deleteRating(ratingId); + if (result.success) { + toast.success("Avis supprimé définitivement."); + } else { + toast.error(result.error || "Une erreur est survenue"); + } + } catch (error) { + toast.error("Erreur lors de la suppression"); + } finally { + setIsPending(false); + } + }; + return (
+
); } diff --git a/app/api/businesses/[id]/ratings/route.ts b/app/api/businesses/[id]/ratings/route.ts index 4fba201..282939a 100644 --- a/app/api/businesses/[id]/ratings/route.ts +++ b/app/api/businesses/[id]/ratings/route.ts @@ -46,7 +46,17 @@ export async function GET( } }); - return NextResponse.json(ratings); + // 3. Fallback for deleted users + const formattedRatings = ratings.map(r => ({ + ...r, + user: r.user || { + id: null, + name: "Utilisateur supprimé", + avatar: null + } + })); + + return NextResponse.json(formattedRatings); } catch (error: any) { console.error('Error fetching ratings:', error); diff --git a/app/api/ratings/[id]/reply/route.ts b/app/api/ratings/[id]/reply/route.ts index ea42948..0df3e5e 100644 --- a/app/api/ratings/[id]/reply/route.ts +++ b/app/api/ratings/[id]/reply/route.ts @@ -36,7 +36,7 @@ export async function POST( } // 2. Security: Only the owner of the business can reply - if (rating.business.ownerId !== userId) { + if (!rating.business || rating.business.ownerId !== userId) { return NextResponse.json({ error: 'Action non autorisée. Seul l\'entrepreneur concerné peut répondre.' }, { status: 403 }); } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e89673a..740d990 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -245,16 +245,16 @@ model AnalyticsEvent { model Rating { id String @id @default(uuid()) value Int - userId String - businessId String + userId String? + businessId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comment String? reply String? replyAt DateTime? status RatingStatus @default(PENDING) - business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) @@unique([userId, businessId]) }