import { prisma } from "../database"; import bcrypt from "bcryptjs"; import type { User, CharacterClass, 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();