import { prisma } from "../database"; import type { Challenge, ChallengeStatus, Prisma, } from "@/prisma/generated/prisma/client"; import { ValidationError, NotFoundError } from "../errors"; export interface CreateChallengeInput { challengerId: string; challengedId: string; title: string; description: string; pointsReward?: number; } export interface UpdateChallengeInput { status?: ChallengeStatus; adminId?: string; adminComment?: string; winnerId?: string; title?: string; description?: string; pointsReward?: number; } export interface ChallengeWithUsers extends Challenge { challenger: { id: string; username: string; avatar: string | null; }; challenged: { id: string; username: string; avatar: string | null; }; admin?: { id: string; username: string; } | null; winner?: { id: string; username: string; } | null; } /** * Service de gestion des défis entre joueurs */ export class ChallengeService { /** * Crée un nouveau défi */ async createChallenge(data: CreateChallengeInput): Promise { // Vérifier que les deux joueurs existent const [challenger, challenged] = await Promise.all([ prisma.user.findUnique({ where: { id: data.challengerId } }), prisma.user.findUnique({ where: { id: data.challengedId } }), ]); if (!challenger) { throw new NotFoundError("Joueur qui lance le défi"); } if (!challenged) { throw new NotFoundError("Joueur qui reçoit le défi"); } // Vérifier qu'on ne se défie pas soi-même if (data.challengerId === data.challengedId) { throw new ValidationError("Vous ne pouvez pas vous défier vous-même"); } return prisma.challenge.create({ data: { challengerId: data.challengerId, challengedId: data.challengedId, title: data.title, description: data.description, pointsReward: data.pointsReward || 100, status: "PENDING", }, }); } /** * Accepte un défi */ async acceptChallenge( challengeId: string, userId: string ): Promise { const challenge = await prisma.challenge.findUnique({ where: { id: challengeId }, }); if (!challenge) { throw new NotFoundError("Défi"); } // Vérifier que l'utilisateur est bien celui qui reçoit le défi if (challenge.challengedId !== userId) { throw new ValidationError("Vous n'êtes pas autorisé à accepter ce défi"); } // Vérifier que le défi est en attente if (challenge.status !== "PENDING") { throw new ValidationError( "Ce défi ne peut plus être accepté (statut: " + challenge.status + ")" ); } return prisma.challenge.update({ where: { id: challengeId }, data: { status: "ACCEPTED", acceptedAt: new Date(), }, }); } /** * Accepte un défi en tant qu'admin (bypass les vérifications utilisateur) */ async adminAcceptChallenge(challengeId: string): Promise { const challenge = await prisma.challenge.findUnique({ where: { id: challengeId }, }); if (!challenge) { throw new NotFoundError("Défi"); } // Vérifier que le défi est en attente if (challenge.status !== "PENDING") { throw new ValidationError( "Ce défi ne peut plus être accepté (statut: " + challenge.status + ")" ); } return prisma.challenge.update({ where: { id: challengeId }, data: { status: "ACCEPTED", acceptedAt: new Date(), }, }); } /** * Annule un défi (par le challenger ou le challenged) */ async cancelChallenge( challengeId: string, userId: string ): Promise { const challenge = await prisma.challenge.findUnique({ where: { id: challengeId }, }); if (!challenge) { throw new NotFoundError("Défi"); } // Vérifier que l'utilisateur est bien impliqué dans le défi if ( challenge.challengerId !== userId && challenge.challengedId !== userId ) { throw new ValidationError("Vous n'êtes pas autorisé à annuler ce défi"); } // Vérifier que le défi peut être annulé if (challenge.status === "COMPLETED") { throw new ValidationError("Un défi complété ne peut pas être annulé"); } return prisma.challenge.update({ where: { id: challengeId }, data: { status: "CANCELLED", }, }); } /** * Valide un défi (admin seulement) */ async validateChallenge( challengeId: string, adminId: string, winnerId: string, adminComment?: string ): Promise { // Récupérer uniquement les champs nécessaires pour la validation const challenge = await prisma.challenge.findUnique({ where: { id: challengeId }, select: { id: true, status: true, challengerId: true, challengedId: true, pointsReward: true, }, }); if (!challenge) { throw new NotFoundError("Défi"); } // Vérifier que le défi est accepté if (challenge.status !== "ACCEPTED") { throw new ValidationError( "Seuls les défis acceptés peuvent être validés" ); } // Vérifier que le winner est bien l'un des deux joueurs if ( winnerId !== challenge.challengerId && winnerId !== challenge.challengedId ) { throw new ValidationError( "Le gagnant doit être l'un des deux joueurs du défi" ); } // Paralléliser la mise à jour du défi et l'attribution des points const [updatedChallenge] = await Promise.all([ prisma.challenge.update({ where: { id: challengeId }, data: { status: "COMPLETED", adminId, adminComment: adminComment || null, winnerId, completedAt: new Date(), }, include: { challenger: { select: { id: true, username: true, avatar: true, }, }, challenged: { select: { id: true, username: true, avatar: true, }, }, winner: { select: { id: true, username: true, }, }, }, }), prisma.user.update({ where: { id: winnerId }, data: { score: { increment: challenge.pointsReward, }, }, }), ]); return updatedChallenge; } /** * Rejette un défi (admin seulement) */ async rejectChallenge( challengeId: string, adminId: string, adminComment?: string ): Promise { const challenge = await prisma.challenge.findUnique({ where: { id: challengeId }, }); if (!challenge) { throw new NotFoundError("Défi"); } // Vérifier que le défi est accepté if (challenge.status !== "ACCEPTED") { throw new ValidationError( "Seuls les défis acceptés peuvent être rejetés" ); } return prisma.challenge.update({ where: { id: challengeId }, data: { status: "REJECTED", adminId, adminComment: adminComment || null, }, }); } /** * Met à jour un défi (admin seulement) */ async updateChallenge( challengeId: string, data: UpdateChallengeInput ): Promise { const updateData: Prisma.ChallengeUpdateInput = {}; if (data.title !== undefined) { updateData.title = data.title; } if (data.description !== undefined) { updateData.description = data.description; } if (data.pointsReward !== undefined) { updateData.pointsReward = data.pointsReward; } if (data.status !== undefined) { updateData.status = data.status; } if (data.adminId !== undefined) { updateData.admin = data.adminId ? { connect: { id: data.adminId } } : { disconnect: true }; } if (data.adminComment !== undefined) { updateData.adminComment = data.adminComment; } if (data.winnerId !== undefined) { updateData.winner = data.winnerId ? { connect: { id: data.winnerId } } : { disconnect: true }; } try { return await prisma.challenge.update({ where: { id: challengeId }, data: updateData, }); } catch (error: unknown) { if ( error && typeof error === "object" && "code" in error && error.code === "P2025" ) { // Record not found throw new NotFoundError("Défi"); } throw error; } } /** * Annule un défi (admin seulement) */ async adminCancelChallenge(challengeId: string): Promise { // Récupérer uniquement le statut pour la validation const challenge = await prisma.challenge.findUnique({ where: { id: challengeId }, select: { id: true, status: true, }, }); if (!challenge) { throw new NotFoundError("Défi"); } // Vérifier que le défi peut être annulé if (challenge.status === "COMPLETED") { throw new ValidationError("Un défi complété ne peut pas être annulé"); } return prisma.challenge.update({ where: { id: challengeId }, data: { status: "CANCELLED", }, }); } /** * Réactive un défi annulé (admin seulement) * Remet le défi en PENDING s'il n'avait jamais été accepté, sinon en ACCEPTED */ async reactivateChallenge(challengeId: string): Promise { // Récupérer uniquement les champs nécessaires const challenge = await prisma.challenge.findUnique({ where: { id: challengeId }, select: { id: true, status: true, acceptedAt: true, }, }); if (!challenge) { throw new NotFoundError("Défi"); } // Vérifier que le défi est annulé if (challenge.status !== "CANCELLED") { throw new ValidationError( "Seuls les défis annulés peuvent être réactivés" ); } // Si le défi avait été accepté avant, le remettre en ACCEPTED // Sinon, le remettre en PENDING const newStatus = challenge.acceptedAt ? "ACCEPTED" : "PENDING"; return prisma.challenge.update({ where: { id: challengeId }, data: { status: newStatus, }, }); } /** * Supprime un défi (admin seulement) */ async deleteChallenge(challengeId: string): Promise { try { await prisma.challenge.delete({ where: { id: challengeId }, }); } catch (error: unknown) { if ( error && typeof error === "object" && "code" in error && error.code === "P2025" ) { // Record not found throw new NotFoundError("Défi"); } throw error; } } /** * Récupère un défi par son ID avec les utilisateurs */ async getChallengeById(id: string): Promise { const challenge = await prisma.challenge.findUnique({ where: { id }, include: { challenger: { select: { id: true, username: true, avatar: true, }, }, challenged: { select: { id: true, username: true, avatar: true, }, }, admin: { select: { id: true, username: true, }, }, winner: { select: { id: true, username: true, }, }, }, }); return challenge as ChallengeWithUsers | null; } /** * Récupère tous les défis d'un utilisateur */ async getUserChallenges(userId: string): Promise { return prisma.challenge.findMany({ where: { OR: [{ challengerId: userId }, { challengedId: userId }], }, include: { challenger: { select: { id: true, username: true, avatar: true, }, }, challenged: { select: { id: true, username: true, avatar: true, }, }, admin: { select: { id: true, username: true, }, }, winner: { select: { id: true, username: true, }, }, }, orderBy: { createdAt: "desc", }, }) as Promise; } /** * Récupère les défis acceptés en attente de désignation du gagnant */ async getPendingValidationChallenges(): Promise { return prisma.challenge.findMany({ where: { status: "ACCEPTED", }, include: { challenger: { select: { id: true, username: true, avatar: true, }, }, challenged: { select: { id: true, username: true, avatar: true, }, }, admin: { select: { id: true, username: true, }, }, winner: { select: { id: true, username: true, }, }, }, orderBy: { acceptedAt: "asc", }, }) as Promise; } /** * Récupère tous les défis (pour admin) */ async getAllChallenges(options?: { status?: ChallengeStatus; take?: number; }): Promise { return prisma.challenge.findMany({ where: options?.status ? { status: options.status } : undefined, include: { challenger: { select: { id: true, username: true, avatar: true, }, }, challenged: { select: { id: true, username: true, avatar: true, }, }, admin: { select: { id: true, username: true, }, }, winner: { select: { id: true, username: true, }, }, }, orderBy: { createdAt: "desc", }, take: options?.take, }) as Promise; } /** * Compte les défis actifs pour un utilisateur * - PENDING où l'utilisateur est challenged (en attente de sa réponse) * - ACCEPTED où l'utilisateur est challenger ou challenged (en cours, en attente de validation) */ async getActiveChallengesCount(userId: string): Promise { return prisma.challenge.count({ where: { OR: [ // Défis en attente de réponse de l'utilisateur { status: "PENDING", challengedId: userId, }, // Défis acceptés en cours (en attente de désignation du gagnant) { status: "ACCEPTED", OR: [{ challengerId: userId }, { challengedId: userId }], }, ], }, }); } } export const challengeService = new ChallengeService();