feat: add moderation center for reports and ratings with user cleanup cron job
This commit is contained in:
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user