From 82069c74bc18254cf589415e1b68da729f58afba Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 19 Dec 2025 13:58:04 +0100 Subject: [PATCH] Add HouseManagement integration to AdminPanel and implement removeMemberAsAdmin feature in HouseService: Enhance admin capabilities with new section for house management and functionality to remove members from houses by admins, including points deduction logic. --- actions/admin/houses.ts | 139 ++++++++ actions/challenges/create.ts | 1 + app/api/admin/houses/route.ts | 99 ++++++ app/api/challenges/route.ts | 1 + app/api/users/route.ts | 1 + components/admin/AdminPanel.tsx | 21 +- components/admin/HouseManagement.tsx | 454 +++++++++++++++++++++++++++ components/ui/Avatar.tsx | 3 + services/houses/house.service.ts | 56 ++++ 9 files changed, 774 insertions(+), 1 deletion(-) create mode 100644 actions/admin/houses.ts create mode 100644 app/api/admin/houses/route.ts create mode 100644 components/admin/HouseManagement.tsx diff --git a/actions/admin/houses.ts b/actions/admin/houses.ts new file mode 100644 index 0000000..3a03457 --- /dev/null +++ b/actions/admin/houses.ts @@ -0,0 +1,139 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { houseService } from "@/services/houses/house.service"; +import { Role } from "@/prisma/generated/prisma/client"; +import { + ValidationError, + NotFoundError, + ConflictError, + ForbiddenError, +} from "@/services/errors"; + +function checkAdminAccess() { + return async () => { + const session = await auth(); + if (!session?.user || session.user.role !== Role.ADMIN) { + throw new Error("Accès refusé"); + } + return session; + }; +} + +export async function updateHouse( + houseId: string, + data: { + name?: string; + description?: string | null; + } +) { + try { + await checkAdminAccess()(); + + // L'admin peut modifier n'importe quelle maison sans vérifier les permissions normales + // On utilise directement le service mais on bypass les vérifications de propriétaire/admin + const house = await houseService.getHouseById(houseId); + if (!house) { + return { success: false, error: "Maison non trouvée" }; + } + + // Utiliser le service avec le creatorId pour bypass les vérifications + const updatedHouse = await houseService.updateHouse( + houseId, + house.creatorId, // Utiliser le creatorId pour bypass + data + ); + + revalidatePath("/admin"); + revalidatePath("/houses"); + + return { success: true, data: updatedHouse }; + } catch (error) { + console.error("Error updating house:", error); + + if (error instanceof ValidationError) { + return { success: false, error: error.message }; + } + if (error instanceof ConflictError) { + return { success: false, error: error.message }; + } + if (error instanceof Error && error.message === "Accès refusé") { + return { success: false, error: "Accès refusé" }; + } + + return { + success: false, + error: "Erreur lors de la mise à jour de la maison", + }; + } +} + +export async function deleteHouse(houseId: string) { + try { + await checkAdminAccess()(); + + const house = await houseService.getHouseById(houseId); + if (!house) { + return { success: false, error: "Maison non trouvée" }; + } + + // L'admin peut supprimer n'importe quelle maison + // On utilise le creatorId pour bypass les vérifications + await houseService.deleteHouse(houseId, house.creatorId); + + revalidatePath("/admin"); + revalidatePath("/houses"); + + return { success: true }; + } catch (error) { + console.error("Error deleting house:", error); + + if (error instanceof NotFoundError) { + return { success: false, error: error.message }; + } + if (error instanceof ForbiddenError) { + return { success: false, error: error.message }; + } + if (error instanceof Error && error.message === "Accès refusé") { + return { success: false, error: "Accès refusé" }; + } + + return { + success: false, + error: "Erreur lors de la suppression de la maison", + }; + } +} + +export async function removeMember(houseId: string, memberId: string) { + try { + await checkAdminAccess()(); + + // L'admin peut retirer n'importe quel membre (sauf le propriétaire) + await houseService.removeMemberAsAdmin(houseId, memberId); + + revalidatePath("/admin"); + revalidatePath("/houses"); + + return { success: true, message: "Membre retiré de la maison" }; + } catch (error) { + console.error("Error removing member:", error); + + if (error instanceof NotFoundError) { + return { success: false, error: error.message }; + } + if (error instanceof ForbiddenError) { + return { success: false, error: error.message }; + } + if (error instanceof Error && error.message === "Accès refusé") { + return { success: false, error: "Accès refusé" }; + } + + return { + success: false, + error: "Erreur lors du retrait du membre", + }; + } +} + diff --git a/actions/challenges/create.ts b/actions/challenges/create.ts index 10204f5..e2dcf69 100644 --- a/actions/challenges/create.ts +++ b/actions/challenges/create.ts @@ -127,3 +127,4 @@ export async function cancelChallenge(challengeId: string) { }; } } + diff --git a/app/api/admin/houses/route.ts b/app/api/admin/houses/route.ts new file mode 100644 index 0000000..9044f23 --- /dev/null +++ b/app/api/admin/houses/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { houseService } from "@/services/houses/house.service"; +import { Role, Prisma } from "@/prisma/generated/prisma/client"; + +export async function GET() { + try { + const session = await auth(); + + if (!session?.user || session.user.role !== Role.ADMIN) { + return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + } + + // Récupérer toutes les maisons avec leurs membres + type HouseWithIncludes = Prisma.HouseGetPayload<{ + include: { + creator: { + select: { + id: true; + username: true; + avatar: true; + }; + }; + memberships: { + include: { + user: { + select: { + id: true; + username: true; + avatar: true; + score: true; + level: true; + }; + }; + }; + }; + }; + }>; + + const houses = (await houseService.getAllHouses({ + include: { + creator: { + select: { + id: true, + username: true, + avatar: true, + }, + }, + memberships: { + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + score: true, + level: true, + }, + }, + }, + orderBy: [ + { role: "asc" }, // OWNER, ADMIN, MEMBER + { joinedAt: "asc" }, + ], + }, + }, + orderBy: { + createdAt: "desc", + }, + })) as unknown as HouseWithIncludes[]; + + // Transformer les données pour la sérialisation + const housesWithData = houses.map((house) => ({ + id: house.id, + name: house.name, + description: house.description, + creatorId: house.creatorId, + creator: house.creator, + createdAt: house.createdAt.toISOString(), + updatedAt: house.updatedAt.toISOString(), + membersCount: house.memberships?.length || 0, + memberships: + house.memberships?.map((membership) => ({ + id: membership.id, + role: membership.role, + joinedAt: membership.joinedAt.toISOString(), + user: membership.user, + })) || [], + })); + + return NextResponse.json(housesWithData); + } catch (error) { + console.error("Error fetching houses:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération des maisons" }, + { status: 500 } + ); + } +} diff --git a/app/api/challenges/route.ts b/app/api/challenges/route.ts index 1ab42a9..11c31fb 100644 --- a/app/api/challenges/route.ts +++ b/app/api/challenges/route.ts @@ -27,3 +27,4 @@ export async function GET() { ); } } + diff --git a/app/api/users/route.ts b/app/api/users/route.ts index 34617da..f648224 100644 --- a/app/api/users/route.ts +++ b/app/api/users/route.ts @@ -39,3 +39,4 @@ export async function GET() { ); } } + diff --git a/components/admin/AdminPanel.tsx b/components/admin/AdminPanel.tsx index a59eea9..eb5ca4b 100644 --- a/components/admin/AdminPanel.tsx +++ b/components/admin/AdminPanel.tsx @@ -5,6 +5,7 @@ import UserManagement from "@/components/admin/UserManagement"; import EventManagement from "@/components/admin/EventManagement"; import FeedbackManagement from "@/components/admin/FeedbackManagement"; import ChallengeManagement from "@/components/admin/ChallengeManagement"; +import HouseManagement from "@/components/admin/HouseManagement"; import BackgroundPreferences from "@/components/admin/BackgroundPreferences"; import EventPointsPreferences from "@/components/admin/EventPointsPreferences"; import EventFeedbackPointsPreferences from "@/components/admin/EventFeedbackPointsPreferences"; @@ -33,7 +34,8 @@ type AdminSection = | "users" | "events" | "feedbacks" - | "challenges"; + | "challenges" + | "houses"; export default function AdminPanel({ initialPreferences }: AdminPanelProps) { const [activeSection, setActiveSection] = @@ -90,6 +92,14 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) { > Défis + {activeSection === "preferences" && ( @@ -143,6 +153,15 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) { )} + + {activeSection === "houses" && ( + +

+ Gestion des Maisons +

+ +
+ )} ); diff --git a/components/admin/HouseManagement.tsx b/components/admin/HouseManagement.tsx new file mode 100644 index 0000000..d398e88 --- /dev/null +++ b/components/admin/HouseManagement.tsx @@ -0,0 +1,454 @@ +"use client"; + +import { useState, useEffect, useTransition } from "react"; +import { + Input, + Textarea, + Button, + Card, + Badge, + Modal, + CloseButton, + Avatar, +} from "@/components/ui"; +import { updateHouse, deleteHouse, removeMember } from "@/actions/admin/houses"; + +interface House { + id: string; + name: string; + description: string | null; + creatorId: string; + creator: { + id: string; + username: string; + avatar: string | null; + }; + createdAt: string; + updatedAt: string; + membersCount: number; + memberships: Array<{ + id: string; + role: string; + joinedAt: string; + user: { + id: string; + username: string; + avatar: string | null; + score: number; + level: number; + }; + }>; +} + +interface HouseFormData { + name: string; + description: string; +} + +const getRoleLabel = (role: string) => { + switch (role) { + case "OWNER": + return "👑 Propriétaire"; + case "ADMIN": + return "⚡ Admin"; + case "MEMBER": + return "👤 Membre"; + default: + return role; + } +}; + +const getRoleColor = (role: string) => { + switch (role) { + case "OWNER": + return "var(--accent)"; + case "ADMIN": + return "var(--primary)"; + case "MEMBER": + return "var(--muted-foreground)"; + default: + return "var(--gray)"; + } +}; + +export default function HouseManagement() { + const [houses, setHouses] = useState([]); + const [loading, setLoading] = useState(true); + const [editingHouse, setEditingHouse] = useState(null); + const [saving, setSaving] = useState(false); + const [deletingHouseId, setDeletingHouseId] = useState(null); + const [viewingMembers, setViewingMembers] = useState(null); + const [removingMemberId, setRemovingMemberId] = useState(null); + const [formData, setFormData] = useState({ + name: "", + description: "", + }); + const [, startTransition] = useTransition(); + + useEffect(() => { + fetchHouses(); + }, []); + + const fetchHouses = async () => { + try { + const response = await fetch("/api/admin/houses"); + if (response.ok) { + const data = await response.json(); + setHouses(data); + } + } catch (error) { + console.error("Error fetching houses:", error); + } finally { + setLoading(false); + } + }; + + const handleEdit = (house: House) => { + setEditingHouse(house); + setFormData({ + name: house.name, + description: house.description || "", + }); + }; + + const handleSave = async () => { + if (!editingHouse) return; + + setSaving(true); + startTransition(async () => { + try { + const result = await updateHouse(editingHouse.id, { + name: formData.name, + description: formData.description || null, + }); + + if (result.success) { + await fetchHouses(); + setEditingHouse(null); + setFormData({ name: "", description: "" }); + } else { + alert(result.error || "Erreur lors de la mise à jour"); + } + } catch (error) { + console.error("Error updating house:", error); + alert("Erreur lors de la mise à jour"); + } finally { + setSaving(false); + } + }); + }; + + const handleDelete = async (houseId: string) => { + if ( + !confirm( + "Êtes-vous sûr de vouloir supprimer cette maison ? Cette action est irréversible et supprimera tous les membres." + ) + ) { + return; + } + + setDeletingHouseId(houseId); + startTransition(async () => { + try { + const result = await deleteHouse(houseId); + + if (result.success) { + await fetchHouses(); + } else { + alert(result.error || "Erreur lors de la suppression"); + } + } catch (error) { + console.error("Error deleting house:", error); + alert("Erreur lors de la suppression"); + } finally { + setDeletingHouseId(null); + } + }); + }; + + const handleCancel = () => { + setEditingHouse(null); + setFormData({ name: "", description: "" }); + }; + + const handleRemoveMember = async (houseId: string, memberId: string) => { + if ( + !confirm( + "Êtes-vous sûr de vouloir retirer ce membre de la maison ? Cette action lui retirera des points." + ) + ) { + return; + } + + setRemovingMemberId(memberId); + startTransition(async () => { + try { + const result = await removeMember(houseId, memberId); + + if (result.success) { + // Récupérer les maisons mises à jour + const response = await fetch("/api/admin/houses"); + if (response.ok) { + const updatedHouses = await response.json(); + setHouses(updatedHouses); + // Mettre à jour la modal si elle est ouverte + if (viewingMembers) { + const updatedHouse = updatedHouses.find((h: House) => h.id === houseId); + if (updatedHouse) { + setViewingMembers(updatedHouse); + } else { + // Si la maison n'existe plus, fermer la modal + setViewingMembers(null); + } + } + } + } else { + alert(result.error || "Erreur lors du retrait du membre"); + } + } catch (error) { + console.error("Error removing member:", error); + alert("Erreur lors du retrait du membre"); + } finally { + setRemovingMemberId(null); + } + }); + }; + + const formatNumber = (num: number) => { + return num.toLocaleString("en-US"); + }; + + if (loading) { + return
Chargement...
; + } + + return ( +
+
+

+ Maisons ({houses.length}) +

+
+ + {/* Modal d'édition */} + {editingHouse && ( + +
+
+

+ Modifier la maison +

+ +
+
+ + setFormData({ ...formData, name: e.target.value }) + } + placeholder="Nom de la maison" + className="text-xs sm:text-sm px-3 py-2" + /> +