All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m38s
515 lines
12 KiB
TypeScript
515 lines
12 KiB
TypeScript
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<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();
|