Refactor event handling and user management: Replace direct database calls with service layer methods for events, user profiles, and preferences, enhancing code organization and maintainability. Update API routes to utilize new services for event registration, feedback, and user statistics, ensuring a consistent approach across the application.

This commit is contained in:
Julien Froidefond
2025-12-12 16:19:13 +01:00
parent fd095246a3
commit 494ac3f503
34 changed files with 1795 additions and 1096 deletions

7
services/database.ts Normal file
View File

@@ -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";

31
services/errors.ts Normal file
View File

@@ -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";
}
}

View File

@@ -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<EventFeedback> {
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<EventFeedback | null> {
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<EventFeedback[]> {
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<FeedbackStatistics[]> {
// 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<EventFeedback> {
// 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();

View File

@@ -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<EventRegistration> {
return prisma.eventRegistration.create({
data: {
userId,
eventId,
},
});
}
/**
* Désinscrit un utilisateur d'un événement
*/
async unregisterUserFromEvent(
userId: string,
eventId: string
): Promise<void> {
await prisma.eventRegistration.deleteMany({
where: {
userId,
eventId,
},
});
}
/**
* Vérifie si un utilisateur est inscrit à un événement
*/
async checkUserRegistration(
userId: string,
eventId: string
): Promise<boolean> {
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<EventRegistration | null> {
return prisma.eventRegistration.findUnique({
where: {
userId_eventId: {
userId,
eventId,
},
},
});
}
/**
* Récupère toutes les inscriptions d'un utilisateur
*/
async getUserRegistrations(userId: string): Promise<EventRegistration[]> {
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<number> {
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<EventRegistration> {
// 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();

View File

@@ -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<Event[]> {
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<Event | null> {
return prisma.event.findUnique({
where: { id },
});
}
/**
* Récupère les événements à venir
*/
async getUpcomingEvents(limit: number = 3): Promise<Event[]> {
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<Event> {
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<Event> {
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<void> {
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<Event> {
// 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<Event> {
// 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();

View File

@@ -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<SitePreferences | null> {
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<SitePreferences> {
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<SitePreferences> {
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<string> {
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();

View File

@@ -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<LeaderboardEntry[]> {
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<User> {
// 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<User> {
// 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();

View File

@@ -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<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();