feat: add moderation center for reports and ratings with user cleanup cron job

This commit is contained in:
2026-10-16 07:23:00 +02:00
parent af6ec84cd0
commit edcb229c42
7 changed files with 126 additions and 17 deletions

View File

@@ -68,13 +68,65 @@ export async function getPendingRatings() {
export async function updateRatingStatus(id: string, status: 'APPROVED' | 'REJECTED') { export async function updateRatingStatus(id: string, status: 'APPROVED' | 'REJECTED') {
try { try {
await prisma.rating.update({ const updated = await prisma.rating.update({
where: { id }, where: { id },
data: { status } 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'); revalidatePath('/moderation');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { success: false, error: "Erreur lors de la mise à jour de l'avis" }; 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" };
}
}

View File

@@ -30,12 +30,23 @@ export async function GET(request: Request) {
return NextResponse.json({ message: "Aucun compte à supprimer" }); return NextResponse.json({ message: "Aucun compte à supprimer" });
} }
// 2. Suppression définitive const userIds = usersToDelete.map(u => u.id);
// Prisma supprimera les données liées si onDelete: Cascade est configuré dans le schéma
// 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({ const deleteResult = await prisma.user.deleteMany({
where: { where: {
id: { id: {
in: usersToDelete.map(u => u.id) in: userIds
} }
} }
}); });

View File

@@ -131,10 +131,16 @@ export default async function ModerationPage({
<div> <div>
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5">Auteur</div> <div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5">Auteur</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-full bg-slate-800 flex items-center justify-center text-brand-400 font-bold text-sm uppercase">{r.user.name.charAt(0)}</div> <div className="w-9 h-9 rounded-full bg-slate-800 flex items-center justify-center text-brand-400 font-bold text-sm uppercase">
{r.user?.name ? r.user.name.charAt(0) : '?'}
</div>
<div> <div>
<div className="text-sm font-bold text-white">{r.user.name}</div> <div className="text-sm font-bold text-white">
<div className="text-[10px] text-slate-500">{r.user.email}</div> {r.user?.name || "Utilisateur supprimé"}
</div>
<div className="text-[10px] text-slate-500">
{r.user?.email || "Compte clôturé"}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -142,7 +148,9 @@ export default async function ModerationPage({
<div> <div>
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5">Entrepreneur Concerné</div> <div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5">Entrepreneur Concerné</div>
<div className="bg-slate-900/80 p-2.5 rounded-lg border border-slate-800"> <div className="bg-slate-900/80 p-2.5 rounded-lg border border-slate-800">
<div className="text-sm font-bold text-indigo-400">{r.business.name}</div> <div className="text-sm font-bold text-indigo-400">
{r.business?.name || "Entreprise introuvable ou supprimée"}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState } from 'react'; import { useState } from 'react';
import { Check, X, Loader2 } from 'lucide-react'; import { Check, X, Loader2, Trash2 } from 'lucide-react';
import { updateRatingStatus } from '@/app/actions/moderation'; import { updateRatingStatus, deleteRating } from '@/app/actions/moderation';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
interface RatingModerationButtonsProps { 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 ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -41,11 +60,20 @@ export default function RatingModerationButtons({ ratingId }: RatingModerationBu
<button <button
onClick={() => handleAction('REJECTED')} onClick={() => handleAction('REJECTED')}
disabled={isPending} disabled={isPending}
className="flex items-center gap-1.5 bg-slate-800 hover:bg-red-500/20 hover:text-red-500 text-slate-400 px-3 py-1.5 rounded-lg text-xs font-bold transition-all border border-slate-700 disabled:opacity-50" className="flex items-center gap-1.5 bg-slate-800 hover:bg-amber-500/20 hover:text-amber-500 text-slate-400 px-3 py-1.5 rounded-lg text-xs font-bold transition-all border border-slate-700 disabled:opacity-50"
> >
{isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <X className="w-3.5 h-3.5" />} {isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <X className="w-3.5 h-3.5" />}
Rejeter Rejeter
</button> </button>
<button
onClick={handleDelete}
disabled={isPending}
title="Supprimer définitivement l'avis (demande de l'entrepreneur)"
className="flex items-center gap-1.5 bg-slate-800 hover:bg-red-500/20 hover:text-red-500 text-slate-400 px-3 py-1.5 rounded-lg text-xs font-bold transition-all border border-slate-700 disabled:opacity-50"
>
{isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
Supprimer
</button>
</div> </div>
); );
} }

View File

@@ -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) { } catch (error: any) {
console.error('Error fetching ratings:', error); console.error('Error fetching ratings:', error);

View File

@@ -36,7 +36,7 @@ export async function POST(
} }
// 2. Security: Only the owner of the business can reply // 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 }); return NextResponse.json({ error: 'Action non autorisée. Seul l\'entrepreneur concerné peut répondre.' }, { status: 403 });
} }

View File

@@ -245,16 +245,16 @@ model AnalyticsEvent {
model Rating { model Rating {
id String @id @default(uuid()) id String @id @default(uuid())
value Int value Int
userId String userId String?
businessId String businessId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
comment String? comment String?
reply String? reply String?
replyAt DateTime? replyAt DateTime?
status RatingStatus @default(PENDING) status RatingStatus @default(PENDING)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@unique([userId, businessId]) @@unique([userId, businessId])
} }