From f093977b34629f85e55ce1131c5b71f53ae5472e Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Mon, 15 Dec 2025 15:16:54 +0100 Subject: [PATCH] Add dotenv package for environment variable management and update pnpm-lock.yaml. Adjust layout in RegisterPage and LoginPage components for improved responsiveness. Enhance AdminPanel with ChallengeManagement section and update navigation links for challenges. Refactor Prisma schema to include Challenge model and related enums. --- actions/admin/challenges.ts | 152 + actions/challenges/create.ts | 112 + app/api/admin/challenges/route.ts | 30 + app/api/challenges/route.ts | 25 + app/api/users/route.ts | 39 + app/challenges/page.tsx | 28 + app/login/page.tsx | 2 +- app/register/page.tsx | 19 +- components/admin/AdminPanel.tsx | 20 +- components/admin/ChallengeManagement.tsx | 504 +++ components/challenges/ChallengesSection.tsx | 428 ++ components/navigation/Navigation.tsx | 31 + components/ui/Alert.tsx | 4 +- package.json | 1 + pnpm-lock.yaml | 9 + prisma/generated/prisma/browser.ts | 44 +- prisma/generated/prisma/client.ts | 68 +- prisma/generated/prisma/commonInputTypes.ts | 838 ++-- prisma/generated/prisma/enums.ts | 69 +- prisma/generated/prisma/internal/class.ts | 263 +- .../prisma/internal/prismaNamespace.ts | 1610 +++---- .../prisma/internal/prismaNamespaceBrowser.ts | 235 +- prisma/generated/prisma/models.ts | 18 +- prisma/generated/prisma/models/Challenge.ts | 2261 ++++++++++ prisma/generated/prisma/models/Event.ts | 2113 ++++------ .../generated/prisma/models/EventFeedback.ts | 2104 ++++------ .../prisma/models/EventRegistration.ts | 1825 +++----- .../prisma/models/SitePreferences.ts | 1395 +++--- prisma/generated/prisma/models/User.ts | 3738 +++++++++-------- .../prisma/models/UserPreferences.ts | 1754 +++----- .../migration.sql | 2 + .../migration.sql | 33 + prisma/schema.prisma | 221 +- services/challenges/challenge.service.ts | 500 +++ 34 files changed, 11414 insertions(+), 9081 deletions(-) create mode 100644 actions/admin/challenges.ts create mode 100644 actions/challenges/create.ts create mode 100644 app/api/admin/challenges/route.ts create mode 100644 app/api/challenges/route.ts create mode 100644 app/api/users/route.ts create mode 100644 app/challenges/page.tsx create mode 100644 components/admin/ChallengeManagement.tsx create mode 100644 components/challenges/ChallengesSection.tsx create mode 100644 prisma/generated/prisma/models/Challenge.ts create mode 100644 prisma/migrations/20251212093056_add_primary_color/migration.sql create mode 100644 prisma/migrations/20251215140148_add_challenges/migration.sql create mode 100644 services/challenges/challenge.service.ts diff --git a/actions/admin/challenges.ts b/actions/admin/challenges.ts new file mode 100644 index 0000000..de5073e --- /dev/null +++ b/actions/admin/challenges.ts @@ -0,0 +1,152 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { auth } from '@/lib/auth' +import { challengeService } from '@/services/challenges/challenge.service' +import { Role } from '@/prisma/generated/prisma/client' +import { + ValidationError, + NotFoundError, +} from '@/services/errors' + +async function checkAdminAccess() { + const session = await auth() + if (!session?.user || session.user.role !== Role.ADMIN) { + throw new Error('Accès refusé - Admin uniquement') + } + return session +} + +export async function validateChallenge( + challengeId: string, + winnerId: string, + adminComment?: string +) { + try { + const session = await checkAdminAccess() + + const challenge = await challengeService.validateChallenge( + challengeId, + session.user.id, + winnerId, + adminComment + ) + + revalidatePath('/admin') + revalidatePath('/challenges') + revalidatePath('/leaderboard') + + return { success: true, message: 'Défi validé avec succès', data: challenge } + } catch (error) { + console.error('Validate challenge error:', error) + + if (error instanceof ValidationError) { + return { success: false, error: error.message } + } + if (error instanceof NotFoundError) { + return { success: false, error: error.message } + } + if (error instanceof Error && error.message.includes('Accès refusé')) { + return { success: false, error: error.message } + } + + return { success: false, error: 'Une erreur est survenue lors de la validation du défi' } + } +} + +export async function rejectChallenge( + challengeId: string, + adminComment?: string +) { + try { + const session = await checkAdminAccess() + + const challenge = await challengeService.rejectChallenge( + challengeId, + session.user.id, + adminComment + ) + + revalidatePath('/admin') + revalidatePath('/challenges') + + return { success: true, message: 'Défi rejeté', data: challenge } + } catch (error) { + console.error('Reject challenge error:', error) + + if (error instanceof ValidationError) { + return { success: false, error: error.message } + } + if (error instanceof NotFoundError) { + return { success: false, error: error.message } + } + if (error instanceof Error && error.message.includes('Accès refusé')) { + return { success: false, error: error.message } + } + + return { success: false, error: 'Une erreur est survenue lors du rejet du défi' } + } +} + +export async function updateChallenge( + challengeId: string, + data: { + title?: string + description?: string + pointsReward?: number + } +) { + try { + const session = await checkAdminAccess() + + const challenge = await challengeService.updateChallenge(challengeId, { + title: data.title, + description: data.description, + pointsReward: data.pointsReward, + }) + + revalidatePath('/admin') + revalidatePath('/challenges') + + return { success: true, message: 'Défi mis à jour avec succès', data: challenge } + } catch (error) { + console.error('Update challenge error:', error) + + if (error instanceof ValidationError) { + return { success: false, error: error.message } + } + if (error instanceof NotFoundError) { + return { success: false, error: error.message } + } + if (error instanceof Error && error.message.includes('Accès refusé')) { + return { success: false, error: error.message } + } + + return { success: false, error: 'Une erreur est survenue lors de la mise à jour du défi' } + } +} + +export async function deleteChallenge(challengeId: string) { + try { + const session = await checkAdminAccess() + + await challengeService.deleteChallenge(challengeId) + + revalidatePath('/admin') + revalidatePath('/challenges') + + return { success: true, message: 'Défi supprimé avec succès' } + } catch (error) { + console.error('Delete challenge error:', error) + + if (error instanceof NotFoundError) { + return { success: false, error: error.message } + } + if (error instanceof Error && error.message.includes('Accès refusé')) { + return { success: false, error: error.message } + } + + return { success: false, error: 'Une erreur est survenue lors de la suppression du défi' } + } +} + diff --git a/actions/challenges/create.ts b/actions/challenges/create.ts new file mode 100644 index 0000000..fc2b67a --- /dev/null +++ b/actions/challenges/create.ts @@ -0,0 +1,112 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { auth } from '@/lib/auth' +import { challengeService } from '@/services/challenges/challenge.service' +import { + ValidationError, + NotFoundError, + ConflictError, +} from '@/services/errors' + +export async function createChallenge(data: { + challengedId: string + title: string + description: string + pointsReward?: number +}) { + try { + const session = await auth() + + if (!session?.user?.id) { + return { success: false, error: 'Vous devez être connecté pour créer un défi' } + } + + const challenge = await challengeService.createChallenge({ + challengerId: session.user.id, + challengedId: data.challengedId, + title: data.title, + description: data.description, + pointsReward: data.pointsReward || 100, + }) + + revalidatePath('/challenges') + revalidatePath('/profile') + + return { success: true, message: 'Défi créé avec succès', data: challenge } + } catch (error) { + console.error('Create challenge error:', error) + + if (error instanceof ValidationError || error instanceof ConflictError) { + return { success: false, error: error.message } + } + if (error instanceof NotFoundError) { + return { success: false, error: error.message } + } + + return { success: false, error: 'Une erreur est survenue lors de la création du défi' } + } +} + +export async function acceptChallenge(challengeId: string) { + try { + const session = await auth() + + if (!session?.user?.id) { + return { success: false, error: 'Vous devez être connecté pour accepter un défi' } + } + + const challenge = await challengeService.acceptChallenge( + challengeId, + session.user.id + ) + + revalidatePath('/challenges') + revalidatePath('/profile') + + return { success: true, message: 'Défi accepté', data: challenge } + } catch (error) { + console.error('Accept challenge error:', error) + + if (error instanceof ValidationError) { + return { success: false, error: error.message } + } + if (error instanceof NotFoundError) { + return { success: false, error: error.message } + } + + return { success: false, error: 'Une erreur est survenue lors de l\'acceptation du défi' } + } +} + +export async function cancelChallenge(challengeId: string) { + try { + const session = await auth() + + if (!session?.user?.id) { + return { success: false, error: 'Vous devez être connecté pour annuler un défi' } + } + + const challenge = await challengeService.cancelChallenge( + challengeId, + session.user.id + ) + + revalidatePath('/challenges') + revalidatePath('/profile') + + return { success: true, message: 'Défi annulé', data: challenge } + } catch (error) { + console.error('Cancel challenge error:', error) + + if (error instanceof ValidationError) { + return { success: false, error: error.message } + } + if (error instanceof NotFoundError) { + return { success: false, error: error.message } + } + + return { success: false, error: 'Une erreur est survenue lors de l\'annulation du défi' } + } +} + diff --git a/app/api/admin/challenges/route.ts b/app/api/admin/challenges/route.ts new file mode 100644 index 0000000..e447672 --- /dev/null +++ b/app/api/admin/challenges/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { challengeService } from "@/services/challenges/challenge.service"; +import { Role } 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 tous les défis (PENDING et ACCEPTED) pour l'admin + const allChallenges = await challengeService.getAllChallenges(); + // Filtrer pour ne garder que PENDING et ACCEPTED + const challenges = allChallenges.filter( + (c) => c.status === "PENDING" || c.status === "ACCEPTED" + ); + + return NextResponse.json(challenges); + } catch (error) { + console.error("Error fetching challenges:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération des défis" }, + { status: 500 } + ); + } +} + diff --git a/app/api/challenges/route.ts b/app/api/challenges/route.ts new file mode 100644 index 0000000..4db2d1c --- /dev/null +++ b/app/api/challenges/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { challengeService } from "@/services/challenges/challenge.service"; + +export async function GET() { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Vous devez être connecté" }, { status: 401 }); + } + + // Récupérer tous les défis de l'utilisateur + const challenges = await challengeService.getUserChallenges(session.user.id); + + return NextResponse.json(challenges); + } catch (error) { + console.error("Error fetching challenges:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération des défis" }, + { status: 500 } + ); + } +} + diff --git a/app/api/users/route.ts b/app/api/users/route.ts new file mode 100644 index 0000000..e3a924a --- /dev/null +++ b/app/api/users/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { userService } from "@/services/users/user.service"; + +export async function GET() { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Vous devez être connecté" }, { status: 401 }); + } + + // Récupérer tous les utilisateurs (pour sélectionner qui défier) + const users = await userService.getAllUsers({ + orderBy: { + username: "asc", + }, + select: { + id: true, + username: true, + avatar: true, + score: true, + level: true, + }, + }); + + // Filtrer l'utilisateur actuel + const otherUsers = users.filter((user) => user.id !== session.user.id); + + return NextResponse.json(otherUsers); + } catch (error) { + console.error("Error fetching users:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération des utilisateurs" }, + { status: 500 } + ); + } +} + diff --git a/app/challenges/page.tsx b/app/challenges/page.tsx new file mode 100644 index 0000000..944a981 --- /dev/null +++ b/app/challenges/page.tsx @@ -0,0 +1,28 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/lib/auth"; +import { getBackgroundImage } from "@/lib/preferences"; +import NavigationWrapper from "@/components/navigation/NavigationWrapper"; +import ChallengesSection from "@/components/challenges/ChallengesSection"; + +export const dynamic = "force-dynamic"; + +export default async function ChallengesPage() { + const session = await auth(); + + if (!session?.user) { + redirect("/login"); + } + + const backgroundImage = await getBackgroundImage( + "home", + "/got-background.jpg" + ); + + return ( +
+ + +
+ ); +} + diff --git a/app/login/page.tsx b/app/login/page.tsx index fc31174..69b6604 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -60,7 +60,7 @@ export default function LoginPage() { CONNEXION diff --git a/app/register/page.tsx b/app/register/page.tsx index 23b580e..a1471a7 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -174,7 +174,7 @@ export default function RegisterPage() { {/* Register Form */} -
+
Classe de Personnage (optionnel) -
+
{CHARACTER_CLASSES.map((cls) => ( ))}
diff --git a/components/admin/AdminPanel.tsx b/components/admin/AdminPanel.tsx index d10df95..e22033d 100644 --- a/components/admin/AdminPanel.tsx +++ b/components/admin/AdminPanel.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; 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 BackgroundPreferences from "@/components/admin/BackgroundPreferences"; import { Button, Card, SectionTitle } from "@/components/ui"; @@ -18,7 +19,7 @@ interface AdminPanelProps { initialPreferences: SitePreferences; } -type AdminSection = "preferences" | "users" | "events" | "feedbacks"; +type AdminSection = "preferences" | "users" | "events" | "feedbacks" | "challenges"; export default function AdminPanel({ initialPreferences }: AdminPanelProps) { const [activeSection, setActiveSection] = @@ -67,6 +68,14 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) { > Feedbacks +
{activeSection === "preferences" && ( @@ -108,6 +117,15 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
)} + + {activeSection === "challenges" && ( + +

+ Gestion des Défis +

+ +
+ )}
); diff --git a/components/admin/ChallengeManagement.tsx b/components/admin/ChallengeManagement.tsx new file mode 100644 index 0000000..f19e07b --- /dev/null +++ b/components/admin/ChallengeManagement.tsx @@ -0,0 +1,504 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; +import { validateChallenge, rejectChallenge, updateChallenge, deleteChallenge } from "@/actions/admin/challenges"; +import { Button, Card, Input, Textarea, Alert } from "@/components/ui"; +import { Avatar } from "@/components/ui"; + +interface Challenge { + id: string; + challenger: { + id: string; + username: string; + avatar: string | null; + }; + challenged: { + id: string; + username: string; + avatar: string | null; + }; + title: string; + description: string; + pointsReward: number; + status: string; + adminComment: string | null; + createdAt: string; + acceptedAt: string | null; +} + +export default function ChallengeManagement() { + const [challenges, setChallenges] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedChallenge, setSelectedChallenge] = useState(null); + const [editingChallenge, setEditingChallenge] = useState(null); + const [winnerId, setWinnerId] = useState(""); + const [adminComment, setAdminComment] = useState(""); + const [editTitle, setEditTitle] = useState(""); + const [editDescription, setEditDescription] = useState(""); + const [editPointsReward, setEditPointsReward] = useState(0); + const [isPending, startTransition] = useTransition(); + const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + fetchChallenges(); + }, []); + + const fetchChallenges = async () => { + try { + const response = await fetch("/api/admin/challenges"); + if (response.ok) { + const data = await response.json(); + setChallenges(data); + } + } catch (error) { + console.error("Error fetching challenges:", error); + } finally { + setLoading(false); + } + }; + + const handleValidate = async () => { + if (!selectedChallenge || !winnerId) { + setErrorMessage("Veuillez sélectionner un gagnant"); + setTimeout(() => setErrorMessage(null), 5000); + return; + } + + startTransition(async () => { + const result = await validateChallenge( + selectedChallenge.id, + winnerId, + adminComment || undefined + ); + + if (result.success) { + setSuccessMessage("Défi validé avec succès ! Les points ont été attribués."); + setSelectedChallenge(null); + setWinnerId(""); + setAdminComment(""); + fetchChallenges(); + setTimeout(() => setSuccessMessage(null), 5000); + } else { + setErrorMessage(result.error || "Erreur lors de la validation"); + setTimeout(() => setErrorMessage(null), 5000); + } + }); + }; + + const handleReject = async () => { + if (!selectedChallenge) return; + + if (!confirm("Êtes-vous sûr de vouloir rejeter ce défi ?")) { + return; + } + + startTransition(async () => { + const result = await rejectChallenge( + selectedChallenge.id, + adminComment || undefined + ); + + if (result.success) { + setSuccessMessage("Défi rejeté"); + setSelectedChallenge(null); + setAdminComment(""); + fetchChallenges(); + setTimeout(() => setSuccessMessage(null), 5000); + } else { + setErrorMessage(result.error || "Erreur lors du rejet"); + setTimeout(() => setErrorMessage(null), 5000); + } + }); + }; + + const handleEdit = (challenge: Challenge) => { + setEditingChallenge(challenge); + setEditTitle(challenge.title); + setEditDescription(challenge.description); + setEditPointsReward(challenge.pointsReward); + }; + + const handleUpdate = async () => { + if (!editingChallenge) return; + + startTransition(async () => { + const result = await updateChallenge(editingChallenge.id, { + title: editTitle, + description: editDescription, + pointsReward: editPointsReward, + }); + + if (result.success) { + setSuccessMessage("Défi mis à jour avec succès"); + setEditingChallenge(null); + setEditTitle(""); + setEditDescription(""); + setEditPointsReward(0); + fetchChallenges(); + setTimeout(() => setSuccessMessage(null), 5000); + } else { + setErrorMessage(result.error || "Erreur lors de la mise à jour"); + setTimeout(() => setErrorMessage(null), 5000); + } + }); + }; + + const handleDelete = async (challengeId: string) => { + if (!confirm("Êtes-vous sûr de vouloir supprimer ce défi ? Cette action est irréversible.")) { + return; + } + + startTransition(async () => { + const result = await deleteChallenge(challengeId); + + if (result.success) { + setSuccessMessage("Défi supprimé avec succès"); + fetchChallenges(); + setTimeout(() => setSuccessMessage(null), 5000); + } else { + setErrorMessage(result.error || "Erreur lors de la suppression"); + setTimeout(() => setErrorMessage(null), 5000); + } + }); + }; + + if (loading) { + return ( +
Chargement...
+ ); + } + + if (challenges.length === 0) { + return ( +
+ Aucun défi en attente +
+ ); + } + + const acceptedChallenges = challenges.filter((c) => c.status === "ACCEPTED"); + const pendingChallenges = challenges.filter((c) => c.status === "PENDING"); + + return ( +
+ {successMessage && ( + + {successMessage} + + )} + {errorMessage && ( + + {errorMessage} + + )} +
+ {acceptedChallenges.length} défi{acceptedChallenges.length > 1 ? "s" : ""} en attente de validation + {pendingChallenges.length > 0 && ( + + • {pendingChallenges.length} défi{pendingChallenges.length > 1 ? "s" : ""} en attente d'acceptation + + )} +
+ + {challenges.map((challenge) => ( + +
+
+

+ {challenge.title} +

+

{challenge.description}

+ +
+
+ + + {challenge.challenger.username} + + VS + + + {challenge.challenged.username} + +
+
+ +
+ Récompense: {challenge.pointsReward} points +
+
+ + {challenge.status === "ACCEPTED" ? "Accepté" : "En attente d'acceptation"} + +
+ {challenge.acceptedAt && ( +
+ Accepté le: {new Date(challenge.acceptedAt).toLocaleDateString("fr-FR")} +
+ )} +
+ +
+ + {challenge.status === "ACCEPTED" && ( + + )} + +
+
+
+ ))} + + {/* Modal de validation */} + {selectedChallenge && ( +
{ + setSelectedChallenge(null); + setWinnerId(""); + setAdminComment(""); + }} + > + e.stopPropagation()} + > +
+

+ Valider/Rejeter le défi +

+ +
+

+ {selectedChallenge.title} +

+

+ {selectedChallenge.description} +

+ +
+
+ + + {selectedChallenge.challenger.username} + +
+ VS +
+ + + {selectedChallenge.challenged.username} + +
+
+
+ +
+ +
+ + +
+
+ +
+ +