Files
got-gaming/services/users/user.service.ts

516 lines
12 KiB
TypeScript

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<User | null> {
return prisma.user.findUnique({
where: { id },
select,
});
}
/**
* Récupère un utilisateur par son email
*/
async getUserByEmail(email: string): Promise<User | null> {
return prisma.user.findUnique({
where: { email },
});
}
/**
* Récupère un utilisateur par son username
*/
async getUserByUsername(username: string): Promise<User | null> {
return prisma.user.findUnique({
where: { username },
});
}
/**
* Vérifie si un username est disponible
*/
async checkUsernameAvailability(
username: string,
excludeUserId?: string
): Promise<boolean> {
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<boolean> {
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ email }, { username }],
},
});
return !!existingUser;
}
/**
* Crée un nouvel utilisateur
*/
async createUser(data: CreateUserInput): Promise<User> {
// 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<User> {
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<void> {
// 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<void> {
await prisma.user.delete({
where: { id },
});
}
/**
* Récupère tous les utilisateurs (pour admin)
*/
async getAllUsers(options?: {
orderBy?: Prisma.UserOrderByWithRelationInput;
select?: Prisma.UserSelect;
}): Promise<User[]> {
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<User | null> {
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<boolean> {
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<User> {
// 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<User> {
// 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<User> {
// 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<void> {
// 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<void> {
// 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();