diff --git a/app/admin/page.tsx b/app/admin/page.tsx index ec624f1..e8b52b0 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; +import { sitePreferencesService } from "@/services/preferences/site-preferences.service"; import { Role } from "@/prisma/generated/prisma/client"; import NavigationWrapper from "@/components/NavigationWrapper"; import AdminPanel from "@/components/AdminPanel"; @@ -18,22 +18,9 @@ export default async function AdminPage() { redirect("/"); } - // Récupérer les préférences globales du site - let sitePreferences = await prisma.sitePreferences.findUnique({ - where: { id: "global" }, - }); - - // Si elles n'existent pas, créer une entrée par défaut - if (!sitePreferences) { - sitePreferences = await prisma.sitePreferences.create({ - data: { - id: "global", - homeBackground: null, - eventsBackground: null, - leaderboardBackground: null, - }, - }); - } + // Récupérer les préférences globales du site (ou créer si elles n'existent pas) + const sitePreferences = + await sitePreferencesService.getOrCreateSitePreferences(); return (
diff --git a/app/api/admin/events/[id]/route.ts b/app/api/admin/events/[id]/route.ts index 586736e..8eaa088 100644 --- a/app/api/admin/events/[id]/route.ts +++ b/app/api/admin/events/[id]/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; -import { Role, EventType } from "@/prisma/generated/prisma/client"; +import { eventService } from "@/services/events/event.service"; +import { Role } from "@/prisma/generated/prisma/client"; +import { ValidationError, NotFoundError } from "@/services/errors"; export async function PUT( request: Request, @@ -19,63 +20,27 @@ export async function PUT( const { date, name, description, type, room, time, maxPlaces } = body; // Le statut est ignoré s'il est fourni, il sera calculé automatiquement - // Vérifier que l'événement existe - const existingEvent = await prisma.event.findUnique({ - where: { id }, - }); - - if (!existingEvent) { - return NextResponse.json( - { error: "Événement non trouvé" }, - { status: 404 } - ); - } - - const updateData: { - date?: Date; - name?: string; - description?: string; - type?: EventType; - room?: string | null; - time?: string | null; - maxPlaces?: number | null; - } = {}; - - if (date !== undefined) { - const eventDate = new Date(date); - if (isNaN(eventDate.getTime())) { - return NextResponse.json( - { error: "Format de date invalide" }, - { status: 400 } - ); - } - updateData.date = eventDate; - } - if (name !== undefined) updateData.name = name; - if (description !== undefined) updateData.description = description; - if (type !== undefined) { - if (!Object.values(EventType).includes(type)) { - return NextResponse.json( - { error: "Type d'événement invalide" }, - { status: 400 } - ); - } - updateData.type = type as EventType; - } - // Le statut est toujours calculé automatiquement, on ignore s'il est fourni - if (room !== undefined) updateData.room = room || null; - if (time !== undefined) updateData.time = time || null; - if (maxPlaces !== undefined) - updateData.maxPlaces = maxPlaces ? parseInt(maxPlaces) : null; - - const event = await prisma.event.update({ - where: { id }, - data: updateData, + const event = await eventService.validateAndUpdateEvent(id, { + date, + name, + description, + type, + room, + time, + maxPlaces, }); return NextResponse.json(event); } catch (error) { console.error("Error updating event:", error); + + if (error instanceof ValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + if (error instanceof NotFoundError) { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + return NextResponse.json( { error: "Erreur lors de la mise à jour de l'événement" }, { status: 500 } @@ -97,9 +62,7 @@ export async function DELETE( const { id } = await params; // Vérifier que l'événement existe - const existingEvent = await prisma.event.findUnique({ - where: { id }, - }); + const existingEvent = await eventService.getEventById(id); if (!existingEvent) { return NextResponse.json( @@ -108,9 +71,7 @@ export async function DELETE( ); } - await prisma.event.delete({ - where: { id }, - }); + await eventService.deleteEvent(id); return NextResponse.json({ success: true }); } catch (error) { diff --git a/app/api/admin/events/route.ts b/app/api/admin/events/route.ts index 9828e34..9590c44 100644 --- a/app/api/admin/events/route.ts +++ b/app/api/admin/events/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; -import { Role, EventType } from "@/prisma/generated/prisma/client"; -import { calculateEventStatus } from "@/lib/eventStatus"; +import { eventService } from "@/services/events/event.service"; +import { Role } from "@/prisma/generated/prisma/client"; +import { ValidationError } from "@/services/errors"; export async function GET() { try { @@ -12,34 +12,22 @@ export async function GET() { return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); } - const events = await prisma.event.findMany({ - orderBy: { - date: "desc", - }, - include: { - _count: { - select: { - registrations: true, - }, - }, - }, - }); + const events = await eventService.getEventsWithStatus(); - // Transformer les données pour inclure le nombre d'inscriptions - // Le statut est calculé automatiquement en fonction de la date + // Transformer les données pour la sérialisation const eventsWithCount = events.map((event) => ({ id: event.id, date: event.date.toISOString(), name: event.name, description: event.description, type: event.type, - status: calculateEventStatus(event.date), + status: event.status, room: event.room, time: event.time, maxPlaces: event.maxPlaces, createdAt: event.createdAt.toISOString(), updatedAt: event.updatedAt.toISOString(), - registrationsCount: event._count.registrations, + registrationsCount: event.registrationsCount, })); return NextResponse.json(eventsWithCount); @@ -63,45 +51,24 @@ export async function POST(request: Request) { const body = await request.json(); const { date, name, description, type, room, time, maxPlaces } = body; - if (!date || !name || !description || !type) { - return NextResponse.json( - { error: "Tous les champs sont requis" }, - { status: 400 } - ); - } - - // Convertir la date string en Date object - const eventDate = new Date(date); - if (isNaN(eventDate.getTime())) { - return NextResponse.json( - { error: "Format de date invalide" }, - { status: 400 } - ); - } - - // Valider les enums - if (!Object.values(EventType).includes(type)) { - return NextResponse.json( - { error: "Type d'événement invalide" }, - { status: 400 } - ); - } - - const event = await prisma.event.create({ - data: { - date: eventDate, - name, - description, - type: type as EventType, - room: room || null, - time: time || null, - maxPlaces: maxPlaces ? parseInt(maxPlaces) : null, - }, + const event = await eventService.validateAndCreateEvent({ + date, + name, + description, + type, + room, + time, + maxPlaces, }); return NextResponse.json(event); } catch (error) { console.error("Error creating event:", error); + + if (error instanceof ValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + return NextResponse.json( { error: "Erreur lors de la création de l'événement" }, { status: 500 } diff --git a/app/api/admin/feedback/route.ts b/app/api/admin/feedback/route.ts index 938351d..3449bf9 100644 --- a/app/api/admin/feedback/route.ts +++ b/app/api/admin/feedback/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; +import { eventFeedbackService } from "@/services/events/event-feedback.service"; import { Role } from "@/prisma/generated/prisma/client"; export async function GET() { @@ -15,72 +15,14 @@ export async function GET() { } // Récupérer tous les feedbacks avec les détails de l'événement et de l'utilisateur - const feedbacks = await prisma.eventFeedback.findMany({ - include: { - event: { - select: { - id: true, - name: true, - date: true, - type: true, - }, - }, - user: { - select: { - id: true, - username: true, - email: true, - }, - }, - }, - orderBy: { - createdAt: "desc", - }, - }); + const feedbacks = await eventFeedbackService.getAllFeedbacks(); // Calculer les statistiques par événement - const eventStats = await prisma.eventFeedback.groupBy({ - by: ["eventId"], - _avg: { - rating: true, - }, - _count: { - id: true, - }, - }); - - // Récupérer les détails des événements pour les stats - const eventIds = eventStats.map((stat) => stat.eventId); - const events = await prisma.event.findMany({ - where: { - id: { - in: eventIds, - }, - }, - select: { - id: true, - name: true, - date: true, - type: true, - }, - }); - - // Combiner les stats avec les détails des événements - const statsWithDetails = eventStats.map((stat) => { - const event = events.find((e) => e.id === stat.eventId); - return { - eventId: stat.eventId, - eventName: event?.name || "Événement supprimé", - eventDate: event?.date || null, - eventType: event?.type || null, - averageRating: stat._avg.rating || 0, - feedbackCount: stat._count.id, - }; - }); + const statistics = await eventFeedbackService.getFeedbackStatistics(); return NextResponse.json({ feedbacks, - statistics: statsWithDetails, + statistics, }); } catch (error) { console.error("Error fetching feedbacks:", error); diff --git a/app/api/admin/preferences/route.ts b/app/api/admin/preferences/route.ts index 58d8c05..c7cd9e8 100644 --- a/app/api/admin/preferences/route.ts +++ b/app/api/admin/preferences/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; +import { sitePreferencesService } from "@/services/preferences/site-preferences.service"; import { Role } from "@/prisma/generated/prisma/client"; export async function GET() { @@ -11,22 +11,8 @@ export async function GET() { return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); } - // Récupérer les préférences globales du site - let sitePreferences = await prisma.sitePreferences.findUnique({ - where: { id: "global" }, - }); - - // Si elles n'existent pas, créer une entrée par défaut - if (!sitePreferences) { - sitePreferences = await prisma.sitePreferences.create({ - data: { - id: "global", - homeBackground: null, - eventsBackground: null, - leaderboardBackground: null, - }, - }); - } + // Récupérer les préférences globales du site (ou créer si elles n'existent pas) + const sitePreferences = await sitePreferencesService.getOrCreateSitePreferences(); return NextResponse.json(sitePreferences); } catch (error) { @@ -49,26 +35,10 @@ export async function PUT(request: Request) { const body = await request.json(); const { homeBackground, eventsBackground, leaderboardBackground } = body; - const preferences = await prisma.sitePreferences.upsert({ - where: { id: "global" }, - update: { - homeBackground: - homeBackground === "" ? null : (homeBackground ?? undefined), - eventsBackground: - eventsBackground === "" ? null : (eventsBackground ?? undefined), - leaderboardBackground: - leaderboardBackground === "" - ? null - : (leaderboardBackground ?? undefined), - }, - create: { - id: "global", - homeBackground: homeBackground === "" ? null : (homeBackground ?? null), - eventsBackground: - eventsBackground === "" ? null : (eventsBackground ?? null), - leaderboardBackground: - leaderboardBackground === "" ? null : (leaderboardBackground ?? null), - }, + const preferences = await sitePreferencesService.updateSitePreferences({ + homeBackground, + eventsBackground, + leaderboardBackground, }); return NextResponse.json(preferences); diff --git a/app/api/admin/users/[id]/route.ts b/app/api/admin/users/[id]/route.ts index 48b7a93..905bfa6 100644 --- a/app/api/admin/users/[id]/route.ts +++ b/app/api/admin/users/[id]/route.ts @@ -1,7 +1,13 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; +import { userService } from "@/services/users/user.service"; +import { userStatsService } from "@/services/users/user-stats.service"; import { Role } from "@/prisma/generated/prisma/client"; +import { + ValidationError, + NotFoundError, + ConflictError, +} from "@/services/errors"; export async function PUT( request: Request, @@ -18,157 +24,26 @@ export async function PUT( const body = await request.json(); const { username, avatar, hpDelta, xpDelta, score, level, role } = body; - // Récupérer l'utilisateur actuel - const user = await prisma.user.findUnique({ - where: { id }, - }); - - if (!user) { - return NextResponse.json( - { error: "Utilisateur non trouvé" }, - { status: 404 } - ); - } - - // Calculer les nouvelles valeurs - let newHp = user.hp; - let newXp = user.xp; - let newLevel = user.level; - let newMaxXp = user.maxXp; - - // Appliquer les changements de HP - if (hpDelta !== undefined) { - newHp = Math.max(0, Math.min(user.maxHp, user.hp + hpDelta)); - } - - // Appliquer les changements de XP - if (xpDelta !== undefined) { - newXp = user.xp + xpDelta; - newLevel = user.level; - newMaxXp = user.maxXp; - - // Gérer le niveau up si nécessaire (quand on ajoute de l'XP) - if (newXp >= newMaxXp && newXp > 0) { - while (newXp >= newMaxXp) { - newXp -= newMaxXp; - newLevel += 1; - // Augmenter le maxXp pour le prochain niveau (formule simple) - newMaxXp = Math.floor(newMaxXp * 1.2); - } - } - - // Gérer le niveau down si nécessaire (quand on enlève de l'XP) - if (newXp < 0 && newLevel > 1) { - while (newXp < 0 && newLevel > 1) { - newLevel -= 1; - // Calculer le maxXp du niveau précédent - newMaxXp = Math.floor(newMaxXp / 1.2); - newXp += newMaxXp; - } - // S'assurer que l'XP ne peut pas être négative - newXp = Math.max(0, newXp); - } - - // S'assurer que le niveau minimum est 1 - if (newLevel < 1) { - newLevel = 1; - newXp = 0; - } - } - - // Appliquer les changements directs (username, avatar, score, level, role) - const updateData: { - hp: number; - xp: number; - level: number; - maxXp: number; - username?: string; - avatar?: string | null; - score?: number; - role?: Role; - } = { - hp: newHp, - xp: newXp, - level: newLevel, - maxXp: newMaxXp, - }; - - // Validation et mise à jour du username + // Valider username si fourni if (username !== undefined) { - if (typeof username !== "string" || username.trim().length === 0) { - return NextResponse.json( - { error: "Le nom d'utilisateur ne peut pas être vide" }, - { status: 400 } - ); - } - - if (username.length < 3 || username.length > 20) { - return NextResponse.json( - { - error: - "Le nom d'utilisateur doit contenir entre 3 et 20 caractères", - }, - { status: 400 } - ); - } - - // Vérifier si le username est déjà pris par un autre utilisateur - const existingUser = await prisma.user.findFirst({ - where: { - username: username.trim(), - NOT: { id }, - }, - }); - - if (existingUser) { - return NextResponse.json( - { error: "Ce nom d'utilisateur est déjà pris" }, - { status: 400 } - ); - } - - updateData.username = username.trim(); - } - - // Mise à jour de l'avatar - if (avatar !== undefined) { - updateData.avatar = avatar || null; - } - - if (score !== undefined) { - updateData.score = Math.max(0, score); - } - - if (level !== undefined) { - // Si le niveau est modifié directement, utiliser cette valeur - const targetLevel = Math.max(1, level); - updateData.level = targetLevel; - - // Recalculer le maxXp pour le nouveau niveau - // Formule: maxXp = 5000 * (1.2 ^ (level - 1)) - let calculatedMaxXp = 5000; - for (let i = 1; i < targetLevel; i++) { - calculatedMaxXp = Math.floor(calculatedMaxXp * 1.2); - } - updateData.maxXp = calculatedMaxXp; - - // Réinitialiser l'XP si le niveau change directement (sauf si on modifie aussi l'XP) - if (targetLevel !== user.level && xpDelta === undefined) { - updateData.xp = 0; + try { + await userService.validateAndUpdateUserProfile(id, { username }); + } catch (error) { + if ( + error instanceof ValidationError || + error instanceof ConflictError + ) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + throw error; } } - if (role !== undefined) { - if (role === "ADMIN" || role === "USER") { - updateData.role = role as Role; - } - } - - // Mettre à jour l'utilisateur - const updatedUser = await prisma.user.update({ - where: { id }, - data: updateData, - select: { + // Mettre à jour stats et profil + const updatedUser = await userStatsService.updateUserStatsAndProfile( + id, + { username, avatar, hpDelta, xpDelta, score, level, role }, + { id: true, username: true, email: true, @@ -180,8 +55,8 @@ export async function PUT( xp: true, maxXp: true, avatar: true, - }, - }); + } + ); return NextResponse.json(updatedUser); } catch (error) { @@ -206,34 +81,19 @@ export async function DELETE( const { id } = await params; - // Vérifier que l'utilisateur existe - const user = await prisma.user.findUnique({ - where: { id }, - }); - - if (!user) { - return NextResponse.json( - { error: "Utilisateur non trouvé" }, - { status: 404 } - ); - } - - // Empêcher la suppression de soi-même - if (user.id === session.user.id) { - return NextResponse.json( - { error: "Vous ne pouvez pas supprimer votre propre compte" }, - { status: 400 } - ); - } - - // Supprimer l'utilisateur (les relations seront supprimées en cascade) - await prisma.user.delete({ - where: { id }, - }); + await userService.validateAndDeleteUser(id, session.user.id); return NextResponse.json({ success: true }); } catch (error) { console.error("Error deleting user:", error); + + if (error instanceof ValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + if (error instanceof NotFoundError) { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + return NextResponse.json( { error: "Erreur lors de la suppression de l'utilisateur" }, { status: 500 } diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 5fbc5f9..8e09cbe 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; +import { userService } from "@/services/users/user.service"; import { Role } from "@/prisma/generated/prisma/client"; export async function GET() { @@ -12,7 +12,10 @@ export async function GET() { } // Récupérer tous les utilisateurs avec leurs stats - const users = await prisma.user.findMany({ + const users = await userService.getAllUsers({ + orderBy: { + score: "desc", + }, select: { id: true, username: true, @@ -27,9 +30,6 @@ export async function GET() { avatar: true, createdAt: true, }, - orderBy: { - score: "desc", - }, }); return NextResponse.json(users); diff --git a/app/api/events/[id]/register/route.ts b/app/api/events/[id]/register/route.ts index ac57240..3ef9233 100644 --- a/app/api/events/[id]/register/route.ts +++ b/app/api/events/[id]/register/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; -import { calculateEventStatus } from "@/lib/eventStatus"; +import { eventRegistrationService } from "@/services/events/event-registration.service"; +import { + ValidationError, + NotFoundError, + ConflictError, +} from "@/services/errors"; export async function POST( request: Request, @@ -19,50 +23,10 @@ export async function POST( const { id: eventId } = await params; - // Vérifier si l'événement existe - const event = await prisma.event.findUnique({ - where: { id: eventId }, - }); - - if (!event) { - return NextResponse.json( - { error: "Événement introuvable" }, - { status: 404 } - ); - } - - const eventStatus = calculateEventStatus(event.date); - if (eventStatus !== "UPCOMING") { - return NextResponse.json( - { error: "Vous ne pouvez vous inscrire qu'aux événements à venir" }, - { status: 400 } - ); - } - - // Vérifier si l'utilisateur est déjà inscrit - const existingRegistration = await prisma.eventRegistration.findUnique({ - where: { - userId_eventId: { - userId: session.user.id, - eventId: eventId, - }, - }, - }); - - if (existingRegistration) { - return NextResponse.json( - { error: "Vous êtes déjà inscrit à cet événement" }, - { status: 400 } - ); - } - - // Créer l'inscription - const registration = await prisma.eventRegistration.create({ - data: { - userId: session.user.id, - eventId: eventId, - }, - }); + const registration = await eventRegistrationService.validateAndRegisterUser( + session.user.id, + eventId + ); return NextResponse.json( { message: "Inscription réussie", registration }, @@ -70,6 +34,17 @@ export async function POST( ); } catch (error) { console.error("Registration error:", error); + + if ( + error instanceof ValidationError || + error instanceof ConflictError + ) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + if (error instanceof NotFoundError) { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + return NextResponse.json( { error: "Une erreur est survenue lors de l'inscription" }, { status: 500 } @@ -94,12 +69,10 @@ export async function DELETE( const { id: eventId } = await params; // Supprimer l'inscription - await prisma.eventRegistration.deleteMany({ - where: { - userId: session.user.id, - eventId: eventId, - }, - }); + await eventRegistrationService.unregisterUserFromEvent( + session.user.id, + eventId + ); return NextResponse.json({ message: "Inscription annulée" }); } catch (error) { @@ -124,16 +97,12 @@ export async function GET( const { id: eventId } = await params; - const registration = await prisma.eventRegistration.findUnique({ - where: { - userId_eventId: { - userId: session.user.id, - eventId: eventId, - }, - }, - }); + const isRegistered = await eventRegistrationService.checkUserRegistration( + session.user.id, + eventId + ); - return NextResponse.json({ registered: !!registration }); + return NextResponse.json({ registered: isRegistered }); } catch (error) { console.error("Check registration error:", error); return NextResponse.json({ registered: false }); diff --git a/app/api/events/[id]/route.ts b/app/api/events/[id]/route.ts index 1adbe7d..4352469 100644 --- a/app/api/events/[id]/route.ts +++ b/app/api/events/[id]/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; +import { eventService } from "@/services/events/event.service"; export async function GET( request: Request, @@ -8,9 +8,7 @@ export async function GET( try { const { id } = await params; - const event = await prisma.event.findUnique({ - where: { id }, - }); + const event = await eventService.getEventById(id); if (!event) { return NextResponse.json( diff --git a/app/api/events/route.ts b/app/api/events/route.ts index 533b170..9f0b90d 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -1,12 +1,10 @@ import { NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; +import { eventService } from "@/services/events/event.service"; export async function GET() { try { - const events = await prisma.event.findMany({ - orderBy: { - date: "asc", - }, + const events = await eventService.getAllEvents({ + orderBy: { date: "asc" }, }); return NextResponse.json(events); diff --git a/app/api/feedback/[eventId]/route.ts b/app/api/feedback/[eventId]/route.ts index 150b622..3c090ec 100644 --- a/app/api/feedback/[eventId]/route.ts +++ b/app/api/feedback/[eventId]/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; +import { eventFeedbackService } from "@/services/events/event-feedback.service"; +import { + ValidationError, + NotFoundError, +} from "@/services/errors"; export async function POST( request: Request, @@ -16,49 +20,23 @@ export async function POST( const body = await request.json(); const { rating, comment } = body; - // Valider la note (1-5) - if (!rating || rating < 1 || rating > 5) { - return NextResponse.json( - { error: "La note doit être entre 1 et 5" }, - { status: 400 } - ); - } - - // Vérifier que l'événement existe - const event = await prisma.event.findUnique({ - where: { id: eventId }, - }); - - if (!event) { - return NextResponse.json( - { error: "Événement introuvable" }, - { status: 404 } - ); - } - - // Créer ou mettre à jour le feedback (unique par utilisateur/événement) - const feedback = await prisma.eventFeedback.upsert({ - where: { - userId_eventId: { - userId: session.user.id, - eventId, - }, - }, - update: { - rating, - comment: comment || null, - }, - create: { - userId: session.user.id, - eventId, - rating, - comment: comment || null, - }, - }); + const feedback = await eventFeedbackService.validateAndCreateFeedback( + session.user.id, + eventId, + { rating, comment } + ); return NextResponse.json({ success: true, feedback }); } catch (error) { console.error("Error saving feedback:", error); + + if (error instanceof ValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + if (error instanceof NotFoundError) { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + return NextResponse.json( { error: "Erreur lors de l'enregistrement du feedback" }, { status: 500 } @@ -79,23 +57,10 @@ export async function GET( const { eventId } = await params; // Récupérer le feedback de l'utilisateur pour cet événement - const feedback = await prisma.eventFeedback.findUnique({ - where: { - userId_eventId: { - userId: session.user.id, - eventId, - }, - }, - include: { - event: { - select: { - id: true, - name: true, - date: true, - }, - }, - }, - }); + const feedback = await eventFeedbackService.getUserFeedback( + session.user.id, + eventId + ); return NextResponse.json({ feedback }); } catch (error) { diff --git a/app/api/leaderboard/route.ts b/app/api/leaderboard/route.ts index 4797739..3b895c7 100644 --- a/app/api/leaderboard/route.ts +++ b/app/api/leaderboard/route.ts @@ -1,49 +1,9 @@ import { NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; +import { userStatsService } from "@/services/users/user-stats.service"; export async function GET() { try { - const users = await prisma.user.findMany({ - orderBy: { - score: "desc", - }, - take: 10, - select: { - id: true, - username: true, - email: true, - score: true, - level: true, - avatar: true, - bio: true, - characterClass: true, - }, - }); - - const leaderboard = users.map( - ( - user: { - id: string; - username: string; - email: string; - score: number; - level: number; - avatar: string | null; - bio: string | null; - characterClass: string | null; - }, - index: number - ) => ({ - rank: index + 1, - username: user.username, - email: user.email, - score: user.score, - level: user.level, - avatar: user.avatar, - bio: user.bio, - characterClass: user.characterClass, - }) - ); + const leaderboard = await userStatsService.getLeaderboard(10); return NextResponse.json(leaderboard); } catch (error) { diff --git a/app/api/preferences/route.ts b/app/api/preferences/route.ts index e4f5920..72c88fa 100644 --- a/app/api/preferences/route.ts +++ b/app/api/preferences/route.ts @@ -1,12 +1,10 @@ import { NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; +import { sitePreferencesService } from "@/services/preferences/site-preferences.service"; export async function GET() { try { // Récupérer les préférences globales du site (pas besoin d'authentification) - let sitePreferences = await prisma.sitePreferences.findUnique({ - where: { id: "global" }, - }); + const sitePreferences = await sitePreferencesService.getSitePreferences(); // Si elles n'existent pas, retourner des valeurs par défaut if (!sitePreferences) { diff --git a/app/api/profile/password/route.ts b/app/api/profile/password/route.ts index 327ba28..b66e6ed 100644 --- a/app/api/profile/password/route.ts +++ b/app/api/profile/password/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; -import bcrypt from "bcryptjs"; +import { userService } from "@/services/users/user.service"; +import { ValidationError, NotFoundError } from "@/services/errors"; export async function PUT(request: Request) { try { @@ -14,68 +14,24 @@ export async function PUT(request: Request) { const body = await request.json(); const { currentPassword, newPassword, confirmPassword } = body; - // Validation - if (!currentPassword || !newPassword || !confirmPassword) { - return NextResponse.json( - { error: "Tous les champs sont requis" }, - { status: 400 } - ); - } - - if (newPassword.length < 6) { - return NextResponse.json( - { - error: "Le nouveau mot de passe doit contenir au moins 6 caractères", - }, - { status: 400 } - ); - } - - if (newPassword !== confirmPassword) { - return NextResponse.json( - { error: "Les mots de passe ne correspondent pas" }, - { status: 400 } - ); - } - - // Récupérer l'utilisateur avec le mot de passe - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { password: true }, - }); - - if (!user) { - return NextResponse.json( - { error: "Utilisateur non trouvé" }, - { status: 404 } - ); - } - - // Vérifier l'ancien mot de passe - const isPasswordValid = await bcrypt.compare( + await userService.validateAndUpdatePassword( + session.user.id, currentPassword, - user.password + newPassword, + confirmPassword ); - if (!isPasswordValid) { - return NextResponse.json( - { error: "Mot de passe actuel incorrect" }, - { status: 400 } - ); - } - - // Hasher le nouveau mot de passe - const hashedPassword = await bcrypt.hash(newPassword, 10); - - // Mettre à jour le mot de passe - await prisma.user.update({ - where: { id: session.user.id }, - data: { password: hashedPassword }, - }); - return NextResponse.json({ message: "Mot de passe modifié avec succès" }); } catch (error) { console.error("Error updating password:", error); + + if (error instanceof ValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + if (error instanceof NotFoundError) { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + return NextResponse.json( { error: "Erreur lors de la modification du mot de passe" }, { status: 500 } diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index c2ca04e..af85399 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; -import { CharacterClass } from "@/prisma/generated/prisma/enums"; +import { userService } from "@/services/users/user.service"; +import { + ValidationError, + ConflictError, + NotFoundError, +} from "@/services/errors"; export async function GET() { try { @@ -11,23 +15,20 @@ export async function GET() { return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); } - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { - id: true, - email: true, - username: true, - avatar: true, - bio: true, - characterClass: true, - hp: true, - maxHp: true, - xp: true, - maxXp: true, - level: true, - score: true, - createdAt: true, - }, + const user = await userService.getUserById(session.user.id, { + id: true, + email: true, + username: true, + avatar: true, + bio: true, + characterClass: true, + hp: true, + maxHp: true, + xp: true, + maxXp: true, + level: true, + score: true, + createdAt: true, }); if (!user) { @@ -58,103 +59,10 @@ export async function PUT(request: Request) { const body = await request.json(); const { username, avatar, bio, characterClass } = body; - // Validation - if (username !== undefined) { - if (typeof username !== "string" || username.trim().length === 0) { - return NextResponse.json( - { error: "Le nom d'utilisateur ne peut pas être vide" }, - { status: 400 } - ); - } - - if (username.length < 3 || username.length > 20) { - return NextResponse.json( - { - error: - "Le nom d'utilisateur doit contenir entre 3 et 20 caractères", - }, - { status: 400 } - ); - } - - // Vérifier si le username est déjà pris par un autre utilisateur - const existingUser = await prisma.user.findFirst({ - where: { - username: username.trim(), - NOT: { id: session.user.id }, - }, - }); - - if (existingUser) { - return NextResponse.json( - { error: "Ce nom d'utilisateur est déjà pris" }, - { status: 400 } - ); - } - } - - // Validation bio - if (bio !== undefined && bio !== null) { - if (typeof bio !== "string") { - return NextResponse.json( - { error: "La bio doit être une chaîne de caractères" }, - { status: 400 } - ); - } - if (bio.length > 500) { - return NextResponse.json( - { error: "La bio ne peut pas dépasser 500 caractères" }, - { status: 400 } - ); - } - } - - // Validation characterClass - const validClasses = [ - "WARRIOR", - "MAGE", - "ROGUE", - "RANGER", - "PALADIN", - "ENGINEER", - "MERCHANT", - "SCHOLAR", - "BERSERKER", - "NECROMANCER", - ]; - if (characterClass !== undefined && characterClass !== null) { - if (!validClasses.includes(characterClass)) { - return NextResponse.json( - { error: "Classe de personnage invalide" }, - { status: 400 } - ); - } - } - - // Mettre à jour l'utilisateur - const updateData: { - username?: string; - avatar?: string | null; - bio?: string | null; - characterClass?: CharacterClass | null; - } = {}; - if (username !== undefined) { - updateData.username = username.trim(); - } - if (avatar !== undefined) { - updateData.avatar = avatar || null; - } - if (bio !== undefined) { - updateData.bio = bio === null ? null : bio.trim() || null; - } - if (characterClass !== undefined) { - updateData.characterClass = (characterClass as CharacterClass) || null; - } - - const updatedUser = await prisma.user.update({ - where: { id: session.user.id }, - data: updateData, - select: { + const updatedUser = await userService.validateAndUpdateUserProfile( + session.user.id, + { username, avatar, bio, characterClass }, + { id: true, email: true, username: true, @@ -167,12 +75,17 @@ export async function PUT(request: Request) { maxXp: true, level: true, score: true, - }, - }); + } + ); return NextResponse.json(updatedUser); } catch (error) { console.error("Error updating profile:", error); + + if (error instanceof ValidationError || error instanceof ConflictError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + return NextResponse.json( { error: "Erreur lors de la mise à jour du profil" }, { status: 500 } diff --git a/app/api/register/complete/route.ts b/app/api/register/complete/route.ts index 5bce068..e0350b4 100644 --- a/app/api/register/complete/route.ts +++ b/app/api/register/complete/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; -import { CharacterClass } from "@/prisma/generated/prisma/enums"; +import { userService } from "@/services/users/user.service"; +import { + ValidationError, + NotFoundError, + ConflictError, +} from "@/services/errors"; export async function POST(request: Request) { try { @@ -14,139 +18,10 @@ export async function POST(request: Request) { ); } - // Vérifier que l'utilisateur existe et a été créé récemment (dans les 10 dernières minutes) - const user = await prisma.user.findUnique({ - where: { id: userId }, - }); - - if (!user) { - return NextResponse.json( - { error: "Utilisateur non trouvé" }, - { status: 404 } - ); - } - - // Vérifier que le compte a été créé récemment (dans les 10 dernières minutes) - const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); - if (user.createdAt < tenMinutesAgo) { - return NextResponse.json( - { error: "Temps écoulé pour finaliser l'inscription" }, - { status: 400 } - ); - } - - // Validation username - if (username !== undefined) { - if (typeof username !== "string" || username.trim().length === 0) { - return NextResponse.json( - { error: "Le nom d'utilisateur ne peut pas être vide" }, - { status: 400 } - ); - } - - if (username.length < 3 || username.length > 20) { - return NextResponse.json( - { - error: - "Le nom d'utilisateur doit contenir entre 3 et 20 caractères", - }, - { status: 400 } - ); - } - - // Vérifier si le username est déjà pris par un autre utilisateur - const existingUser = await prisma.user.findFirst({ - where: { - username: username.trim(), - NOT: { id: userId }, - }, - }); - - if (existingUser) { - return NextResponse.json( - { error: "Ce nom d'utilisateur est déjà pris" }, - { status: 400 } - ); - } - } - - // Validation bio - if (bio !== undefined && bio !== null) { - if (typeof bio !== "string") { - return NextResponse.json( - { error: "La bio doit être une chaîne de caractères" }, - { status: 400 } - ); - } - if (bio.length > 500) { - return NextResponse.json( - { error: "La bio ne peut pas dépasser 500 caractères" }, - { status: 400 } - ); - } - } - - // Validation characterClass - const validClasses = [ - "WARRIOR", - "MAGE", - "ROGUE", - "RANGER", - "PALADIN", - "ENGINEER", - "MERCHANT", - "SCHOLAR", - "BERSERKER", - "NECROMANCER", - ]; - if (characterClass !== undefined && characterClass !== null) { - if (!validClasses.includes(characterClass)) { - return NextResponse.json( - { error: "Classe de personnage invalide" }, - { status: 400 } - ); - } - } - - // Mettre à jour l'utilisateur - const updateData: { - username?: string; - avatar?: string | null; - bio?: string | null; - characterClass?: CharacterClass | null; - } = {}; - - if (username !== undefined) { - updateData.username = username.trim(); - } - if (avatar !== undefined) { - updateData.avatar = avatar || null; - } - if (bio !== undefined) { - if (bio === null || bio === "") { - updateData.bio = null; - } else if (typeof bio === "string") { - updateData.bio = bio.trim() || null; - } else { - updateData.bio = null; - } - } - if (characterClass !== undefined) { - updateData.characterClass = (characterClass as CharacterClass) || null; - } - - // Si aucun champ à mettre à jour, retourner succès quand même - if (Object.keys(updateData).length === 0) { - return NextResponse.json({ - message: "Profil finalisé avec succès", - userId: user.id, - }); - } - - const updatedUser = await prisma.user.update({ - where: { id: userId }, - data: updateData, - }); + const updatedUser = await userService.validateAndCompleteRegistration( + userId, + { username, avatar, bio, characterClass } + ); return NextResponse.json({ message: "Profil finalisé avec succès", @@ -154,11 +29,20 @@ export async function POST(request: Request) { }); } catch (error) { console.error("Error completing registration:", error); - const errorMessage = - error instanceof Error ? error.message : "Erreur inconnue"; + + if ( + error instanceof ValidationError || + error instanceof ConflictError + ) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + if (error instanceof NotFoundError) { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + return NextResponse.json( { - error: `Erreur lors de la finalisation de l'inscription: ${errorMessage}`, + error: `Erreur lors de la finalisation de l'inscription: ${error instanceof Error ? error.message : "Erreur inconnue"}`, }, { status: 500 } ); diff --git a/app/api/register/route.ts b/app/api/register/route.ts index 1649a57..9bdd353 100644 --- a/app/api/register/route.ts +++ b/app/api/register/route.ts @@ -1,73 +1,19 @@ import { NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; -import bcrypt from "bcryptjs"; +import { userService } from "@/services/users/user.service"; +import { ValidationError, ConflictError } from "@/services/errors"; export async function POST(request: Request) { try { const body = await request.json(); const { email, username, password, bio, characterClass, avatar } = body; - if (!email || !username || !password) { - return NextResponse.json( - { error: "Email, nom d'utilisateur et mot de passe sont requis" }, - { status: 400 } - ); - } - - if (password.length < 6) { - return NextResponse.json( - { error: "Le mot de passe doit contenir au moins 6 caractères" }, - { status: 400 } - ); - } - - // Valider characterClass si fourni - const validCharacterClasses = [ - "WARRIOR", - "MAGE", - "ROGUE", - "RANGER", - "PALADIN", - "ENGINEER", - "MERCHANT", - "SCHOLAR", - "BERSERKER", - "NECROMANCER", - ]; - if (characterClass && !validCharacterClasses.includes(characterClass)) { - return NextResponse.json( - { error: "Classe de personnage invalide" }, - { status: 400 } - ); - } - - // Vérifier si l'email existe déjà - const existingUser = await prisma.user.findFirst({ - where: { - OR: [{ email }, { username }], - }, - }); - - if (existingUser) { - return NextResponse.json( - { error: "Cet email ou nom d'utilisateur est déjà utilisé" }, - { status: 400 } - ); - } - - // Hasher le mot de passe - const hashedPassword = await bcrypt.hash(password, 10); - - // Créer l'utilisateur - const user = await prisma.user.create({ - data: { - email, - username, - password: hashedPassword, - bio: bio || null, - characterClass: characterClass || null, - avatar: avatar || null, - }, + const user = await userService.validateAndCreateUser({ + email, + username, + password, + bio, + characterClass, + avatar, }); return NextResponse.json( @@ -76,6 +22,11 @@ export async function POST(request: Request) { ); } catch (error) { console.error("Registration error:", error); + + if (error instanceof ValidationError || error instanceof ConflictError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + return NextResponse.json( { error: "Une erreur est survenue lors de l'inscription" }, { status: 500 } diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts index 55b3efe..201fae0 100644 --- a/app/api/users/[id]/route.ts +++ b/app/api/users/[id]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; +import { userService } from "@/services/users/user.service"; export async function GET( request: Request, @@ -19,19 +19,16 @@ export async function GET( return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); } - const user = await prisma.user.findUnique({ - where: { id }, - select: { - id: true, - username: true, - avatar: true, - hp: true, - maxHp: true, - xp: true, - maxXp: true, - level: true, - score: true, - }, + const user = await userService.getUserById(id, { + id: true, + username: true, + avatar: true, + hp: true, + maxHp: true, + xp: true, + maxXp: true, + level: true, + score: true, }); if (!user) { diff --git a/app/events/page.tsx b/app/events/page.tsx index 3770a73..9feab53 100644 --- a/app/events/page.tsx +++ b/app/events/page.tsx @@ -1,16 +1,15 @@ import NavigationWrapper from "@/components/NavigationWrapper"; import EventsPageSection from "@/components/EventsPageSection"; -import { prisma } from "@/lib/prisma"; +import { eventService } from "@/services/events/event.service"; +import { eventRegistrationService } from "@/services/events/event-registration.service"; import { getBackgroundImage } from "@/lib/preferences"; import { auth } from "@/lib/auth"; export const dynamic = "force-dynamic"; export default async function EventsPage() { - const events = await prisma.event.findMany({ - orderBy: { - date: "desc", - }, + const events = await eventService.getAllEvents({ + orderBy: { date: "desc" }, }); // Sérialiser les dates pour le client @@ -29,14 +28,8 @@ export default async function EventsPage() { if (session?.user?.id) { // Récupérer toutes les inscriptions (passées et à venir) pour permettre le feedback - const allRegistrations = await prisma.eventRegistration.findMany({ - where: { - userId: session.user.id, - }, - select: { - eventId: true, - }, - }); + const allRegistrations = + await eventRegistrationService.getUserRegistrations(session.user.id); allRegistrations.forEach((reg) => { initialRegistrations[reg.eventId] = true; diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index a01a8da..f65bd43 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -1,49 +1,12 @@ import NavigationWrapper from "@/components/NavigationWrapper"; import LeaderboardSection from "@/components/LeaderboardSection"; -import { prisma } from "@/lib/prisma"; +import { userStatsService } from "@/services/users/user-stats.service"; import { getBackgroundImage } from "@/lib/preferences"; export const dynamic = "force-dynamic"; -interface LeaderboardEntry { - rank: number; - username: string; - email: string; - score: number; - level: number; - avatar: string | null; - bio: string | null; - characterClass: string | null; -} - export default async function LeaderboardPage() { - const users = await prisma.user.findMany({ - orderBy: { - score: "desc", - }, - take: 10, - select: { - id: true, - username: true, - email: true, - score: true, - level: true, - avatar: true, - bio: true, - characterClass: true, - }, - }); - - const leaderboard: LeaderboardEntry[] = users.map((user, index) => ({ - rank: index + 1, - username: user.username, - email: user.email, - score: user.score, - level: user.level, - avatar: user.avatar, - bio: user.bio, - characterClass: user.characterClass, - })); + const leaderboard = await userStatsService.getLeaderboard(10); const backgroundImage = await getBackgroundImage( "leaderboard", diff --git a/app/page.tsx b/app/page.tsx index 38635e7..896fbd3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,18 +1,13 @@ import NavigationWrapper from "@/components/NavigationWrapper"; import HeroSection from "@/components/HeroSection"; import EventsSection from "@/components/EventsSection"; -import { prisma } from "@/lib/prisma"; +import { eventService } from "@/services/events/event.service"; import { getBackgroundImage } from "@/lib/preferences"; export const dynamic = "force-dynamic"; export default async function Home() { - const events = await prisma.event.findMany({ - orderBy: { - date: "asc", - }, - take: 3, - }); + const events = await eventService.getUpcomingEvents(3); // Convert Date objects to strings for serialization const serializedEvents = events.map((event) => ({ diff --git a/app/profile/page.tsx b/app/profile/page.tsx index e9b3bd5..0e6790c 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; +import { userService } from "@/services/users/user.service"; import { getBackgroundImage } from "@/lib/preferences"; import NavigationWrapper from "@/components/NavigationWrapper"; import ProfileForm from "@/components/ProfileForm"; @@ -12,23 +12,20 @@ export default async function ProfilePage() { redirect("/login"); } - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { - id: true, - email: true, - username: true, - avatar: true, - bio: true, - characterClass: true, - hp: true, - maxHp: true, - xp: true, - maxXp: true, - level: true, - score: true, - createdAt: true, - }, + const user = await userService.getUserById(session.user.id, { + id: true, + email: true, + username: true, + avatar: true, + bio: true, + characterClass: true, + hp: true, + maxHp: true, + xp: true, + maxXp: true, + level: true, + score: true, + createdAt: true, }); if (!user) { diff --git a/components/NavigationWrapper.tsx b/components/NavigationWrapper.tsx index 092599b..44ade67 100644 --- a/components/NavigationWrapper.tsx +++ b/components/NavigationWrapper.tsx @@ -1,5 +1,5 @@ import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; +import { userService } from "@/services/users/user.service"; import Navigation from "./Navigation"; interface UserData { @@ -19,17 +19,14 @@ export default async function NavigationWrapper() { const isAdmin = session?.user?.role === "ADMIN"; if (session?.user?.id) { - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { - username: true, - avatar: true, - hp: true, - maxHp: true, - xp: true, - maxXp: true, - level: true, - }, + const user = await userService.getUserById(session.user.id, { + username: true, + avatar: true, + hp: true, + maxHp: true, + xp: true, + maxXp: true, + level: true, }); if (user) { diff --git a/lib/auth.ts b/lib/auth.ts index 1f90d56..b752751 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,7 +1,6 @@ import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; -import { prisma } from "./prisma"; -import bcrypt from "bcryptjs"; +import { userService } from "@/services/users/user.service"; import type { Role } from "@/prisma/generated/prisma/client"; export const { handlers, signIn, signOut, auth } = NextAuth({ @@ -17,20 +16,12 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ return null; } - const user = await prisma.user.findUnique({ - where: { email: credentials.email as string }, - }); - - if (!user) { - return null; - } - - const isPasswordValid = await bcrypt.compare( - credentials.password as string, - user.password + const user = await userService.verifyCredentials( + credentials.email as string, + credentials.password as string ); - if (!isPasswordValid) { + if (!user) { return null; } diff --git a/lib/preferences.ts b/lib/preferences.ts index 7a822ac..cf4403a 100644 --- a/lib/preferences.ts +++ b/lib/preferences.ts @@ -1,27 +1,8 @@ -import { prisma } from "@/lib/prisma"; -import { normalizeBackgroundUrl } from "@/lib/avatars"; +import { sitePreferencesService } from "@/services/preferences/site-preferences.service"; export async function getBackgroundImage( page: "home" | "events" | "leaderboard", defaultImage: string ): Promise { - try { - const sitePreferences = await prisma.sitePreferences.findUnique({ - where: { id: "global" }, - }); - - if (!sitePreferences) { - return defaultImage; - } - - const imageKey = `${page}Background` as keyof typeof sitePreferences; - const customImage = sitePreferences[imageKey]; - - const imageUrl = (customImage as string | null) || defaultImage; - // Normaliser l'URL pour utiliser l'API si nécessaire - return normalizeBackgroundUrl(imageUrl) || defaultImage; - } catch (error) { - console.error("Error fetching background image:", error); - return defaultImage; - } + return sitePreferencesService.getBackgroundImage(page, defaultImage); } diff --git a/public/uploads/avatars/avatar-cmj1i0cs200005opouaz6vlu5-1765552685034-Sans titre.png b/public/uploads/avatars/avatar-cmj1i0cs200005opouaz6vlu5-1765552685034-Sans titre.png new file mode 100644 index 0000000..58b2143 Binary files /dev/null and b/public/uploads/avatars/avatar-cmj1i0cs200005opouaz6vlu5-1765552685034-Sans titre.png differ diff --git a/services/database.ts b/services/database.ts new file mode 100644 index 0000000..b15fd54 --- /dev/null +++ b/services/database.ts @@ -0,0 +1,7 @@ +/** + * Database client export + * Réexport du client Prisma depuis lib/prisma.ts + * Tous les services doivent importer depuis ici, pas directement depuis lib/prisma.ts + */ +export { prisma } from "@/lib/prisma"; + diff --git a/services/errors.ts b/services/errors.ts new file mode 100644 index 0000000..9d8e087 --- /dev/null +++ b/services/errors.ts @@ -0,0 +1,31 @@ +/** + * Erreurs métier personnalisées + */ +export class BusinessError extends Error { + constructor(message: string, public code?: string) { + super(message); + this.name = "BusinessError"; + } +} + +export class ValidationError extends BusinessError { + constructor(message: string, public field?: string) { + super(message, "VALIDATION_ERROR"); + this.name = "ValidationError"; + } +} + +export class NotFoundError extends BusinessError { + constructor(resource: string) { + super(`${resource} non trouvé`, "NOT_FOUND"); + this.name = "NotFoundError"; + } +} + +export class ConflictError extends BusinessError { + constructor(message: string) { + super(message, "CONFLICT"); + this.name = "ConflictError"; + } +} + diff --git a/services/events/event-feedback.service.ts b/services/events/event-feedback.service.ts new file mode 100644 index 0000000..635cf0f --- /dev/null +++ b/services/events/event-feedback.service.ts @@ -0,0 +1,179 @@ +import { prisma } from "../database"; +import type { EventFeedback, Prisma } from "@/prisma/generated/prisma/client"; +import { ValidationError, NotFoundError } from "../errors"; +import { eventService } from "./event.service"; + +export interface CreateOrUpdateFeedbackInput { + rating: number; + comment?: string | null; +} + +export interface FeedbackStatistics { + eventId: string; + eventName: string; + eventDate: Date | null; + eventType: string | null; + averageRating: number; + feedbackCount: number; +} + +/** + * Service de gestion des feedbacks sur les événements + */ +export class EventFeedbackService { + /** + * Crée ou met à jour un feedback + */ + async createOrUpdateFeedback( + userId: string, + eventId: string, + data: CreateOrUpdateFeedbackInput + ): Promise { + return prisma.eventFeedback.upsert({ + where: { + userId_eventId: { + userId, + eventId, + }, + }, + update: { + rating: data.rating, + comment: data.comment || null, + }, + create: { + userId, + eventId, + rating: data.rating, + comment: data.comment || null, + }, + }); + } + + /** + * Récupère le feedback d'un utilisateur pour un événement + */ + async getUserFeedback( + userId: string, + eventId: string + ): Promise { + return prisma.eventFeedback.findUnique({ + where: { + userId_eventId: { + userId, + eventId, + }, + }, + include: { + event: { + select: { + id: true, + name: true, + date: true, + }, + }, + }, + }); + } + + /** + * Récupère tous les feedbacks (pour admin) + */ + async getAllFeedbacks(options?: { + include?: Prisma.EventFeedbackInclude; + orderBy?: Prisma.EventFeedbackOrderByWithRelationInput; + }): Promise { + return prisma.eventFeedback.findMany({ + include: options?.include || { + event: { + select: { + id: true, + name: true, + date: true, + type: true, + }, + }, + user: { + select: { + id: true, + username: true, + email: true, + }, + }, + }, + orderBy: options?.orderBy || { createdAt: "desc" }, + }); + } + + /** + * Récupère les statistiques de feedback par événement + */ + async getFeedbackStatistics(): Promise { + // Calculer les statistiques par événement + const eventStats = await prisma.eventFeedback.groupBy({ + by: ["eventId"], + _avg: { + rating: true, + }, + _count: { + id: true, + }, + }); + + // Récupérer les détails des événements pour les stats + const eventIds = eventStats.map((stat) => stat.eventId); + const events = await prisma.event.findMany({ + where: { + id: { + in: eventIds, + }, + }, + select: { + id: true, + name: true, + date: true, + type: true, + }, + }); + + // Combiner les stats avec les détails des événements + return eventStats.map((stat) => { + const event = events.find((e) => e.id === stat.eventId); + return { + eventId: stat.eventId, + eventName: event?.name || "Événement supprimé", + eventDate: event?.date || null, + eventType: event?.type || null, + averageRating: stat._avg.rating || 0, + feedbackCount: stat._count.id, + }; + }); + } + + /** + * Valide et crée/met à jour un feedback avec toutes les règles métier + */ + async validateAndCreateFeedback( + userId: string, + eventId: string, + data: { rating: number; comment?: string | null } + ): Promise { + // Valider la note (1-5) + if (!data.rating || data.rating < 1 || data.rating > 5) { + throw new ValidationError("La note doit être entre 1 et 5", "rating"); + } + + // Vérifier que l'événement existe + const event = await eventService.getEventById(eventId); + if (!event) { + throw new NotFoundError("Événement"); + } + + // Créer ou mettre à jour le feedback + return this.createOrUpdateFeedback(userId, eventId, { + rating: data.rating, + comment: data.comment || null, + }); + } +} + +export const eventFeedbackService = new EventFeedbackService(); diff --git a/services/events/event-registration.service.ts b/services/events/event-registration.service.ts new file mode 100644 index 0000000..aa85767 --- /dev/null +++ b/services/events/event-registration.service.ts @@ -0,0 +1,134 @@ +import { prisma } from "../database"; +import type { EventRegistration } from "@/prisma/generated/prisma/client"; +import { ValidationError, NotFoundError, ConflictError } from "../errors"; +import { eventService } from "./event.service"; +import { calculateEventStatus } from "@/lib/eventStatus"; + +/** + * Service de gestion des inscriptions aux événements + */ +export class EventRegistrationService { + /** + * Inscrit un utilisateur à un événement + */ + async registerUserToEvent( + userId: string, + eventId: string + ): Promise { + return prisma.eventRegistration.create({ + data: { + userId, + eventId, + }, + }); + } + + /** + * Désinscrit un utilisateur d'un événement + */ + async unregisterUserFromEvent( + userId: string, + eventId: string + ): Promise { + await prisma.eventRegistration.deleteMany({ + where: { + userId, + eventId, + }, + }); + } + + /** + * Vérifie si un utilisateur est inscrit à un événement + */ + async checkUserRegistration( + userId: string, + eventId: string + ): Promise { + const registration = await prisma.eventRegistration.findUnique({ + where: { + userId_eventId: { + userId, + eventId, + }, + }, + }); + return !!registration; + } + + /** + * Récupère l'inscription d'un utilisateur à un événement + */ + async getUserRegistration( + userId: string, + eventId: string + ): Promise { + return prisma.eventRegistration.findUnique({ + where: { + userId_eventId: { + userId, + eventId, + }, + }, + }); + } + + /** + * Récupère toutes les inscriptions d'un utilisateur + */ + async getUserRegistrations(userId: string): Promise { + return prisma.eventRegistration.findMany({ + where: { + userId, + }, + select: { + eventId: true, + }, + }); + } + + /** + * Récupère le nombre d'inscriptions pour un événement + */ + async getEventRegistrationsCount(eventId: string): Promise { + const count = await prisma.eventRegistration.count({ + where: { + eventId, + }, + }); + return count; + } + + /** + * Valide et inscrit un utilisateur à un événement avec toutes les règles métier + */ + async validateAndRegisterUser( + userId: string, + eventId: string + ): Promise { + // Vérifier que l'événement existe + const event = await eventService.getEventById(eventId); + if (!event) { + throw new NotFoundError("Événement"); + } + + // Vérifier que l'événement est à venir + const eventStatus = calculateEventStatus(event.date); + if (eventStatus !== "UPCOMING") { + throw new ValidationError( + "Vous ne pouvez vous inscrire qu'aux événements à venir" + ); + } + + // Vérifier si l'utilisateur est déjà inscrit + const isRegistered = await this.checkUserRegistration(userId, eventId); + if (isRegistered) { + throw new ConflictError("Vous êtes déjà inscrit à cet événement"); + } + + // Créer l'inscription + return this.registerUserToEvent(userId, eventId); + } +} + +export const eventRegistrationService = new EventRegistrationService(); diff --git a/services/events/event.service.ts b/services/events/event.service.ts new file mode 100644 index 0000000..d8a4582 --- /dev/null +++ b/services/events/event.service.ts @@ -0,0 +1,278 @@ +import { prisma } from "../database"; +import type { + Event, + EventType, + Prisma, +} from "@/prisma/generated/prisma/client"; +import { ValidationError, NotFoundError } from "../errors"; +import { calculateEventStatus } from "@/lib/eventStatus"; + +export interface CreateEventInput { + date: Date; + name: string; + description: string; + type: EventType; + room?: string | null; + time?: string | null; + maxPlaces?: number | null; +} + +export interface UpdateEventInput { + date?: Date; + name?: string; + description?: string; + type?: EventType; + room?: string | null; + time?: string | null; + maxPlaces?: number | null; +} + +export interface EventWithRegistrationsCount extends Event { + registrationsCount: number; +} + +/** + * Service de gestion des événements + */ +export class EventService { + /** + * Récupère tous les événements + */ + async getAllEvents(options?: { + orderBy?: Prisma.EventOrderByWithRelationInput; + take?: number; + }): Promise { + return prisma.event.findMany({ + orderBy: options?.orderBy || { date: "asc" }, + ...(options?.take && { take: options.take }), + }); + } + + /** + * Récupère un événement par son ID + */ + async getEventById(id: string): Promise { + return prisma.event.findUnique({ + where: { id }, + }); + } + + /** + * Récupère les événements à venir + */ + async getUpcomingEvents(limit: number = 3): Promise { + const now = new Date(); + return prisma.event.findMany({ + where: { + date: { + gte: now, + }, + }, + orderBy: { + date: "asc", + }, + take: limit, + }); + } + + /** + * Crée un nouvel événement + */ + async createEvent(data: CreateEventInput): Promise { + return prisma.event.create({ + data: { + date: data.date, + name: data.name, + description: data.description, + type: data.type, + room: data.room || null, + time: data.time || null, + maxPlaces: data.maxPlaces ? parseInt(String(data.maxPlaces)) : null, + }, + }); + } + + /** + * Met à jour un événement + */ + async updateEvent(id: string, data: UpdateEventInput): Promise { + const updateData: Prisma.EventUpdateInput = {}; + + if (data.date !== undefined) { + updateData.date = data.date; + } + if (data.name !== undefined) { + updateData.name = data.name; + } + if (data.description !== undefined) { + updateData.description = data.description; + } + if (data.type !== undefined) { + updateData.type = data.type; + } + if (data.room !== undefined) { + updateData.room = data.room || null; + } + if (data.time !== undefined) { + updateData.time = data.time || null; + } + if (data.maxPlaces !== undefined) { + updateData.maxPlaces = data.maxPlaces + ? parseInt(String(data.maxPlaces)) + : null; + } + + return prisma.event.update({ + where: { id }, + data: updateData, + }); + } + + /** + * Supprime un événement + */ + async deleteEvent(id: string): Promise { + await prisma.event.delete({ + where: { id }, + }); + } + + /** + * Récupère les événements avec le nombre d'inscriptions (pour admin) + */ + async getEventsWithRegistrationsCount(): Promise< + EventWithRegistrationsCount[] + > { + const events = await prisma.event.findMany({ + orderBy: { + date: "desc", + }, + include: { + _count: { + select: { + registrations: true, + }, + }, + }, + }); + + return events.map((event) => ({ + ...event, + registrationsCount: event._count.registrations, + })); + } + + /** + * Récupère les événements avec leur statut calculé (pour admin) + */ + async getEventsWithStatus(): Promise< + Array< + EventWithRegistrationsCount & { status: "UPCOMING" | "LIVE" | "PAST" } + > + > { + const events = await this.getEventsWithRegistrationsCount(); + return events.map((event) => ({ + ...event, + status: calculateEventStatus(event.date), + })); + } + + /** + * Valide et crée un événement avec toutes les règles métier + */ + async validateAndCreateEvent(data: { + date: string | Date; + name: string; + description: string; + type: string; + room?: string | null; + time?: string | null; + maxPlaces?: string | number | null; + }): Promise { + // Validation des champs requis + if (!data.date || !data.name || !data.description || !data.type) { + throw new ValidationError("Tous les champs sont requis"); + } + + // Convertir et valider la date + const eventDate = + typeof data.date === "string" ? new Date(data.date) : data.date; + if (isNaN(eventDate.getTime())) { + throw new ValidationError("Format de date invalide", "date"); + } + + // Valider le type d'événement + if (!Object.values(EventType).includes(data.type as EventType)) { + throw new ValidationError("Type d'événement invalide", "type"); + } + + // Créer l'événement + return this.createEvent({ + date: eventDate, + name: data.name, + description: data.description, + type: data.type as EventType, + room: data.room || null, + time: data.time || null, + maxPlaces: data.maxPlaces ? parseInt(String(data.maxPlaces)) : null, + }); + } + + /** + * Valide et met à jour un événement avec toutes les règles métier + */ + async validateAndUpdateEvent( + id: string, + data: { + date?: string | Date; + name?: string; + description?: string; + type?: string; + room?: string | null; + time?: string | null; + maxPlaces?: string | number | null; + } + ): Promise { + // Vérifier que l'événement existe + const existingEvent = await this.getEventById(id); + if (!existingEvent) { + throw new NotFoundError("Événement"); + } + + const updateData: UpdateEventInput = {}; + + // Valider et convertir la date si fournie + if (data.date !== undefined) { + const eventDate = + typeof data.date === "string" ? new Date(data.date) : data.date; + if (isNaN(eventDate.getTime())) { + throw new ValidationError("Format de date invalide", "date"); + } + updateData.date = eventDate; + } + + // Valider le type si fourni + if (data.type !== undefined) { + if (!Object.values(EventType).includes(data.type as EventType)) { + throw new ValidationError("Type d'événement invalide", "type"); + } + updateData.type = data.type as EventType; + } + + // Autres champs + if (data.name !== undefined) updateData.name = data.name; + if (data.description !== undefined) + updateData.description = data.description; + if (data.room !== undefined) updateData.room = data.room || null; + if (data.time !== undefined) updateData.time = data.time || null; + if (data.maxPlaces !== undefined) { + updateData.maxPlaces = data.maxPlaces + ? parseInt(String(data.maxPlaces)) + : null; + } + + return this.updateEvent(id, updateData); + } +} + +export const eventService = new EventService(); diff --git a/services/preferences/site-preferences.service.ts b/services/preferences/site-preferences.service.ts new file mode 100644 index 0000000..99e3e98 --- /dev/null +++ b/services/preferences/site-preferences.service.ts @@ -0,0 +1,111 @@ +import { prisma } from "../database"; +import { normalizeBackgroundUrl } from "@/lib/avatars"; +import type { SitePreferences } from "@/prisma/generated/prisma/client"; + +export interface UpdateSitePreferencesInput { + homeBackground?: string | null; + eventsBackground?: string | null; + leaderboardBackground?: string | null; +} + +/** + * Service de gestion des préférences globales du site + */ +export class SitePreferencesService { + /** + * Récupère les préférences du site + */ + async getSitePreferences(): Promise { + return prisma.sitePreferences.findUnique({ + where: { id: "global" }, + }); + } + + /** + * Récupère les préférences du site ou crée une entrée par défaut + */ + async getOrCreateSitePreferences(): Promise { + let sitePreferences = await prisma.sitePreferences.findUnique({ + where: { id: "global" }, + }); + + if (!sitePreferences) { + sitePreferences = await prisma.sitePreferences.create({ + data: { + id: "global", + homeBackground: null, + eventsBackground: null, + leaderboardBackground: null, + }, + }); + } + + return sitePreferences; + } + + /** + * Met à jour les préférences du site + */ + async updateSitePreferences( + data: UpdateSitePreferencesInput + ): Promise { + return prisma.sitePreferences.upsert({ + where: { id: "global" }, + update: { + homeBackground: + data.homeBackground === "" + ? null + : (data.homeBackground ?? undefined), + eventsBackground: + data.eventsBackground === "" + ? null + : (data.eventsBackground ?? undefined), + leaderboardBackground: + data.leaderboardBackground === "" + ? null + : (data.leaderboardBackground ?? undefined), + }, + create: { + id: "global", + homeBackground: + data.homeBackground === "" ? null : (data.homeBackground ?? null), + eventsBackground: + data.eventsBackground === "" ? null : (data.eventsBackground ?? null), + leaderboardBackground: + data.leaderboardBackground === "" + ? null + : (data.leaderboardBackground ?? null), + }, + }); + } + + /** + * Récupère l'image de fond pour une page donnée + */ + async getBackgroundImage( + page: "home" | "events" | "leaderboard", + defaultImage: string + ): Promise { + try { + const sitePreferences = await prisma.sitePreferences.findUnique({ + where: { id: "global" }, + }); + + if (!sitePreferences) { + return defaultImage; + } + + const imageKey = `${page}Background` as keyof typeof sitePreferences; + const customImage = sitePreferences[imageKey]; + + const imageUrl = (customImage as string | null) || defaultImage; + // Normaliser l'URL pour utiliser l'API si nécessaire + return normalizeBackgroundUrl(imageUrl) || defaultImage; + } catch (error) { + console.error("Error fetching background image:", error); + return defaultImage; + } + } +} + +export const sitePreferencesService = new SitePreferencesService(); diff --git a/services/users/user-stats.service.ts b/services/users/user-stats.service.ts new file mode 100644 index 0000000..0e8cc94 --- /dev/null +++ b/services/users/user-stats.service.ts @@ -0,0 +1,251 @@ +import { prisma } from "../database"; +import type { User, Role, Prisma } from "@/prisma/generated/prisma/client"; +import { NotFoundError } from "../errors"; +import { userService } from "./user.service"; + +export interface UpdateUserStatsInput { + hpDelta?: number; + xpDelta?: number; + score?: number; + level?: number; + role?: Role; +} + +export interface LeaderboardEntry { + rank: number; + username: string; + email: string; + score: number; + level: number; + avatar: string | null; + bio: string | null; + characterClass: string | null; +} + +/** + * Service de gestion des statistiques utilisateur + */ +export class UserStatsService { + /** + * Récupère le leaderboard + */ + async getLeaderboard(limit: number = 10): Promise { + const users = await prisma.user.findMany({ + orderBy: { + score: "desc", + }, + take: limit, + select: { + id: true, + username: true, + email: true, + score: true, + level: true, + avatar: true, + bio: true, + characterClass: true, + }, + }); + + return users.map((user, index) => ({ + rank: index + 1, + username: user.username, + email: user.email, + score: user.score, + level: user.level, + avatar: user.avatar, + bio: user.bio, + characterClass: user.characterClass, + })); + } + + /** + * Met à jour les statistiques d'un utilisateur + */ + async updateUserStats( + id: string, + stats: UpdateUserStatsInput, + select?: Prisma.UserSelect + ): Promise { + // Récupérer l'utilisateur actuel + const user = await prisma.user.findUnique({ + where: { id }, + }); + + if (!user) { + throw new Error("Utilisateur non trouvé"); + } + + // Calculer les nouvelles valeurs + let newHp = user.hp; + let newXp = user.xp; + let newLevel = user.level; + let newMaxXp = user.maxXp; + + // Appliquer les changements de HP + if (stats.hpDelta !== undefined) { + newHp = Math.max(0, Math.min(user.maxHp, user.hp + stats.hpDelta)); + } + + // Appliquer les changements de XP + if (stats.xpDelta !== undefined) { + newXp = user.xp + stats.xpDelta; + newLevel = user.level; + newMaxXp = user.maxXp; + + // Gérer le niveau up si nécessaire (quand on ajoute de l'XP) + if (newXp >= newMaxXp && newXp > 0) { + while (newXp >= newMaxXp) { + newXp -= newMaxXp; + newLevel += 1; + // Augmenter le maxXp pour le prochain niveau (formule simple) + newMaxXp = Math.floor(newMaxXp * 1.2); + } + } + + // Gérer le niveau down si nécessaire (quand on enlève de l'XP) + if (newXp < 0 && newLevel > 1) { + while (newXp < 0 && newLevel > 1) { + newLevel -= 1; + // Calculer le maxXp du niveau précédent + newMaxXp = Math.floor(newMaxXp / 1.2); + newXp += newMaxXp; + } + // S'assurer que l'XP ne peut pas être négative + newXp = Math.max(0, newXp); + } + + // S'assurer que le niveau minimum est 1 + if (newLevel < 1) { + newLevel = 1; + newXp = 0; + } + } + + // Construire les données de mise à jour + const updateData: Prisma.UserUpdateInput = { + hp: newHp, + xp: newXp, + level: newLevel, + maxXp: newMaxXp, + }; + + // Appliquer les changements directs + if (stats.score !== undefined) { + updateData.score = Math.max(0, stats.score); + } + + if (stats.level !== undefined) { + // Si le niveau est modifié directement, utiliser cette valeur + const targetLevel = Math.max(1, stats.level); + updateData.level = targetLevel; + + // Recalculer le maxXp pour le nouveau niveau + // Formule: maxXp = 5000 * (1.2 ^ (level - 1)) + let calculatedMaxXp = 5000; + for (let i = 1; i < targetLevel; i++) { + calculatedMaxXp = Math.floor(calculatedMaxXp * 1.2); + } + updateData.maxXp = calculatedMaxXp; + + // Réinitialiser l'XP si le niveau change directement (sauf si on modifie aussi l'XP) + if (targetLevel !== user.level && stats.xpDelta === undefined) { + updateData.xp = 0; + } + } + + if (stats.role !== undefined) { + if (stats.role === "ADMIN" || stats.role === "USER") { + updateData.role = stats.role; + } + } + + return prisma.user.update({ + where: { id }, + data: updateData, + select, + }); + } + + /** + * Met à jour les stats ET le profil d'un utilisateur (pour admin) + */ + async updateUserStatsAndProfile( + id: string, + data: { + username?: string; + avatar?: string | null; + hpDelta?: number; + xpDelta?: number; + score?: number; + level?: number; + role?: Role; + }, + select?: Prisma.UserSelect + ): Promise { + // Vérifier que l'utilisateur existe + const user = await userService.getUserById(id); + if (!user) { + throw new NotFoundError("Utilisateur"); + } + + const selectFields = select || { + id: true, + username: true, + email: true, + role: true, + score: true, + level: true, + hp: true, + maxHp: true, + xp: true, + maxXp: true, + avatar: true, + }; + + // Mettre à jour les stats si nécessaire + const hasStatsChanges = + data.hpDelta !== undefined || + data.xpDelta !== undefined || + data.score !== undefined || + data.level !== undefined || + data.role !== undefined; + + let updatedUser: User; + if (hasStatsChanges) { + updatedUser = await this.updateUserStats( + id, + { + hpDelta: data.hpDelta, + xpDelta: data.xpDelta, + score: data.score, + level: data.level, + role: data.role, + }, + selectFields + ); + } else { + updatedUser = await userService.getUserById(id, selectFields); + if (!updatedUser) { + throw new NotFoundError("Utilisateur"); + } + } + + // Mettre à jour username/avatar si nécessaire + if (data.username !== undefined || data.avatar !== undefined) { + updatedUser = await userService.updateUser( + id, + { + username: + data.username !== undefined ? data.username.trim() : undefined, + avatar: data.avatar, + }, + selectFields + ); + } + + return updatedUser; + } +} + +export const userStatsService = new UserStatsService(); diff --git a/services/users/user.service.ts b/services/users/user.service.ts new file mode 100644 index 0000000..07caa98 --- /dev/null +++ b/services/users/user.service.ts @@ -0,0 +1,515 @@ +import { prisma } from "../database"; +import bcrypt from "bcryptjs"; +import type { + User, + CharacterClass, + Role, + Prisma, +} from "@/prisma/generated/prisma/client"; +import { ValidationError, NotFoundError, ConflictError } from "../errors"; + +// Constantes de validation +const VALID_CHARACTER_CLASSES = [ + "WARRIOR", + "MAGE", + "ROGUE", + "RANGER", + "PALADIN", + "ENGINEER", + "MERCHANT", + "SCHOLAR", + "BERSERKER", + "NECROMANCER", +] as const; + +const USERNAME_MIN_LENGTH = 3; +const USERNAME_MAX_LENGTH = 20; +const BIO_MAX_LENGTH = 500; +const PASSWORD_MIN_LENGTH = 6; + +export interface CreateUserInput { + email: string; + username: string; + password: string; + bio?: string | null; + characterClass?: CharacterClass | null; + avatar?: string | null; +} + +export interface UpdateUserInput { + username?: string; + avatar?: string | null; + bio?: string | null; + characterClass?: CharacterClass | null; +} + +export interface UserSelect { + id?: boolean; + email?: boolean; + username?: boolean; + avatar?: boolean; + bio?: boolean; + characterClass?: boolean; + hp?: boolean; + maxHp?: boolean; + xp?: boolean; + maxXp?: boolean; + level?: boolean; + score?: boolean; + role?: boolean; + createdAt?: boolean; +} + +/** + * Service de gestion des utilisateurs + */ +export class UserService { + /** + * Récupère un utilisateur par son ID + */ + async getUserById( + id: string, + select?: Prisma.UserSelect + ): Promise { + return prisma.user.findUnique({ + where: { id }, + select, + }); + } + + /** + * Récupère un utilisateur par son email + */ + async getUserByEmail(email: string): Promise { + return prisma.user.findUnique({ + where: { email }, + }); + } + + /** + * Récupère un utilisateur par son username + */ + async getUserByUsername(username: string): Promise { + return prisma.user.findUnique({ + where: { username }, + }); + } + + /** + * Vérifie si un username est disponible + */ + async checkUsernameAvailability( + username: string, + excludeUserId?: string + ): Promise { + const existingUser = await prisma.user.findFirst({ + where: { + username: username.trim(), + ...(excludeUserId && { NOT: { id: excludeUserId } }), + }, + }); + return !existingUser; + } + + /** + * Vérifie si un email ou username existe déjà + */ + async checkEmailOrUsernameExists( + email: string, + username: string + ): Promise { + const existingUser = await prisma.user.findFirst({ + where: { + OR: [{ email }, { username }], + }, + }); + return !!existingUser; + } + + /** + * Crée un nouvel utilisateur + */ + async createUser(data: CreateUserInput): Promise { + // Hasher le mot de passe + const hashedPassword = await bcrypt.hash(data.password, 10); + + return prisma.user.create({ + data: { + email: data.email, + username: data.username, + password: hashedPassword, + bio: data.bio || null, + characterClass: data.characterClass || null, + avatar: data.avatar || null, + }, + }); + } + + /** + * Met à jour un utilisateur + */ + async updateUser( + id: string, + data: UpdateUserInput, + select?: Prisma.UserSelect + ): Promise { + const updateData: Prisma.UserUpdateInput = {}; + + if (data.username !== undefined) { + updateData.username = data.username.trim(); + } + if (data.avatar !== undefined) { + updateData.avatar = data.avatar || null; + } + if (data.bio !== undefined) { + updateData.bio = data.bio === null ? null : data.bio.trim() || null; + } + if (data.characterClass !== undefined) { + updateData.characterClass = data.characterClass || null; + } + + return prisma.user.update({ + where: { id }, + data: updateData, + select, + }); + } + + /** + * Met à jour le mot de passe d'un utilisateur + */ + async updateUserPassword( + id: string, + currentPassword: string, + newPassword: string + ): Promise { + // Récupérer l'utilisateur avec le mot de passe + const user = await prisma.user.findUnique({ + where: { id }, + select: { password: true }, + }); + + if (!user) { + throw new Error("Utilisateur non trouvé"); + } + + // Vérifier l'ancien mot de passe + const isPasswordValid = await bcrypt.compare( + currentPassword, + user.password + ); + + if (!isPasswordValid) { + throw new Error("Mot de passe actuel incorrect"); + } + + // Hasher le nouveau mot de passe + const hashedPassword = await bcrypt.hash(newPassword, 10); + + // Mettre à jour le mot de passe + await prisma.user.update({ + where: { id }, + data: { password: hashedPassword }, + }); + } + + /** + * Supprime un utilisateur + */ + async deleteUser(id: string): Promise { + await prisma.user.delete({ + where: { id }, + }); + } + + /** + * Récupère tous les utilisateurs (pour admin) + */ + async getAllUsers(options?: { + orderBy?: Prisma.UserOrderByWithRelationInput; + select?: Prisma.UserSelect; + }): Promise { + return prisma.user.findMany({ + orderBy: options?.orderBy || { score: "desc" }, + select: options?.select, + }); + } + + /** + * Vérifie les credentials pour l'authentification + */ + async verifyCredentials( + email: string, + password: string + ): Promise { + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + return null; + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + return null; + } + + return user; + } + + /** + * Vérifie si un compte a été créé récemment (dans les X minutes) + */ + async isAccountRecentlyCreated( + userId: string, + minutesAgo: number = 10 + ): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { createdAt: true }, + }); + + if (!user) { + return false; + } + + const timeLimit = new Date(Date.now() - minutesAgo * 60 * 1000); + return user.createdAt >= timeLimit; + } + + /** + * Valide et crée un utilisateur avec toutes les règles métier + */ + async validateAndCreateUser(data: { + email: string; + username: string; + password: string; + bio?: string | null; + characterClass?: string | null; + avatar?: string | null; + }): Promise { + // Validation des champs requis + if (!data.email || !data.username || !data.password) { + throw new ValidationError( + "Email, nom d'utilisateur et mot de passe sont requis" + ); + } + + // Validation du mot de passe + if (data.password.length < PASSWORD_MIN_LENGTH) { + throw new ValidationError( + `Le mot de passe doit contenir au moins ${PASSWORD_MIN_LENGTH} caractères`, + "password" + ); + } + + // Validation du characterClass + if ( + data.characterClass && + !VALID_CHARACTER_CLASSES.includes(data.characterClass as CharacterClass) + ) { + throw new ValidationError( + "Classe de personnage invalide", + "characterClass" + ); + } + + // Vérifier si l'email ou username existe déjà + const exists = await this.checkEmailOrUsernameExists( + data.email, + data.username + ); + if (exists) { + throw new ConflictError( + "Cet email ou nom d'utilisateur est déjà utilisé" + ); + } + + // Créer l'utilisateur + return this.createUser({ + email: data.email, + username: data.username, + password: data.password, + bio: data.bio || null, + characterClass: (data.characterClass as CharacterClass) || null, + avatar: data.avatar || null, + }); + } + + /** + * Valide et met à jour le profil utilisateur avec toutes les règles métier + */ + async validateAndUpdateUserProfile( + userId: string, + data: { + username?: string; + avatar?: string | null; + bio?: string | null; + characterClass?: string | null; + }, + select?: Prisma.UserSelect + ): Promise { + // Validation username + if (data.username !== undefined) { + if ( + typeof data.username !== "string" || + data.username.trim().length === 0 + ) { + throw new ValidationError( + "Le nom d'utilisateur ne peut pas être vide", + "username" + ); + } + + if ( + data.username.length < USERNAME_MIN_LENGTH || + data.username.length > USERNAME_MAX_LENGTH + ) { + throw new ValidationError( + `Le nom d'utilisateur doit contenir entre ${USERNAME_MIN_LENGTH} et ${USERNAME_MAX_LENGTH} caractères`, + "username" + ); + } + + // Vérifier si le username est déjà pris + const isAvailable = await this.checkUsernameAvailability( + data.username.trim(), + userId + ); + if (!isAvailable) { + throw new ConflictError("Ce nom d'utilisateur est déjà pris"); + } + } + + // Validation bio + if (data.bio !== undefined && data.bio !== null) { + if (typeof data.bio !== "string") { + throw new ValidationError( + "La bio doit être une chaîne de caractères", + "bio" + ); + } + if (data.bio.length > BIO_MAX_LENGTH) { + throw new ValidationError( + `La bio ne peut pas dépasser ${BIO_MAX_LENGTH} caractères`, + "bio" + ); + } + } + + // Validation characterClass + if ( + data.characterClass !== undefined && + data.characterClass !== null && + !VALID_CHARACTER_CLASSES.includes(data.characterClass as CharacterClass) + ) { + throw new ValidationError( + "Classe de personnage invalide", + "characterClass" + ); + } + + // Mettre à jour l'utilisateur + return this.updateUser( + userId, + { + username: + data.username !== undefined ? data.username.trim() : undefined, + avatar: data.avatar, + bio: data.bio, + characterClass: data.characterClass as CharacterClass | null, + }, + select + ); + } + + /** + * Valide et finalise l'inscription d'un utilisateur (avec vérification du temps) + */ + async validateAndCompleteRegistration( + userId: string, + data: { + username?: string; + avatar?: string | null; + bio?: string | null; + characterClass?: string | null; + } + ): Promise { + // Vérifier que l'utilisateur existe + const user = await this.getUserById(userId); + if (!user) { + throw new NotFoundError("Utilisateur"); + } + + // Vérifier que le compte a été créé récemment (dans les 10 dernières minutes) + const isRecent = await this.isAccountRecentlyCreated(userId, 10); + if (!isRecent) { + throw new ValidationError("Temps écoulé pour finaliser l'inscription"); + } + + // Valider et mettre à jour + return this.validateAndUpdateUserProfile(userId, data); + } + + /** + * Valide et met à jour le mot de passe avec toutes les règles métier + */ + async validateAndUpdatePassword( + userId: string, + currentPassword: string, + newPassword: string, + confirmPassword: string + ): Promise { + // Validation des champs requis + if (!currentPassword || !newPassword || !confirmPassword) { + throw new ValidationError("Tous les champs sont requis"); + } + + // Validation du nouveau mot de passe + if (newPassword.length < PASSWORD_MIN_LENGTH) { + throw new ValidationError( + `Le nouveau mot de passe doit contenir au moins ${PASSWORD_MIN_LENGTH} caractères`, + "newPassword" + ); + } + + // Vérifier que les mots de passe correspondent + if (newPassword !== confirmPassword) { + throw new ValidationError( + "Les mots de passe ne correspondent pas", + "confirmPassword" + ); + } + + // Mettre à jour le mot de passe (la méthode updateUserPassword gère déjà la vérification de l'ancien mot de passe) + await this.updateUserPassword(userId, currentPassword, newPassword); + } + + /** + * Valide et supprime un utilisateur avec vérification des règles métier + */ + async validateAndDeleteUser( + userId: string, + currentUserId: string + ): Promise { + // Vérifier que l'utilisateur existe + const user = await this.getUserById(userId); + if (!user) { + throw new NotFoundError("Utilisateur"); + } + + // Empêcher la suppression de soi-même + if (user.id === currentUserId) { + throw new ValidationError( + "Vous ne pouvez pas supprimer votre propre compte" + ); + } + + // Supprimer l'utilisateur + await this.deleteUser(userId); + } +} + +export const userService = new UserService();