Compare commits

..

3 Commits

57 changed files with 3055 additions and 1688 deletions

View File

@@ -0,0 +1,32 @@
---
globs: app/api/**/*.ts
---
# API Routes Rules
1. Routes MUST only use services for data access
2. Routes MUST handle input validation
3. Routes MUST return typed responses
4. Routes MUST use proper error handling
Example of correct API route:
```typescript
import { MyService } from "@/services/my-service";
export async function GET(request: Request) {
try {
const service = new MyService(pool);
const data = await service.getData();
return Response.json(data);
} catch (error) {
return Response.error();
}
}
```
❌ FORBIDDEN:
- Direct database queries
- Business logic implementation
- Untyped responses

View File

@@ -0,0 +1,167 @@
---
alwaysApply: true
description: Enforce business logic separation between frontend and backend
---
# Business Logic Separation Rules
## Core Principle: NO Business Logic in Frontend
All business logic, data processing, and domain rules MUST be implemented in the backend services layer. The frontend is purely for presentation and user interaction.
## ✅ ALLOWED in Frontend ([src/components/](mdc:src/components/), [src/hooks/](mdc:src/hooks/), [src/clients/](mdc:src/clients/))
### Components
- UI rendering and presentation logic
- Form validation (UI-level only, not business rules)
- User interaction handling (clicks, inputs, navigation)
- Visual state management (loading, errors, UI states)
- Data formatting for display purposes only
### Hooks
- React state management
- API call orchestration (using clients)
- UI-specific logic (modals, forms, animations)
- Data fetching and caching coordination
### Clients
- HTTP requests to API routes
- Request/response transformation (serialization only)
- Error handling and retry logic
- Authentication token management
## ❌ FORBIDDEN in Frontend
### Business Rules
```typescript
// ❌ BAD: Business logic in component
const TaskCard = ({ task }) => {
const canEdit = task.status === 'open' && task.assignee === currentUser.id;
const priority = task.dueDate < new Date() ? 'high' : 'normal';
// This is business logic!
}
// ✅ GOOD: Get computed values from backend
const TaskCard = ({ task }) => {
const { canEdit, priority } = task; // Computed by backend service
}
```
### Data Processing
```typescript
// ❌ BAD: Data transformation in frontend
const processJiraTasks = (tasks) => {
return tasks.map(task => ({
...task,
normalizedStatus: mapJiraStatus(task.status),
estimatedHours: calculateEstimate(task.storyPoints)
}));
}
// ✅ GOOD: Data already processed by backend service
const { processedTasks } = await tasksClient.getTasks();
```
### Domain Logic
```typescript
// ❌ BAD: Domain calculations in frontend
const calculateTeamVelocity = (sprints) => {
// Complex business calculation
}
// ✅ GOOD: Domain logic in service
// This belongs in services/team-analytics.ts
```
## ✅ REQUIRED in Backend ([src/services/](mdc:src/services/), [src/app/api/](mdc:src/app/api/))
### Services Layer
- All business rules and domain logic
- Data validation and processing
- Integration with external APIs (Jira, macOS Reminders)
- Complex calculations and algorithms
- Data aggregation and analytics
- Permission and authorization logic
### API Routes
- Input validation and sanitization
- Service orchestration
- Response formatting
- Error handling and logging
- Authentication and authorization
## Implementation Pattern
### ✅ Correct Flow
```
User Action → Component → Client → API Route → Service → Database
↑ ↓
Pure UI Logic Business Logic
```
### ❌ Incorrect Flow
```
User Action → Component with Business Logic → Database
```
## Examples
### Task Status Management
```typescript
// ❌ BAD: In component
const updateTaskStatus = (taskId, newStatus) => {
if (newStatus === 'done' && !task.hasAllSubtasks) {
throw new Error('Cannot complete task with pending subtasks');
}
// Business rule in frontend!
}
// ✅ GOOD: In services/task-processor.ts
export const updateTaskStatus = async (taskId: string, newStatus: TaskStatus) => {
const task = await getTask(taskId);
// Business rules in service
if (newStatus === 'done' && !await hasAllSubtasksCompleted(taskId)) {
throw new BusinessError('Cannot complete task with pending subtasks');
}
return await updateTask(taskId, { status: newStatus });
}
```
### Team Analytics
```typescript
// ❌ BAD: In component
const TeamDashboard = () => {
const calculateBurndown = (tasks) => {
// Complex business calculation in component
}
}
// ✅ GOOD: In services/team-analytics.ts
export const getTeamBurndown = async (teamId: string, sprintId: string) => {
// All calculation logic in service
const tasks = await getSprintTasks(sprintId);
return calculateBurndownMetrics(tasks);
}
```
## Enforcement
When reviewing code:
1. **Components**: Should only contain JSX, event handlers, and UI state
2. **Hooks**: Should only orchestrate API calls and manage React state
3. **Clients**: Should only make HTTP requests and handle responses
4. **Services**: Should contain ALL business logic and data processing
5. **API Routes**: Should validate input and call appropriate services
## Red Flags
Watch for these patterns that indicate business logic in frontend:
- Complex calculations in components/hooks
- Business rule validation in forms
- Data transformation beyond display formatting
- Domain-specific constants and rules
- Integration logic with external systems
Remember: **The frontend is a thin presentation layer. All intelligence lives in the backend.**

31
.cursor/rules/clients.mdc Normal file
View File

@@ -0,0 +1,31 @@
---
globs: clients/**/*.ts
---
# HTTP Clients Rules
1. All HTTP calls MUST be in clients/domains/
2. Each domain MUST have its own client
3. Clients MUST use the base HTTP client
4. Clients MUST type their responses
Example of correct client:
```typescript
import { HttpClient } from "@/clients/base/http-client";
import { MyData } from "@/lib/types";
export class MyClient {
constructor(private httpClient: HttpClient) {}
async getData(): Promise<MyData[]> {
return this.httpClient.get("/api/data");
}
}
```
❌ FORBIDDEN:
- Direct fetch calls
- Business logic in clients
- Untyped responses

View File

@@ -0,0 +1,28 @@
---
globs: src/components/**/*.tsx
---
# Components Rules
1. UI components MUST be in src/components/ui/
2. Feature components MUST be in their feature folder
3. Components MUST use clients for data fetching
4. Components MUST be properly typed
Example of correct component:
```typescript
import { useMyClient } from '@/hooks/use-my-client';
export const MyComponent = () => {
const { data } = useMyClient();
return <div>{data.map(item => <Item key={item.id} {...item} />)}</div>;
};
```
❌ FORBIDDEN:
- Direct service usage
- Direct database queries
- Direct fetch calls
- Untyped props

View File

@@ -0,0 +1,167 @@
---
alwaysApply: true
description: CSS Variables theme system best practices
---
# CSS Variables Theme System
## Core Principle: Pure CSS Variables for Theming
This project uses **CSS Variables exclusively** for theming. No Tailwind `dark:` classes or conditional CSS classes.
## ✅ Architecture Pattern
### CSS Structure
```css
:root {
/* Light theme (default values) */
--background: #f1f5f9;
--foreground: #0f172a;
--primary: #0891b2;
--success: #059669;
--destructive: #dc2626;
--accent: #d97706;
--purple: #8b5cf6;
--yellow: #eab308;
--green: #059669;
--blue: #2563eb;
--gray: #6b7280;
--gray-light: #e5e7eb;
}
.dark {
/* Dark theme (override values) */
--background: #1e293b;
--foreground: #f1f5f9;
--primary: #06b6d4;
--success: #10b981;
--destructive: #ef4444;
--accent: #f59e0b;
--purple: #8b5cf6;
--yellow: #eab308;
--green: #10b981;
--blue: #3b82f6;
--gray: #9ca3af;
--gray-light: #374151;
}
```
### Theme Application
- **Single source of truth**: [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) applies theme class to `document.documentElement`
- **No duplication**: Theme is applied only once, not in multiple places
- **SSR safe**: Initial theme from server-side preferences
## ✅ Component Usage Patterns
### Correct: Using CSS Variables
```tsx
// ✅ GOOD: CSS Variables in className
<div className="bg-[var(--card)] text-[var(--foreground)] border-[var(--border)]">
// ✅ GOOD: CSS Variables in style prop
<div style={{ color: 'var(--primary)', backgroundColor: 'var(--card)' }}>
// ✅ GOOD: CSS Variables with color-mix for transparency
<div style={{
backgroundColor: 'color-mix(in srgb, var(--primary) 10%, transparent)',
borderColor: 'color-mix(in srgb, var(--primary) 20%, var(--border))'
}}>
```
### ❌ Forbidden: Tailwind Dark Mode Classes
```tsx
// ❌ BAD: Tailwind dark: classes
<div className="bg-white dark:bg-gray-800 text-black dark:text-white">
// ❌ BAD: Conditional classes
<div className={theme === 'dark' ? 'bg-gray-800' : 'bg-white'}>
// ❌ BAD: Hardcoded colors
<div className="bg-red-500 text-blue-600">
```
## ✅ Color System
### Semantic Color Tokens
- `--background`: Main background color
- `--foreground`: Main text color
- `--card`: Card/panel background
- `--card-hover`: Card hover state
- `--card-column`: Column background (darker than cards)
- `--border`: Border color
- `--input`: Input field background
- `--primary`: Primary brand color
- `--primary-foreground`: Text on primary background
- `--muted`: Muted text color
- `--muted-foreground`: Secondary text color
- `--accent`: Accent color (orange/amber)
- `--destructive`: Error/danger color (red)
- `--success`: Success color (green)
- `--purple`: Purple accent
- `--yellow`: Yellow accent
- `--green`: Green accent
- `--blue`: Blue accent
- `--gray`: Gray color
- `--gray-light`: Light gray background
### Color Mixing Patterns
```css
/* Background with transparency */
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
/* Border with transparency */
border-color: color-mix(in srgb, var(--primary) 20%, var(--border));
/* Text with opacity */
color: color-mix(in srgb, var(--destructive) 80%, transparent);
```
## ✅ Theme Context Usage
### ThemeProvider Setup
```tsx
// In layout.tsx
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
{children}
</ThemeProvider>
```
### Component Usage
```tsx
import { useTheme } from '@/contexts/ThemeContext';
function MyComponent() {
const { theme, toggleTheme, setTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Switch to {theme === 'dark' ? 'light' : 'dark'} theme
</button>
);
}
```
## ✅ Future Extensibility
This system is designed to support:
- **Custom color themes**: Easy to add new color variables
- **User preferences**: Colors can be dynamically changed
- **Theme presets**: Multiple predefined themes
- **Accessibility**: High contrast modes
## 🚨 Anti-patterns to Avoid
1. **Don't mix approaches**: Never use both CSS variables and Tailwind dark: classes
2. **Don't duplicate theme application**: Theme should be applied only in ThemeContext
3. **Don't hardcode colors**: Always use semantic color tokens
4. **Don't use conditional classes**: Use CSS variables instead
5. **Don't forget transparency**: Use `color-mix()` for semi-transparent colors
## 📁 Key Files
- [globals.css](mdc:src/app/globals.css) - CSS Variables definitions
- [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) - Theme management
- [UserPreferencesContext.tsx](mdc:src/contexts/UserPreferencesContext.tsx) - Preferences sync
- [layout.tsx](mdc:src/app/layout.tsx) - Theme provider setup
Remember: **CSS Variables are the single source of truth for theming. Keep it pure and consistent.**

View File

@@ -0,0 +1,30 @@
---
alwaysApply: true
---
# Project Structure Rules
1. Backend:
- [src/services/](mdc:src/services/) - ALL database access
- [src/app/api/](mdc:src/app/api/) - API routes using services
2. Frontend:
- [src/clients/](mdc:src/clients/) - HTTP clients
- [src/components/](mdc:src/components/) - React components (organized by domain)
- [src/hooks/](mdc:src/hooks/) - React hooks
3. Shared:
- [src/lib/](mdc:src/lib/) - Types and utilities
- [scripts/](mdc:scripts/) - Utility scripts
Key Files:
- [src/services/database.ts](mdc:src/services/database.ts) - Database pool
- [src/clients/base/http-client.ts](mdc:src/clients/base/http-client.ts) - Base HTTP client
- [src/lib/types.ts](mdc:src/lib/types.ts) - Shared types
❌ FORBIDDEN:
- Database access outside src/services/
- HTTP calls outside src/clients/
- Business logic in src/components/

View File

@@ -0,0 +1,113 @@
---
alwaysApply: true
description: Guide for when to use Server Actions vs API Routes in Next.js App Router
---
# Server Actions vs API Routes - Decision Guide
## ✅ USE SERVER ACTIONS for:
### Quick Actions & Mutations
- **TaskCard actions**: `updateTaskStatus()`, `updateTaskTitle()`, `deleteTask()`
- **Daily checkboxes**: `toggleCheckbox()`, `addCheckbox()`, `updateCheckbox()`
- **User preferences**: `updateTheme()`, `updateViewPreferences()`, `updateFilters()`
- **Simple CRUD**: `createTag()`, `updateTag()`, `deleteTag()`
### Characteristics of Server Action candidates:
- Simple, frequent mutations
- No complex business logic
- Used in interactive components (forms, buttons, toggles)
- Need immediate UI feedback with `useTransition`
- Benefit from automatic cache revalidation
## ❌ KEEP API ROUTES for:
### Complex Endpoints
- **Initial data fetching**: `GET /api/tasks` with complex filters
- **External integrations**: `POST /api/jira/sync` with complex logic
- **Analytics & reports**: Complex data aggregation
- **Public API**: Endpoints that might be called from mobile/external
### Characteristics that require API Routes:
- Complex business logic or data processing
- Multiple service orchestration
- Need for HTTP monitoring/logging
- External consumption (mobile apps, webhooks)
- Real-time features (WebSockets, SSE)
- File uploads or special content types
## 🔄 Implementation Pattern
### Server Actions Structure
```typescript
// actions/tasks.ts
'use server'
import { tasksService } from '@/services/tasks';
import { revalidatePath } from 'next/cache';
export async function updateTaskStatus(taskId: string, status: TaskStatus) {
try {
const task = await tasksService.updateTask(taskId, { status });
revalidatePath('/'); // Auto cache invalidation
return { success: true, data: task };
} catch (error) {
return { success: false, error: error.message };
}
}
```
### Component Usage with useTransition
```typescript
// components/TaskCard.tsx
'use client';
import { updateTaskStatus } from '@/actions/tasks';
import { useTransition } from 'react';
export function TaskCard({ task }) {
const [isPending, startTransition] = useTransition();
const handleStatusChange = (status) => {
startTransition(async () => {
const result = await updateTaskStatus(task.id, status);
if (!result.success) {
// Handle error
}
});
};
return (
<div className={isPending ? 'opacity-50' : ''}>
{/* UI with loading state */}
</div>
);
}
```
## 🏗️ Migration Strategy
When migrating from API Routes to Server Actions:
1. **Create server action** in `actions/` directory
2. **Update component** to use server action directly
3. **Remove API route** (PATCH, POST, DELETE for mutations)
4. **Simplify client** (remove mutation methods, keep GET only)
5. **Update hooks** to use server actions instead of HTTP calls
## 🎯 Benefits of Server Actions
- **🚀 Performance**: No HTTP serialization overhead
- **🔄 Cache intelligence**: Automatic revalidation with `revalidatePath()`
- **📦 Bundle reduction**: Less client-side HTTP code
- **⚡ UX**: Native loading states with `useTransition`
- **🎯 Simplicity**: Direct service calls, less boilerplate
## 🚨 Anti-patterns to Avoid
- Don't use server actions for complex data fetching
- Don't use server actions for endpoints that need HTTP monitoring
- Don't use server actions for public API endpoints
- Don't mix server actions with client-side state management for the same data
Remember: Server Actions are for **direct mutations**, API Routes are for **complex operations** and **public interfaces**.

View File

@@ -0,0 +1,42 @@
---
globs: src/services/*.ts
---
# Services Rules
1. Services MUST contain ALL PostgreSQL queries
2. Services are the ONLY layer allowed to communicate with the database
3. Each service MUST:
- Use the pool from [src/services/database.ts](mdc:src/services/database.ts)
- Implement proper transaction management
- Handle errors and logging
- Validate data before insertion
- Have a clear interface
Example of correct service implementation:
```typescript
export class MyService {
constructor(private pool: Pool) {}
async myMethod(): Promise<Result> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
// ... queries
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
}
```
❌ FORBIDDEN:
- Direct database queries outside src/services
- Raw SQL in API routes
- Database logic in components

View File

@@ -0,0 +1,54 @@
---
alwaysApply: true
description: Automatic TODO tracking and task completion management
---
# TODO Task Tracking Rules
## Automatic Task Completion
Whenever you complete a task or implement a feature mentioned in [TODO.md](mdc:TODO.md), you MUST:
1. **Immediately update the TODO.md** by changing `- [ ]` to `- [x]` for the completed task
2. **Use the exact task description** from the TODO - don't modify the text
3. **Update related sub-tasks** if completing a parent task affects them
4. **Add completion timestamp** in a comment if the task was significant
## Task Completion Examples
### ✅ Correct completion marking:
```markdown
- [x] Initialiser Next.js avec TypeScript
- [x] Configurer ESLint, Prettier
- [x] Setup structure de dossiers selon les règles du workspace
```
### ✅ With timestamp for major milestones:
```markdown
- [x] Créer `services/database.ts` - Pool de connexion DB <!-- Completed 2025-01-15 -->
```
## When to Update TODO.md
Update the TODO immediately after:
- Creating/modifying files mentioned in tasks
- Implementing features described in tasks
- Completing configuration steps
- Finishing any work item listed in the TODO
## Task Dependencies
When completing tasks, consider:
- **Parent tasks**: Mark parent complete only when ALL sub-tasks are done
- **Blocking tasks**: Some tasks may unblock others - mention this in updates
- **Phase completion**: Note when entire phases are completed
## Progress Tracking
Always maintain visibility of:
- Current phase progress
- Next logical task to tackle
- Any blockers or issues encountered
- Completed vs remaining work ratio
This ensures the TODO.md remains an accurate reflection of project progress and helps maintain momentum.

131
actions/admin/events.ts Normal file
View File

@@ -0,0 +1,131 @@
'use server'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
import { eventService } from '@/services/events/event.service'
import { Role, EventType } from '@/prisma/generated/prisma/client'
import { ValidationError, NotFoundError } from '@/services/errors'
function checkAdminAccess() {
return async () => {
const session = await auth()
if (!session?.user || session.user.role !== Role.ADMIN) {
throw new Error('Accès refusé')
}
return session
}
}
export async function createEvent(data: {
date: string
name: string
description?: string | null
type: string
room?: string | null
time?: string | null
maxPlaces?: number | null
}) {
try {
await checkAdminAccess()()
const event = await eventService.validateAndCreateEvent({
date: data.date,
name: data.name,
description: data.description ?? '',
type: data.type as EventType,
room: data.room ?? undefined,
time: data.time ?? undefined,
maxPlaces: data.maxPlaces ?? undefined,
})
revalidatePath('/admin')
revalidatePath('/events')
revalidatePath('/')
return { success: true, data: event }
} catch (error) {
console.error('Error creating event:', error)
if (error instanceof ValidationError) {
return { success: false, error: error.message }
}
if (error instanceof Error && error.message === 'Accès refusé') {
return { success: false, error: 'Accès refusé' }
}
return { success: false, error: 'Erreur lors de la création de l\'événement' }
}
}
export async function updateEvent(eventId: string, data: {
date?: string
name?: string
description?: string | null
type?: string
room?: string | null
time?: string | null
maxPlaces?: number | null
}) {
try {
await checkAdminAccess()()
const event = await eventService.validateAndUpdateEvent(eventId, {
date: data.date,
name: data.name,
description: data.description ?? undefined,
type: data.type as EventType,
room: data.room ?? undefined,
time: data.time ?? undefined,
maxPlaces: data.maxPlaces ?? undefined,
})
revalidatePath('/admin')
revalidatePath('/events')
revalidatePath('/')
return { success: true, data: event }
} catch (error) {
console.error('Error updating event:', error)
if (error instanceof ValidationError) {
return { success: false, error: error.message }
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message }
}
if (error instanceof Error && error.message === 'Accès refusé') {
return { success: false, error: 'Accès refusé' }
}
return { success: false, error: 'Erreur lors de la mise à jour de l\'événement' }
}
}
export async function deleteEvent(eventId: string) {
try {
await checkAdminAccess()()
const existingEvent = await eventService.getEventById(eventId)
if (!existingEvent) {
return { success: false, error: 'Événement non trouvé' }
}
await eventService.deleteEvent(eventId)
revalidatePath('/admin')
revalidatePath('/events')
revalidatePath('/')
return { success: true }
} catch (error) {
console.error('Error deleting event:', error)
if (error instanceof Error && error.message === 'Accès refusé') {
return { success: false, error: 'Accès refusé' }
}
return { success: false, error: 'Erreur lors de la suppression de l\'événement' }
}
}

View File

@@ -0,0 +1,48 @@
'use server'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
import { sitePreferencesService } from '@/services/preferences/site-preferences.service'
import { Role } from '@/prisma/generated/prisma/client'
function checkAdminAccess() {
return async () => {
const session = await auth()
if (!session?.user || session.user.role !== Role.ADMIN) {
throw new Error('Accès refusé')
}
return session
}
}
export async function updateSitePreferences(data: {
homeBackground?: string | null
eventsBackground?: string | null
leaderboardBackground?: string | null
}) {
try {
await checkAdminAccess()()
const preferences = await sitePreferencesService.updateSitePreferences({
homeBackground: data.homeBackground,
eventsBackground: data.eventsBackground,
leaderboardBackground: data.leaderboardBackground,
})
revalidatePath('/admin')
revalidatePath('/')
revalidatePath('/events')
revalidatePath('/leaderboard')
return { success: true, data: preferences }
} catch (error) {
console.error('Error updating admin preferences:', error)
if (error instanceof Error && error.message === 'Accès refusé') {
return { success: false, error: 'Accès refusé' }
}
return { success: false, error: 'Erreur lors de la mise à jour des préférences' }
}
}

116
actions/admin/users.ts Normal file
View File

@@ -0,0 +1,116 @@
'use server'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
import { userService } from '@/services/users/user.service'
import { userStatsService } from '@/services/users/user-stats.service'
import { Role } from '@/prisma/generated/prisma/client'
import {
ValidationError,
NotFoundError,
ConflictError,
} from '@/services/errors'
function checkAdminAccess() {
return async () => {
const session = await auth()
if (!session?.user || session.user.role !== Role.ADMIN) {
throw new Error('Accès refusé')
}
return session
}
}
export async function updateUser(userId: string, data: {
username?: string
avatar?: string | null
hpDelta?: number
xpDelta?: number
score?: number
level?: number
role?: string
}) {
try {
await checkAdminAccess()()
// Valider username si fourni
if (data.username !== undefined) {
try {
await userService.validateAndUpdateUserProfile(userId, { username: data.username })
} catch (error) {
if (error instanceof ValidationError || error instanceof ConflictError) {
return { success: false, error: error.message }
}
throw error
}
}
// Mettre à jour stats et profil
const updatedUser = await userStatsService.updateUserStatsAndProfile(
userId,
{
username: data.username,
avatar: data.avatar,
hpDelta: data.hpDelta,
xpDelta: data.xpDelta,
score: data.score,
level: data.level,
role: data.role ? (data.role as Role) : undefined,
},
{
id: true,
username: true,
email: true,
role: true,
score: true,
level: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
avatar: true,
}
)
revalidatePath('/admin')
revalidatePath('/leaderboard')
return { success: true, data: updatedUser }
} catch (error) {
console.error('Error updating user:', error)
if (error instanceof Error && error.message === 'Accès refusé') {
return { success: false, error: 'Accès refusé' }
}
return { success: false, error: 'Erreur lors de la mise à jour de l\'utilisateur' }
}
}
export async function deleteUser(userId: string) {
try {
const session = await checkAdminAccess()()
await userService.validateAndDeleteUser(userId, session.user.id)
revalidatePath('/admin')
revalidatePath('/leaderboard')
return { success: true }
} catch (error) {
console.error('Error deleting user:', error)
if (error instanceof ValidationError) {
return { success: false, error: error.message }
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message }
}
if (error instanceof Error && error.message === 'Accès refusé') {
return { success: false, error: 'Accès refusé' }
}
return { success: false, error: 'Erreur lors de la suppression de l\'utilisateur' }
}
}

View File

@@ -0,0 +1,45 @@
'use server'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
import { eventFeedbackService } from '@/services/events/event-feedback.service'
import {
ValidationError,
NotFoundError,
} from '@/services/errors'
export async function createFeedback(eventId: string, data: {
rating: number
comment?: string | null
}) {
try {
const session = await auth()
if (!session?.user?.id) {
return { success: false, error: 'Non authentifié' }
}
const feedback = await eventFeedbackService.validateAndCreateFeedback(
session.user.id,
eventId,
{ rating: data.rating, comment: data.comment }
)
revalidatePath(`/feedback/${eventId}`)
revalidatePath('/events')
return { success: true, data: feedback }
} catch (error) {
console.error('Error saving feedback:', error)
if (error instanceof ValidationError) {
return { success: false, error: error.message }
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message }
}
return { success: false, error: 'Erreur lors de l\'enregistrement du feedback' }
}
}

View File

@@ -0,0 +1,65 @@
'use server'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
import { eventRegistrationService } from '@/services/events/event-registration.service'
import {
ValidationError,
NotFoundError,
ConflictError,
} from '@/services/errors'
export async function registerForEvent(eventId: string) {
try {
const session = await auth()
if (!session?.user?.id) {
return { success: false, error: 'Vous devez être connecté pour vous inscrire' }
}
const registration = await eventRegistrationService.validateAndRegisterUser(
session.user.id,
eventId
)
revalidatePath('/events')
revalidatePath('/')
return { success: true, message: 'Inscription réussie', data: registration }
} catch (error) {
console.error('Registration error:', error)
if (error instanceof ValidationError || error instanceof ConflictError) {
return { success: false, error: error.message }
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message }
}
return { success: false, error: 'Une erreur est survenue lors de l\'inscription' }
}
}
export async function unregisterFromEvent(eventId: string) {
try {
const session = await auth()
if (!session?.user?.id) {
return { success: false, error: 'Vous devez être connecté' }
}
await eventRegistrationService.unregisterUserFromEvent(
session.user.id,
eventId
)
revalidatePath('/events')
revalidatePath('/')
return { success: true, message: 'Inscription annulée' }
} catch (error) {
console.error('Unregistration error:', error)
return { success: false, error: 'Une erreur est survenue lors de l\'annulation' }
}
}

View File

@@ -0,0 +1,46 @@
'use server'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
import { userService } from '@/services/users/user.service'
import {
ValidationError,
NotFoundError,
} from '@/services/errors'
export async function updatePassword(data: {
currentPassword: string
newPassword: string
confirmPassword: string
}) {
try {
const session = await auth()
if (!session?.user) {
return { success: false, error: 'Non authentifié' }
}
await userService.validateAndUpdatePassword(
session.user.id,
data.currentPassword,
data.newPassword,
data.confirmPassword
)
revalidatePath('/profile')
return { success: true, message: 'Mot de passe modifié avec succès' }
} catch (error) {
console.error('Error updating password:', error)
if (error instanceof ValidationError) {
return { success: false, error: error.message }
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message }
}
return { success: false, error: 'Erreur lors de la modification du mot de passe' }
}
}

View File

@@ -0,0 +1,63 @@
'use server'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
import { userService } from '@/services/users/user.service'
import { CharacterClass } from '@/prisma/generated/prisma/client'
import {
ValidationError,
ConflictError,
} from '@/services/errors'
export async function updateProfile(data: {
username?: string
avatar?: string | null
bio?: string | null
characterClass?: string | null
}) {
try {
const session = await auth()
if (!session?.user) {
return { success: false, error: 'Non authentifié' }
}
const updatedUser = await userService.validateAndUpdateUserProfile(
session.user.id,
{
username: data.username,
avatar: data.avatar,
bio: data.bio,
characterClass: data.characterClass ? (data.characterClass as CharacterClass) : null,
},
{
id: true,
email: true,
username: true,
avatar: true,
bio: true,
characterClass: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
level: true,
score: true,
}
)
revalidatePath('/profile')
revalidatePath('/')
return { success: true, data: updatedUser }
} catch (error) {
console.error('Error updating profile:', error)
if (error instanceof ValidationError || error instanceof ConflictError) {
return { success: false, error: error.message }
}
return { success: false, error: 'Erreur lors de la mise à jour du profil' }
}
}

View File

@@ -1,6 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
import { Role } from "@/prisma/generated/prisma/client"; import { Role } from "@/prisma/generated/prisma/client";
import NavigationWrapper from "@/components/NavigationWrapper"; import NavigationWrapper from "@/components/NavigationWrapper";
import AdminPanel from "@/components/AdminPanel"; import AdminPanel from "@/components/AdminPanel";
@@ -18,22 +18,9 @@ export default async function AdminPage() {
redirect("/"); redirect("/");
} }
// Récupérer les préférences globales du site // Récupérer les préférences globales du site (ou créer si elles n'existent pas)
let sitePreferences = await prisma.sitePreferences.findUnique({ const sitePreferences =
where: { id: "global" }, await sitePreferencesService.getOrCreateSitePreferences();
});
// Si elles n'existent pas, créer une entrée par défaut
if (!sitePreferences) {
sitePreferences = await prisma.sitePreferences.create({
data: {
id: "global",
homeBackground: null,
eventsBackground: null,
leaderboardBackground: null,
},
});
}
return ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen bg-black relative">

View File

@@ -1,123 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { Role, EventType } from "@/prisma/generated/prisma/client";
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const { date, name, description, type, room, time, maxPlaces } = body;
// Le statut est ignoré s'il est fourni, il sera calculé automatiquement
// Vérifier que l'événement existe
const existingEvent = await prisma.event.findUnique({
where: { id },
});
if (!existingEvent) {
return NextResponse.json(
{ error: "Événement non trouvé" },
{ status: 404 }
);
}
const updateData: {
date?: Date;
name?: string;
description?: string;
type?: EventType;
room?: string | null;
time?: string | null;
maxPlaces?: number | null;
} = {};
if (date !== undefined) {
const eventDate = new Date(date);
if (isNaN(eventDate.getTime())) {
return NextResponse.json(
{ error: "Format de date invalide" },
{ status: 400 }
);
}
updateData.date = eventDate;
}
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (type !== undefined) {
if (!Object.values(EventType).includes(type)) {
return NextResponse.json(
{ error: "Type d'événement invalide" },
{ status: 400 }
);
}
updateData.type = type as EventType;
}
// Le statut est toujours calculé automatiquement, on ignore s'il est fourni
if (room !== undefined) updateData.room = room || null;
if (time !== undefined) updateData.time = time || null;
if (maxPlaces !== undefined)
updateData.maxPlaces = maxPlaces ? parseInt(maxPlaces) : null;
const event = await prisma.event.update({
where: { id },
data: updateData,
});
return NextResponse.json(event);
} catch (error) {
console.error("Error updating event:", error);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour de l'événement" },
{ status: 500 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const { id } = await params;
// Vérifier que l'événement existe
const existingEvent = await prisma.event.findUnique({
where: { id },
});
if (!existingEvent) {
return NextResponse.json(
{ error: "Événement non trouvé" },
{ status: 404 }
);
}
await prisma.event.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting event:", error);
return NextResponse.json(
{ error: "Erreur lors de la suppression de l'événement" },
{ status: 500 }
);
}
}

View File

@@ -1,8 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { eventService } from "@/services/events/event.service";
import { Role, EventType } from "@/prisma/generated/prisma/client"; import { Role } from "@/prisma/generated/prisma/client";
import { calculateEventStatus } from "@/lib/eventStatus";
export async function GET() { export async function GET() {
try { try {
@@ -12,34 +11,22 @@ export async function GET() {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
} }
const events = await prisma.event.findMany({ const events = await eventService.getEventsWithStatus();
orderBy: {
date: "desc",
},
include: {
_count: {
select: {
registrations: true,
},
},
},
});
// Transformer les données pour inclure le nombre d'inscriptions // Transformer les données pour la sérialisation
// Le statut est calculé automatiquement en fonction de la date
const eventsWithCount = events.map((event) => ({ const eventsWithCount = events.map((event) => ({
id: event.id, id: event.id,
date: event.date.toISOString(), date: event.date.toISOString(),
name: event.name, name: event.name,
description: event.description, description: event.description,
type: event.type, type: event.type,
status: calculateEventStatus(event.date), status: event.status,
room: event.room, room: event.room,
time: event.time, time: event.time,
maxPlaces: event.maxPlaces, maxPlaces: event.maxPlaces,
createdAt: event.createdAt.toISOString(), createdAt: event.createdAt.toISOString(),
updatedAt: event.updatedAt.toISOString(), updatedAt: event.updatedAt.toISOString(),
registrationsCount: event._count.registrations, registrationsCount: event.registrationsCount,
})); }));
return NextResponse.json(eventsWithCount); return NextResponse.json(eventsWithCount);
@@ -52,59 +39,3 @@ export async function GET() {
} }
} }
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const body = await request.json();
const { date, name, description, type, room, time, maxPlaces } = body;
if (!date || !name || !description || !type) {
return NextResponse.json(
{ error: "Tous les champs sont requis" },
{ status: 400 }
);
}
// Convertir la date string en Date object
const eventDate = new Date(date);
if (isNaN(eventDate.getTime())) {
return NextResponse.json(
{ error: "Format de date invalide" },
{ status: 400 }
);
}
// Valider les enums
if (!Object.values(EventType).includes(type)) {
return NextResponse.json(
{ error: "Type d'événement invalide" },
{ status: 400 }
);
}
const event = await prisma.event.create({
data: {
date: eventDate,
name,
description,
type: type as EventType,
room: room || null,
time: time || null,
maxPlaces: maxPlaces ? parseInt(maxPlaces) : null,
},
});
return NextResponse.json(event);
} catch (error) {
console.error("Error creating event:", error);
return NextResponse.json(
{ error: "Erreur lors de la création de l'événement" },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { eventFeedbackService } from "@/services/events/event-feedback.service";
import { Role } from "@/prisma/generated/prisma/client"; import { Role } from "@/prisma/generated/prisma/client";
export async function GET() { export async function GET() {
@@ -15,72 +15,14 @@ export async function GET() {
} }
// Récupérer tous les feedbacks avec les détails de l'événement et de l'utilisateur // Récupérer tous les feedbacks avec les détails de l'événement et de l'utilisateur
const feedbacks = await prisma.eventFeedback.findMany({ const feedbacks = await eventFeedbackService.getAllFeedbacks();
include: {
event: {
select: {
id: true,
name: true,
date: true,
type: true,
},
},
user: {
select: {
id: true,
username: true,
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
// Calculer les statistiques par événement // Calculer les statistiques par événement
const eventStats = await prisma.eventFeedback.groupBy({ const statistics = await eventFeedbackService.getFeedbackStatistics();
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
const statsWithDetails = 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,
};
});
return NextResponse.json({ return NextResponse.json({
feedbacks, feedbacks,
statistics: statsWithDetails, statistics,
}); });
} catch (error) { } catch (error) {
console.error("Error fetching feedbacks:", error); console.error("Error fetching feedbacks:", error);

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
import { Role } from "@/prisma/generated/prisma/client"; import { Role } from "@/prisma/generated/prisma/client";
export async function GET() { export async function GET() {
@@ -11,22 +11,8 @@ export async function GET() {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
} }
// Récupérer les préférences globales du site // Récupérer les préférences globales du site (ou créer si elles n'existent pas)
let sitePreferences = await prisma.sitePreferences.findUnique({ const sitePreferences = await sitePreferencesService.getOrCreateSitePreferences();
where: { id: "global" },
});
// Si elles n'existent pas, créer une entrée par défaut
if (!sitePreferences) {
sitePreferences = await prisma.sitePreferences.create({
data: {
id: "global",
homeBackground: null,
eventsBackground: null,
leaderboardBackground: null,
},
});
}
return NextResponse.json(sitePreferences); return NextResponse.json(sitePreferences);
} catch (error) { } catch (error) {
@@ -37,46 +23,3 @@ export async function GET() {
); );
} }
} }
export async function PUT(request: Request) {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const body = await request.json();
const { homeBackground, eventsBackground, leaderboardBackground } = body;
const preferences = await prisma.sitePreferences.upsert({
where: { id: "global" },
update: {
homeBackground:
homeBackground === "" ? null : (homeBackground ?? undefined),
eventsBackground:
eventsBackground === "" ? null : (eventsBackground ?? undefined),
leaderboardBackground:
leaderboardBackground === ""
? null
: (leaderboardBackground ?? undefined),
},
create: {
id: "global",
homeBackground: homeBackground === "" ? null : (homeBackground ?? null),
eventsBackground:
eventsBackground === "" ? null : (eventsBackground ?? null),
leaderboardBackground:
leaderboardBackground === "" ? null : (leaderboardBackground ?? null),
},
});
return NextResponse.json(preferences);
} catch (error) {
console.error("Error updating admin preferences:", error);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour des préférences" },
{ status: 500 }
);
}
}

View File

@@ -1,242 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { Role } from "@/prisma/generated/prisma/client";
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const { username, avatar, hpDelta, xpDelta, score, level, role } = body;
// Récupérer l'utilisateur actuel
const user = await prisma.user.findUnique({
where: { id },
});
if (!user) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
// 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 (hpDelta !== undefined) {
newHp = Math.max(0, Math.min(user.maxHp, user.hp + hpDelta));
}
// Appliquer les changements de XP
if (xpDelta !== undefined) {
newXp = user.xp + 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;
}
}
// Appliquer les changements directs (username, avatar, score, level, role)
const updateData: {
hp: number;
xp: number;
level: number;
maxXp: number;
username?: string;
avatar?: string | null;
score?: number;
role?: Role;
} = {
hp: newHp,
xp: newXp,
level: newLevel,
maxXp: newMaxXp,
};
// Validation et mise à jour du username
if (username !== undefined) {
if (typeof username !== "string" || username.trim().length === 0) {
return NextResponse.json(
{ error: "Le nom d'utilisateur ne peut pas être vide" },
{ status: 400 }
);
}
if (username.length < 3 || username.length > 20) {
return NextResponse.json(
{
error:
"Le nom d'utilisateur doit contenir entre 3 et 20 caractères",
},
{ status: 400 }
);
}
// Vérifier si le username est déjà pris par un autre utilisateur
const existingUser = await prisma.user.findFirst({
where: {
username: username.trim(),
NOT: { id },
},
});
if (existingUser) {
return NextResponse.json(
{ error: "Ce nom d'utilisateur est déjà pris" },
{ status: 400 }
);
}
updateData.username = username.trim();
}
// Mise à jour de l'avatar
if (avatar !== undefined) {
updateData.avatar = avatar || null;
}
if (score !== undefined) {
updateData.score = Math.max(0, score);
}
if (level !== undefined) {
// Si le niveau est modifié directement, utiliser cette valeur
const targetLevel = Math.max(1, 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 && xpDelta === undefined) {
updateData.xp = 0;
}
}
if (role !== undefined) {
if (role === "ADMIN" || role === "USER") {
updateData.role = role as Role;
}
}
// Mettre à jour l'utilisateur
const updatedUser = await prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
username: true,
email: true,
role: true,
score: true,
level: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
avatar: true,
},
});
return NextResponse.json(updatedUser);
} catch (error) {
console.error("Error updating user:", error);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour de l'utilisateur" },
{ status: 500 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const { id } = await params;
// Vérifier que l'utilisateur existe
const user = await prisma.user.findUnique({
where: { id },
});
if (!user) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
// Empêcher la suppression de soi-même
if (user.id === session.user.id) {
return NextResponse.json(
{ error: "Vous ne pouvez pas supprimer votre propre compte" },
{ status: 400 }
);
}
// Supprimer l'utilisateur (les relations seront supprimées en cascade)
await prisma.user.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting user:", error);
return NextResponse.json(
{ error: "Erreur lors de la suppression de l'utilisateur" },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { userService } from "@/services/users/user.service";
import { Role } from "@/prisma/generated/prisma/client"; import { Role } from "@/prisma/generated/prisma/client";
export async function GET() { export async function GET() {
@@ -12,7 +12,10 @@ export async function GET() {
} }
// Récupérer tous les utilisateurs avec leurs stats // Récupérer tous les utilisateurs avec leurs stats
const users = await prisma.user.findMany({ const users = await userService.getAllUsers({
orderBy: {
score: "desc",
},
select: { select: {
id: true, id: true,
username: true, username: true,
@@ -27,9 +30,6 @@ export async function GET() {
avatar: true, avatar: true,
createdAt: true, createdAt: true,
}, },
orderBy: {
score: "desc",
},
}); });
return NextResponse.json(users); return NextResponse.json(users);

View File

@@ -1,115 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { eventRegistrationService } from "@/services/events/event-registration.service";
import { calculateEventStatus } from "@/lib/eventStatus";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Vous devez être connecté pour vous inscrire" },
{ status: 401 }
);
}
const { id: eventId } = await params;
// Vérifier si l'événement existe
const event = await prisma.event.findUnique({
where: { id: eventId },
});
if (!event) {
return NextResponse.json(
{ error: "Événement introuvable" },
{ status: 404 }
);
}
const eventStatus = calculateEventStatus(event.date);
if (eventStatus !== "UPCOMING") {
return NextResponse.json(
{ error: "Vous ne pouvez vous inscrire qu'aux événements à venir" },
{ status: 400 }
);
}
// Vérifier si l'utilisateur est déjà inscrit
const existingRegistration = await prisma.eventRegistration.findUnique({
where: {
userId_eventId: {
userId: session.user.id,
eventId: eventId,
},
},
});
if (existingRegistration) {
return NextResponse.json(
{ error: "Vous êtes déjà inscrit à cet événement" },
{ status: 400 }
);
}
// Créer l'inscription
const registration = await prisma.eventRegistration.create({
data: {
userId: session.user.id,
eventId: eventId,
},
});
return NextResponse.json(
{ message: "Inscription réussie", registration },
{ status: 201 }
);
} catch (error) {
console.error("Registration error:", error);
return NextResponse.json(
{ error: "Une erreur est survenue lors de l'inscription" },
{ status: 500 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Vous devez être connecté" },
{ status: 401 }
);
}
const { id: eventId } = await params;
// Supprimer l'inscription
await prisma.eventRegistration.deleteMany({
where: {
userId: session.user.id,
eventId: eventId,
},
});
return NextResponse.json({ message: "Inscription annulée" });
} catch (error) {
console.error("Unregistration error:", error);
return NextResponse.json(
{ error: "Une erreur est survenue lors de l'annulation" },
{ status: 500 }
);
}
}
export async function GET( export async function GET(
request: Request, request: Request,
@@ -124,16 +16,12 @@ export async function GET(
const { id: eventId } = await params; const { id: eventId } = await params;
const registration = await prisma.eventRegistration.findUnique({ const isRegistered = await eventRegistrationService.checkUserRegistration(
where: { session.user.id,
userId_eventId: { eventId
userId: session.user.id, );
eventId: eventId,
},
},
});
return NextResponse.json({ registered: !!registration }); return NextResponse.json({ registered: isRegistered });
} catch (error) { } catch (error) {
console.error("Check registration error:", error); console.error("Check registration error:", error);
return NextResponse.json({ registered: false }); return NextResponse.json({ registered: false });

View File

@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { eventService } from "@/services/events/event.service";
export async function GET( export async function GET(
request: Request, request: Request,
@@ -8,9 +8,7 @@ export async function GET(
try { try {
const { id } = await params; const { id } = await params;
const event = await prisma.event.findUnique({ const event = await eventService.getEventById(id);
where: { id },
});
if (!event) { if (!event) {
return NextResponse.json( return NextResponse.json(

View File

@@ -1,12 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { eventService } from "@/services/events/event.service";
export async function GET() { export async function GET() {
try { try {
const events = await prisma.event.findMany({ const events = await eventService.getAllEvents({
orderBy: { orderBy: { date: "asc" },
date: "asc",
},
}); });
return NextResponse.json(events); return NextResponse.json(events);

View File

@@ -1,70 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { eventFeedbackService } from "@/services/events/event-feedback.service";
export async function POST(
request: Request,
{ params }: { params: Promise<{ eventId: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const { eventId } = await params;
const body = await request.json();
const { rating, comment } = body;
// Valider la note (1-5)
if (!rating || rating < 1 || rating > 5) {
return NextResponse.json(
{ error: "La note doit être entre 1 et 5" },
{ status: 400 }
);
}
// Vérifier que l'événement existe
const event = await prisma.event.findUnique({
where: { id: eventId },
});
if (!event) {
return NextResponse.json(
{ error: "Événement introuvable" },
{ status: 404 }
);
}
// Créer ou mettre à jour le feedback (unique par utilisateur/événement)
const feedback = await prisma.eventFeedback.upsert({
where: {
userId_eventId: {
userId: session.user.id,
eventId,
},
},
update: {
rating,
comment: comment || null,
},
create: {
userId: session.user.id,
eventId,
rating,
comment: comment || null,
},
});
return NextResponse.json({ success: true, feedback });
} catch (error) {
console.error("Error saving feedback:", error);
return NextResponse.json(
{ error: "Erreur lors de l'enregistrement du feedback" },
{ status: 500 }
);
}
}
export async function GET( export async function GET(
request: Request, request: Request,
@@ -79,23 +16,10 @@ export async function GET(
const { eventId } = await params; const { eventId } = await params;
// Récupérer le feedback de l'utilisateur pour cet événement // Récupérer le feedback de l'utilisateur pour cet événement
const feedback = await prisma.eventFeedback.findUnique({ const feedback = await eventFeedbackService.getUserFeedback(
where: { session.user.id,
userId_eventId: { eventId
userId: session.user.id, );
eventId,
},
},
include: {
event: {
select: {
id: true,
name: true,
date: true,
},
},
},
});
return NextResponse.json({ feedback }); return NextResponse.json({ feedback });
} catch (error) { } catch (error) {

View File

@@ -1,49 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { userStatsService } from "@/services/users/user-stats.service";
export async function GET() { export async function GET() {
try { try {
const users = await prisma.user.findMany({ const leaderboard = await userStatsService.getLeaderboard(10);
orderBy: {
score: "desc",
},
take: 10,
select: {
id: true,
username: true,
email: true,
score: true,
level: true,
avatar: true,
bio: true,
characterClass: true,
},
});
const leaderboard = users.map(
(
user: {
id: string;
username: string;
email: string;
score: number;
level: number;
avatar: string | null;
bio: string | null;
characterClass: string | null;
},
index: number
) => ({
rank: index + 1,
username: user.username,
email: user.email,
score: user.score,
level: user.level,
avatar: user.avatar,
bio: user.bio,
characterClass: user.characterClass,
})
);
return NextResponse.json(leaderboard); return NextResponse.json(leaderboard);
} catch (error) { } catch (error) {

View File

@@ -1,12 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
export async function GET() { export async function GET() {
try { try {
// Récupérer les préférences globales du site (pas besoin d'authentification) // Récupérer les préférences globales du site (pas besoin d'authentification)
let sitePreferences = await prisma.sitePreferences.findUnique({ const sitePreferences = await sitePreferencesService.getSitePreferences();
where: { id: "global" },
});
// Si elles n'existent pas, retourner des valeurs par défaut // Si elles n'existent pas, retourner des valeurs par défaut
if (!sitePreferences) { if (!sitePreferences) {

View File

@@ -1,84 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export async function PUT(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const body = await request.json();
const { currentPassword, newPassword, confirmPassword } = body;
// Validation
if (!currentPassword || !newPassword || !confirmPassword) {
return NextResponse.json(
{ error: "Tous les champs sont requis" },
{ status: 400 }
);
}
if (newPassword.length < 6) {
return NextResponse.json(
{
error: "Le nouveau mot de passe doit contenir au moins 6 caractères",
},
{ status: 400 }
);
}
if (newPassword !== confirmPassword) {
return NextResponse.json(
{ error: "Les mots de passe ne correspondent pas" },
{ status: 400 }
);
}
// Récupérer l'utilisateur avec le mot de passe
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { password: true },
});
if (!user) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
// Vérifier l'ancien mot de passe
const isPasswordValid = await bcrypt.compare(
currentPassword,
user.password
);
if (!isPasswordValid) {
return NextResponse.json(
{ error: "Mot de passe actuel incorrect" },
{ status: 400 }
);
}
// 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: session.user.id },
data: { password: hashedPassword },
});
return NextResponse.json({ message: "Mot de passe modifié avec succès" });
} catch (error) {
console.error("Error updating password:", error);
return NextResponse.json(
{ error: "Erreur lors de la modification du mot de passe" },
{ status: 500 }
);
}
}

View File

@@ -1,7 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { userService } from "@/services/users/user.service";
import { CharacterClass } from "@/prisma/generated/prisma/enums";
export async function GET() { export async function GET() {
try { try {
@@ -11,9 +10,7 @@ export async function GET() {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
} }
const user = await prisma.user.findUnique({ const user = await userService.getUserById(session.user.id, {
where: { id: session.user.id },
select: {
id: true, id: true,
email: true, email: true,
username: true, username: true,
@@ -27,7 +24,6 @@ export async function GET() {
level: true, level: true,
score: true, score: true,
createdAt: true, createdAt: true,
},
}); });
if (!user) { if (!user) {
@@ -47,135 +43,3 @@ export async function GET() {
} }
} }
export async function PUT(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const body = await request.json();
const { username, avatar, bio, characterClass } = body;
// Validation
if (username !== undefined) {
if (typeof username !== "string" || username.trim().length === 0) {
return NextResponse.json(
{ error: "Le nom d'utilisateur ne peut pas être vide" },
{ status: 400 }
);
}
if (username.length < 3 || username.length > 20) {
return NextResponse.json(
{
error:
"Le nom d'utilisateur doit contenir entre 3 et 20 caractères",
},
{ status: 400 }
);
}
// Vérifier si le username est déjà pris par un autre utilisateur
const existingUser = await prisma.user.findFirst({
where: {
username: username.trim(),
NOT: { id: session.user.id },
},
});
if (existingUser) {
return NextResponse.json(
{ error: "Ce nom d'utilisateur est déjà pris" },
{ status: 400 }
);
}
}
// Validation bio
if (bio !== undefined && bio !== null) {
if (typeof bio !== "string") {
return NextResponse.json(
{ error: "La bio doit être une chaîne de caractères" },
{ status: 400 }
);
}
if (bio.length > 500) {
return NextResponse.json(
{ error: "La bio ne peut pas dépasser 500 caractères" },
{ status: 400 }
);
}
}
// Validation characterClass
const validClasses = [
"WARRIOR",
"MAGE",
"ROGUE",
"RANGER",
"PALADIN",
"ENGINEER",
"MERCHANT",
"SCHOLAR",
"BERSERKER",
"NECROMANCER",
];
if (characterClass !== undefined && characterClass !== null) {
if (!validClasses.includes(characterClass)) {
return NextResponse.json(
{ error: "Classe de personnage invalide" },
{ status: 400 }
);
}
}
// Mettre à jour l'utilisateur
const updateData: {
username?: string;
avatar?: string | null;
bio?: string | null;
characterClass?: CharacterClass | null;
} = {};
if (username !== undefined) {
updateData.username = username.trim();
}
if (avatar !== undefined) {
updateData.avatar = avatar || null;
}
if (bio !== undefined) {
updateData.bio = bio === null ? null : bio.trim() || null;
}
if (characterClass !== undefined) {
updateData.characterClass = (characterClass as CharacterClass) || null;
}
const updatedUser = await prisma.user.update({
where: { id: session.user.id },
data: updateData,
select: {
id: true,
email: true,
username: true,
avatar: true,
bio: true,
characterClass: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
level: true,
score: true,
},
});
return NextResponse.json(updatedUser);
} catch (error) {
console.error("Error updating profile:", error);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour du profil" },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { userService } from "@/services/users/user.service";
import { CharacterClass } from "@/prisma/generated/prisma/enums"; import {
ValidationError,
NotFoundError,
ConflictError,
} from "@/services/errors";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@@ -14,139 +18,10 @@ export async function POST(request: Request) {
); );
} }
// Vérifier que l'utilisateur existe et a été créé récemment (dans les 10 dernières minutes) const updatedUser = await userService.validateAndCompleteRegistration(
const user = await prisma.user.findUnique({ userId,
where: { id: userId }, { username, avatar, bio, characterClass }
});
if (!user) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
); );
}
// Vérifier que le compte a été créé récemment (dans les 10 dernières minutes)
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
if (user.createdAt < tenMinutesAgo) {
return NextResponse.json(
{ error: "Temps écoulé pour finaliser l'inscription" },
{ status: 400 }
);
}
// Validation username
if (username !== undefined) {
if (typeof username !== "string" || username.trim().length === 0) {
return NextResponse.json(
{ error: "Le nom d'utilisateur ne peut pas être vide" },
{ status: 400 }
);
}
if (username.length < 3 || username.length > 20) {
return NextResponse.json(
{
error:
"Le nom d'utilisateur doit contenir entre 3 et 20 caractères",
},
{ status: 400 }
);
}
// Vérifier si le username est déjà pris par un autre utilisateur
const existingUser = await prisma.user.findFirst({
where: {
username: username.trim(),
NOT: { id: userId },
},
});
if (existingUser) {
return NextResponse.json(
{ error: "Ce nom d'utilisateur est déjà pris" },
{ status: 400 }
);
}
}
// Validation bio
if (bio !== undefined && bio !== null) {
if (typeof bio !== "string") {
return NextResponse.json(
{ error: "La bio doit être une chaîne de caractères" },
{ status: 400 }
);
}
if (bio.length > 500) {
return NextResponse.json(
{ error: "La bio ne peut pas dépasser 500 caractères" },
{ status: 400 }
);
}
}
// Validation characterClass
const validClasses = [
"WARRIOR",
"MAGE",
"ROGUE",
"RANGER",
"PALADIN",
"ENGINEER",
"MERCHANT",
"SCHOLAR",
"BERSERKER",
"NECROMANCER",
];
if (characterClass !== undefined && characterClass !== null) {
if (!validClasses.includes(characterClass)) {
return NextResponse.json(
{ error: "Classe de personnage invalide" },
{ status: 400 }
);
}
}
// Mettre à jour l'utilisateur
const updateData: {
username?: string;
avatar?: string | null;
bio?: string | null;
characterClass?: CharacterClass | null;
} = {};
if (username !== undefined) {
updateData.username = username.trim();
}
if (avatar !== undefined) {
updateData.avatar = avatar || null;
}
if (bio !== undefined) {
if (bio === null || bio === "") {
updateData.bio = null;
} else if (typeof bio === "string") {
updateData.bio = bio.trim() || null;
} else {
updateData.bio = null;
}
}
if (characterClass !== undefined) {
updateData.characterClass = (characterClass as CharacterClass) || null;
}
// Si aucun champ à mettre à jour, retourner succès quand même
if (Object.keys(updateData).length === 0) {
return NextResponse.json({
message: "Profil finalisé avec succès",
userId: user.id,
});
}
const updatedUser = await prisma.user.update({
where: { id: userId },
data: updateData,
});
return NextResponse.json({ return NextResponse.json({
message: "Profil finalisé avec succès", message: "Profil finalisé avec succès",
@@ -154,11 +29,20 @@ export async function POST(request: Request) {
}); });
} catch (error) { } catch (error) {
console.error("Error completing registration:", error); console.error("Error completing registration:", error);
const errorMessage =
error instanceof Error ? error.message : "Erreur inconnue"; if (
error instanceof ValidationError ||
error instanceof ConflictError
) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
if (error instanceof NotFoundError) {
return NextResponse.json({ error: error.message }, { status: 404 });
}
return NextResponse.json( return NextResponse.json(
{ {
error: `Erreur lors de la finalisation de l'inscription: ${errorMessage}`, error: `Erreur lors de la finalisation de l'inscription: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
}, },
{ status: 500 } { status: 500 }
); );

View File

@@ -1,73 +1,19 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { userService } from "@/services/users/user.service";
import bcrypt from "bcryptjs"; import { ValidationError, ConflictError } from "@/services/errors";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const { email, username, password, bio, characterClass, avatar } = body; const { email, username, password, bio, characterClass, avatar } = body;
if (!email || !username || !password) { const user = await userService.validateAndCreateUser({
return NextResponse.json(
{ error: "Email, nom d'utilisateur et mot de passe sont requis" },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: "Le mot de passe doit contenir au moins 6 caractères" },
{ status: 400 }
);
}
// Valider characterClass si fourni
const validCharacterClasses = [
"WARRIOR",
"MAGE",
"ROGUE",
"RANGER",
"PALADIN",
"ENGINEER",
"MERCHANT",
"SCHOLAR",
"BERSERKER",
"NECROMANCER",
];
if (characterClass && !validCharacterClasses.includes(characterClass)) {
return NextResponse.json(
{ error: "Classe de personnage invalide" },
{ status: 400 }
);
}
// Vérifier si l'email existe déjà
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ email }, { username }],
},
});
if (existingUser) {
return NextResponse.json(
{ error: "Cet email ou nom d'utilisateur est déjà utilisé" },
{ status: 400 }
);
}
// Hasher le mot de passe
const hashedPassword = await bcrypt.hash(password, 10);
// Créer l'utilisateur
const user = await prisma.user.create({
data: {
email, email,
username, username,
password: hashedPassword, password,
bio: bio || null, bio,
characterClass: characterClass || null, characterClass,
avatar: avatar || null, avatar,
},
}); });
return NextResponse.json( return NextResponse.json(
@@ -76,6 +22,11 @@ export async function POST(request: Request) {
); );
} catch (error) { } catch (error) {
console.error("Registration error:", error); console.error("Registration error:", error);
if (error instanceof ValidationError || error instanceof ConflictError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json( return NextResponse.json(
{ error: "Une erreur est survenue lors de l'inscription" }, { error: "Une erreur est survenue lors de l'inscription" },
{ status: 500 } { status: 500 }

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { userService } from "@/services/users/user.service";
export async function GET( export async function GET(
request: Request, request: Request,
@@ -19,9 +19,7 @@ export async function GET(
return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
} }
const user = await prisma.user.findUnique({ const user = await userService.getUserById(id, {
where: { id },
select: {
id: true, id: true,
username: true, username: true,
avatar: true, avatar: true,
@@ -31,7 +29,6 @@ export async function GET(
maxXp: true, maxXp: true,
level: true, level: true,
score: true, score: true,
},
}); });
if (!user) { if (!user) {

View File

@@ -1,16 +1,15 @@
import NavigationWrapper from "@/components/NavigationWrapper"; import NavigationWrapper from "@/components/NavigationWrapper";
import EventsPageSection from "@/components/EventsPageSection"; import EventsPageSection from "@/components/EventsPageSection";
import { prisma } from "@/lib/prisma"; import { eventService } from "@/services/events/event.service";
import { eventRegistrationService } from "@/services/events/event-registration.service";
import { getBackgroundImage } from "@/lib/preferences"; import { getBackgroundImage } from "@/lib/preferences";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function EventsPage() { export default async function EventsPage() {
const events = await prisma.event.findMany({ const events = await eventService.getAllEvents({
orderBy: { orderBy: { date: "desc" },
date: "desc",
},
}); });
// Sérialiser les dates pour le client // Sérialiser les dates pour le client
@@ -29,14 +28,8 @@ export default async function EventsPage() {
if (session?.user?.id) { if (session?.user?.id) {
// Récupérer toutes les inscriptions (passées et à venir) pour permettre le feedback // Récupérer toutes les inscriptions (passées et à venir) pour permettre le feedback
const allRegistrations = await prisma.eventRegistration.findMany({ const allRegistrations =
where: { await eventRegistrationService.getUserRegistrations(session.user.id);
userId: session.user.id,
},
select: {
eventId: true,
},
});
allRegistrations.forEach((reg) => { allRegistrations.forEach((reg) => {
initialRegistrations[reg.eventId] = true; initialRegistrations[reg.eventId] = true;

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useState, useEffect, type FormEvent } from "react"; import { useState, useEffect, useTransition, type FormEvent } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useRouter, useParams } from "next/navigation"; import { useRouter, useParams } from "next/navigation";
import Navigation from "@/components/Navigation"; import Navigation from "@/components/Navigation";
import { createFeedback } from "@/actions/events/feedback";
interface Event { interface Event {
id: string; id: string;
@@ -38,6 +39,7 @@ export default function FeedbackPageClient({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [, startTransition] = useTransition();
const [rating, setRating] = useState(0); const [rating, setRating] = useState(0);
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
@@ -95,37 +97,38 @@ export default function FeedbackPageClient({
setSubmitting(true); setSubmitting(true);
startTransition(async () => {
try { try {
const response = await fetch(`/api/feedback/${eventId}`, { const result = await createFeedback(eventId, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
rating, rating,
comment: comment.trim() || null, comment: comment.trim() || null,
}),
}); });
const data = await response.json(); if (!result.success) {
setError(result.error || "Erreur lors de l'enregistrement");
if (!response.ok) { setSubmitting(false);
setError(data.error || "Erreur lors de l&apos;enregistrement");
return; return;
} }
setSuccess(true); setSuccess(true);
setExistingFeedback(data.feedback); if (result.data) {
setExistingFeedback({
id: result.data.id,
rating: result.data.rating,
comment: result.data.comment,
});
}
// Rediriger après 2 secondes // Rediriger après 2 secondes
setTimeout(() => { setTimeout(() => {
router.push("/events"); router.push("/events");
}, 2000); }, 2000);
} catch { } catch {
setError("Erreur lors de l&apos;enregistrement"); setError("Erreur lors de l'enregistrement");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
});
}; };
if (status === "loading" || loading) { if (status === "loading" || loading) {
@@ -262,4 +265,3 @@ export default function FeedbackPageClient({
</main> </main>
); );
} }

View File

@@ -1,49 +1,12 @@
import NavigationWrapper from "@/components/NavigationWrapper"; import NavigationWrapper from "@/components/NavigationWrapper";
import LeaderboardSection from "@/components/LeaderboardSection"; import LeaderboardSection from "@/components/LeaderboardSection";
import { prisma } from "@/lib/prisma"; import { userStatsService } from "@/services/users/user-stats.service";
import { getBackgroundImage } from "@/lib/preferences"; import { getBackgroundImage } from "@/lib/preferences";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
interface LeaderboardEntry {
rank: number;
username: string;
email: string;
score: number;
level: number;
avatar: string | null;
bio: string | null;
characterClass: string | null;
}
export default async function LeaderboardPage() { export default async function LeaderboardPage() {
const users = await prisma.user.findMany({ const leaderboard = await userStatsService.getLeaderboard(10);
orderBy: {
score: "desc",
},
take: 10,
select: {
id: true,
username: true,
email: true,
score: true,
level: true,
avatar: true,
bio: true,
characterClass: true,
},
});
const leaderboard: LeaderboardEntry[] = 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,
}));
const backgroundImage = await getBackgroundImage( const backgroundImage = await getBackgroundImage(
"leaderboard", "leaderboard",

View File

@@ -1,18 +1,13 @@
import NavigationWrapper from "@/components/NavigationWrapper"; import NavigationWrapper from "@/components/NavigationWrapper";
import HeroSection from "@/components/HeroSection"; import HeroSection from "@/components/HeroSection";
import EventsSection from "@/components/EventsSection"; import EventsSection from "@/components/EventsSection";
import { prisma } from "@/lib/prisma"; import { eventService } from "@/services/events/event.service";
import { getBackgroundImage } from "@/lib/preferences"; import { getBackgroundImage } from "@/lib/preferences";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function Home() { export default async function Home() {
const events = await prisma.event.findMany({ const events = await eventService.getUpcomingEvents(3);
orderBy: {
date: "asc",
},
take: 3,
});
// Convert Date objects to strings for serialization // Convert Date objects to strings for serialization
const serializedEvents = events.map((event) => ({ const serializedEvents = events.map((event) => ({

View File

@@ -1,6 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { userService } from "@/services/users/user.service";
import { getBackgroundImage } from "@/lib/preferences"; import { getBackgroundImage } from "@/lib/preferences";
import NavigationWrapper from "@/components/NavigationWrapper"; import NavigationWrapper from "@/components/NavigationWrapper";
import ProfileForm from "@/components/ProfileForm"; import ProfileForm from "@/components/ProfileForm";
@@ -12,9 +12,7 @@ export default async function ProfilePage() {
redirect("/login"); redirect("/login");
} }
const user = await prisma.user.findUnique({ const user = await userService.getUserById(session.user.id, {
where: { id: session.user.id },
select: {
id: true, id: true,
email: true, email: true,
username: true, username: true,
@@ -28,7 +26,6 @@ export default async function ProfilePage() {
level: true, level: true,
score: true, score: true,
createdAt: true, createdAt: true,
},
}); });
if (!user) { if (!user) {

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import ImageSelector from "@/components/ImageSelector"; import ImageSelector from "@/components/ImageSelector";
import { updateSitePreferences } from "@/actions/admin/preferences";
interface SitePreferences { interface SitePreferences {
id: string; id: string;
@@ -90,40 +91,33 @@ export default function BackgroundPreferences({
), ),
}; };
const response = await fetch("/api/admin/preferences", { const result = await updateSitePreferences(apiData);
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(apiData),
});
if (response.ok) { if (result.success && result.data) {
const data = await response.json(); setPreferences(result.data);
setPreferences(data);
// Réinitialiser formData avec les nouvelles valeurs (ou images par défaut) // Réinitialiser formData avec les nouvelles valeurs (ou images par défaut)
setFormData({ setFormData({
homeBackground: getFormValue( homeBackground: getFormValue(
data.homeBackground, result.data.homeBackground,
DEFAULT_IMAGES.home DEFAULT_IMAGES.home
), ),
eventsBackground: getFormValue( eventsBackground: getFormValue(
data.eventsBackground, result.data.eventsBackground,
DEFAULT_IMAGES.events DEFAULT_IMAGES.events
), ),
leaderboardBackground: getFormValue( leaderboardBackground: getFormValue(
data.leaderboardBackground, result.data.leaderboardBackground,
DEFAULT_IMAGES.leaderboard DEFAULT_IMAGES.leaderboard
), ),
}); });
setIsEditing(false); setIsEditing(false);
} else { } else {
const errorData = await response.json(); console.error("Error updating preferences:", result.error);
console.error("Error updating preferences:", errorData); alert(result.error || "Erreur lors de la mise à jour");
alert(errorData.error || "Erreur lors de la mise à jour");
} }
} catch (error) { } catch (error) {
console.error("Error updating preferences:", error); console.error("Error updating preferences:", error);
alert("Erreur lors de la mise à jour");
} }
}; };

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useTransition } from "react";
import { calculateEventStatus } from "@/lib/eventStatus"; import { calculateEventStatus } from "@/lib/eventStatus";
import { createEvent, updateEvent, deleteEvent } from "@/actions/admin/events";
interface Event { interface Event {
id: string; id: string;
@@ -124,29 +125,20 @@ export default function EventManagement() {
}); });
}; };
const [, startTransition] = useTransition();
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
startTransition(async () => {
try { try {
let response; let result;
if (isCreating) { if (isCreating) {
response = await fetch("/api/admin/events", { result = await createEvent(formData);
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
} else if (editingEvent) { } else if (editingEvent) {
response = await fetch(`/api/admin/events/${editingEvent.id}`, { result = await updateEvent(editingEvent.id, formData);
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
} }
if (response?.ok) { if (result?.success) {
await fetchEvents(); await fetchEvents();
setEditingEvent(null); setEditingEvent(null);
setIsCreating(false); setIsCreating(false);
@@ -160,8 +152,7 @@ export default function EventManagement() {
maxPlaces: undefined, maxPlaces: undefined,
}); });
} else { } else {
const error = await response?.json(); alert(result?.error || "Erreur lors de la sauvegarde");
alert(error.error || "Erreur lors de la sauvegarde");
} }
} catch (error) { } catch (error) {
console.error("Error saving event:", error); console.error("Error saving event:", error);
@@ -169,6 +160,7 @@ export default function EventManagement() {
} finally { } finally {
setSaving(false); setSaving(false);
} }
});
}; };
const handleDelete = async (eventId: string) => { const handleDelete = async (eventId: string) => {
@@ -176,21 +168,20 @@ export default function EventManagement() {
return; return;
} }
startTransition(async () => {
try { try {
const response = await fetch(`/api/admin/events/${eventId}`, { const result = await deleteEvent(eventId);
method: "DELETE",
});
if (response.ok) { if (result.success) {
await fetchEvents(); await fetchEvents();
} else { } else {
const error = await response.json(); alert(result.error || "Erreur lors de la suppression");
alert(error.error || "Erreur lors de la suppression");
} }
} catch (error) { } catch (error) {
console.error("Error deleting event:", error); console.error("Error deleting event:", error);
alert("Erreur lors de la suppression"); alert("Erreur lors de la suppression");
} }
});
}; };
const handleCancel = () => { const handleCancel = () => {

View File

@@ -1,10 +1,14 @@
"use client"; "use client";
import { useState, useEffect, useMemo, useRef } from "react"; import { useState, useEffect, useMemo, useRef, useTransition } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { calculateEventStatus } from "@/lib/eventStatus"; import { calculateEventStatus } from "@/lib/eventStatus";
import FeedbackModal from "@/components/FeedbackModal"; import FeedbackModal from "@/components/FeedbackModal";
import {
registerForEvent,
unregisterFromEvent,
} from "@/actions/events/register";
interface Event { interface Event {
id: string; id: string;
@@ -526,6 +530,8 @@ export default function EventsPageSection({
</div> </div>
); );
const [, startTransition] = useTransition();
const handleRegister = async (eventId: string) => { const handleRegister = async (eventId: string) => {
if (!session?.user?.id) { if (!session?.user?.id) {
router.push("/login"); router.push("/login");
@@ -535,53 +541,38 @@ export default function EventsPageSection({
setLoading((prev) => ({ ...prev, [eventId]: true })); setLoading((prev) => ({ ...prev, [eventId]: true }));
setError(""); setError("");
try { startTransition(async () => {
const response = await fetch(`/api/events/${eventId}/register`, { const result = await registerForEvent(eventId);
method: "POST",
});
const data = await response.json();
if (!response.ok) {
setError(data.error || "Une erreur est survenue");
return;
}
if (result.success) {
setRegistrations((prev) => ({ setRegistrations((prev) => ({
...prev, ...prev,
[eventId]: true, [eventId]: true,
})); }));
} catch { } else {
setError("Une erreur est survenue"); setError(result.error || "Une erreur est survenue");
} finally {
setLoading((prev) => ({ ...prev, [eventId]: false }));
} }
setLoading((prev) => ({ ...prev, [eventId]: false }));
});
}; };
const handleUnregister = async (eventId: string) => { const handleUnregister = async (eventId: string) => {
setLoading((prev) => ({ ...prev, [eventId]: true })); setLoading((prev) => ({ ...prev, [eventId]: true }));
setError(""); setError("");
try { startTransition(async () => {
const response = await fetch(`/api/events/${eventId}/register`, { const result = await unregisterFromEvent(eventId);
method: "DELETE",
});
if (!response.ok) {
const data = await response.json();
setError(data.error || "Une erreur est survenue");
return;
}
if (result.success) {
setRegistrations((prev) => ({ setRegistrations((prev) => ({
...prev, ...prev,
[eventId]: false, [eventId]: false,
})); }));
} catch { } else {
setError("Une erreur est survenue"); setError(result.error || "Une erreur est survenue");
} finally {
setLoading((prev) => ({ ...prev, [eventId]: false }));
} }
setLoading((prev) => ({ ...prev, [eventId]: false }));
});
}; };
return ( return (

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import { useState, useEffect, type FormEvent } from "react"; import { useState, useEffect, useTransition, type FormEvent } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { createFeedback } from "@/actions/events/feedback";
interface Event { interface Event {
id: string; id: string;
@@ -36,6 +37,7 @@ export default function FeedbackModal({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [, startTransition] = useTransition();
const [rating, setRating] = useState(0); const [rating, setRating] = useState(0);
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
@@ -118,27 +120,27 @@ export default function FeedbackModal({
setSubmitting(true); setSubmitting(true);
startTransition(async () => {
try { try {
const response = await fetch(`/api/feedback/${eventId}`, { const result = await createFeedback(eventId, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
rating, rating,
comment: comment.trim() || null, comment: comment.trim() || null,
}),
}); });
const data = await response.json(); if (!result.success) {
setError(result.error || "Erreur lors de l'enregistrement");
if (!response.ok) { setSubmitting(false);
setError(data.error || "Erreur lors de l'enregistrement");
return; return;
} }
setSuccess(true); setSuccess(true);
setExistingFeedback(data.feedback); if (result.data) {
setExistingFeedback({
id: result.data.id,
rating: result.data.rating,
comment: result.data.comment,
});
}
// Fermer la modale après 1.5 secondes // Fermer la modale après 1.5 secondes
setTimeout(() => { setTimeout(() => {
@@ -149,6 +151,7 @@ export default function FeedbackModal({
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
});
}; };
const handleClose = () => { const handleClose = () => {

View File

@@ -1,5 +1,5 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { userService } from "@/services/users/user.service";
import Navigation from "./Navigation"; import Navigation from "./Navigation";
interface UserData { interface UserData {
@@ -19,9 +19,7 @@ export default async function NavigationWrapper() {
const isAdmin = session?.user?.role === "ADMIN"; const isAdmin = session?.user?.role === "ADMIN";
if (session?.user?.id) { if (session?.user?.id) {
const user = await prisma.user.findUnique({ const user = await userService.getUserById(session.user.id, {
where: { id: session.user.id },
select: {
username: true, username: true,
avatar: true, avatar: true,
hp: true, hp: true,
@@ -29,7 +27,6 @@ export default async function NavigationWrapper() {
xp: true, xp: true,
maxXp: true, maxXp: true,
level: true, level: true,
},
}); });
if (user) { if (user) {

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { useState, useRef, type ChangeEvent } from "react"; import { useState, useRef, useTransition, type ChangeEvent } from "react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import { updateProfile } from "@/actions/profile/update-profile";
import { updatePassword } from "@/actions/profile/update-password";
type CharacterClass = type CharacterClass =
| "WARRIOR" | "WARRIOR"
@@ -46,7 +48,7 @@ export default function ProfileForm({
backgroundImage, backgroundImage,
}: ProfileFormProps) { }: ProfileFormProps) {
const [profile, setProfile] = useState<UserProfile>(initialProfile); const [profile, setProfile] = useState<UserProfile>(initialProfile);
const [saving, setSaving] = useState(false); const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
@@ -64,7 +66,7 @@ export default function ProfileForm({
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [changingPassword, setChangingPassword] = useState(false); const [isChangingPassword, startPasswordTransition] = useTransition();
const handleAvatarUpload = async (e: ChangeEvent<HTMLInputElement>) => { const handleAvatarUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -104,81 +106,57 @@ export default function ProfileForm({
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSaving(true);
setError(null); setError(null);
setSuccess(null); setSuccess(null);
try { startTransition(async () => {
const response = await fetch("/api/profile", { const result = await updateProfile({
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username, username,
avatar, avatar,
bio, bio,
characterClass, characterClass,
}),
}); });
if (response.ok) { if (result.success && result.data) {
const data = await response.json(); setProfile({
setProfile(data); ...result.data,
setBio(data.bio || null); createdAt: result.data.createdAt instanceof Date
setCharacterClass(data.characterClass || null); ? result.data.createdAt.toISOString()
: result.data.createdAt,
} as UserProfile);
setBio(result.data.bio || null);
setCharacterClass(result.data.characterClass as CharacterClass || null);
setSuccess("Profil mis à jour avec succès"); setSuccess("Profil mis à jour avec succès");
setTimeout(() => setSuccess(null), 3000); setTimeout(() => setSuccess(null), 3000);
} else { } else {
const errorData = await response.json(); setError(result.error || "Erreur lors de la mise à jour");
setError(errorData.error || "Erreur lors de la mise à jour");
}
} catch (err) {
console.error("Error updating profile:", err);
setError("Erreur lors de la mise à jour du profil");
} finally {
setSaving(false);
} }
});
}; };
const handlePasswordChange = async (e: React.FormEvent) => { const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setChangingPassword(true);
setError(null); setError(null);
setSuccess(null); setSuccess(null);
try { startPasswordTransition(async () => {
const response = await fetch("/api/profile/password", { const result = await updatePassword({
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
currentPassword, currentPassword,
newPassword, newPassword,
confirmPassword, confirmPassword,
}),
}); });
if (response.ok) { if (result.success) {
setSuccess("Mot de passe modifié avec succès"); setSuccess(result.message || "Mot de passe modifié avec succès");
setCurrentPassword(""); setCurrentPassword("");
setNewPassword(""); setNewPassword("");
setConfirmPassword(""); setConfirmPassword("");
setShowPasswordForm(false); setShowPasswordForm(false);
setTimeout(() => setSuccess(null), 3000); setTimeout(() => setSuccess(null), 3000);
} else { } else {
const errorData = await response.json(); setError(result.error || "Erreur lors de la modification du mot de passe");
setError(
errorData.error || "Erreur lors de la modification du mot de passe"
);
}
} catch (err) {
console.error("Error changing password:", err);
setError("Erreur lors de la modification du mot de passe");
} finally {
setChangingPassword(false);
} }
});
}; };
const hpPercentage = (profile.hp / profile.maxHp) * 100; const hpPercentage = (profile.hp / profile.maxHp) * 100;
@@ -529,10 +507,10 @@ export default function ProfileForm({
<div className="flex justify-end gap-4 pt-4 border-t border-pixel-gold/20"> <div className="flex justify-end gap-4 pt-4 border-t border-pixel-gold/20">
<button <button
type="submit" type="submit"
disabled={saving} disabled={isPending}
className="px-6 py-2 border border-pixel-gold/50 bg-black/40 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition disabled:opacity-50 disabled:cursor-not-allowed" className="px-6 py-2 border border-pixel-gold/50 bg-black/40 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition disabled:opacity-50 disabled:cursor-not-allowed"
> >
{saving ? "Enregistrement..." : "Enregistrer les modifications"} {isPending ? "Enregistrement..." : "Enregistrer les modifications"}
</button> </button>
</div> </div>
</form> </form>
@@ -616,10 +594,10 @@ export default function ProfileForm({
</button> </button>
<button <button
type="submit" type="submit"
disabled={changingPassword} disabled={isChangingPassword}
className="px-4 py-2 border border-pixel-gold/50 bg-black/40 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 border border-pixel-gold/50 bg-black/40 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition disabled:opacity-50 disabled:cursor-not-allowed"
> >
{changingPassword {isChangingPassword
? "Modification..." ? "Modification..."
: "Modifier le mot de passe"} : "Modifier le mot de passe"}
</button> </button>

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useTransition } from "react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import { updateUser, deleteUser } from "@/actions/admin/users";
interface User { interface User {
id: string; id: string;
@@ -35,6 +36,7 @@ export default function UserManagement() {
const [editingUser, setEditingUser] = useState<EditingUser | null>(null); const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deletingUserId, setDeletingUserId] = useState<string | null>(null); const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [uploadingAvatar, setUploadingAvatar] = useState<string | null>(null); const [uploadingAvatar, setUploadingAvatar] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -72,6 +74,7 @@ export default function UserManagement() {
if (!editingUser) return; if (!editingUser) return;
setSaving(true); setSaving(true);
startTransition(async () => {
try { try {
const body: { const body: {
username?: string; username?: string;
@@ -105,20 +108,13 @@ export default function UserManagement() {
body.role = editingUser.role; body.role = editingUser.role;
} }
const response = await fetch(`/api/admin/users/${editingUser.userId}`, { const result = await updateUser(editingUser.userId, body);
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (response.ok) { if (result.success) {
await fetchUsers(); await fetchUsers();
setEditingUser(null); setEditingUser(null);
} else { } else {
const error = await response.json(); alert(result.error || "Erreur lors de la mise à jour");
alert(error.error || "Erreur lors de la mise à jour");
} }
} catch (error) { } catch (error) {
console.error("Error updating user:", error); console.error("Error updating user:", error);
@@ -126,6 +122,7 @@ export default function UserManagement() {
} finally { } finally {
setSaving(false); setSaving(false);
} }
});
}; };
const handleCancel = () => { const handleCancel = () => {
@@ -143,15 +140,12 @@ export default function UserManagement() {
setDeletingUserId(userId); setDeletingUserId(userId);
try { try {
const response = await fetch(`/api/admin/users/${userId}`, { const result = await deleteUser(userId);
method: "DELETE",
});
if (response.ok) { if (result.success) {
await fetchUsers(); await fetchUsers();
} else { } else {
const error = await response.json(); alert(result.error || "Erreur lors de la suppression");
alert(error.error || "Erreur lors de la suppression");
} }
} catch (error) { } catch (error) {
console.error("Error deleting user:", error); console.error("Error deleting user:", error);

View File

@@ -1,7 +1,6 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials"; import Credentials from "next-auth/providers/credentials";
import { prisma } from "./prisma"; import { userService } from "@/services/users/user.service";
import bcrypt from "bcryptjs";
import type { Role } from "@/prisma/generated/prisma/client"; import type { Role } from "@/prisma/generated/prisma/client";
export const { handlers, signIn, signOut, auth } = NextAuth({ export const { handlers, signIn, signOut, auth } = NextAuth({
@@ -17,20 +16,12 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
return null; return null;
} }
const user = await prisma.user.findUnique({ const user = await userService.verifyCredentials(
where: { email: credentials.email as string }, credentials.email as string,
}); credentials.password as string
if (!user) {
return null;
}
const isPasswordValid = await bcrypt.compare(
credentials.password as string,
user.password
); );
if (!isPasswordValid) { if (!user) {
return null; return null;
} }

View File

@@ -1,27 +1,8 @@
import { prisma } from "@/lib/prisma"; import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
import { normalizeBackgroundUrl } from "@/lib/avatars";
export async function getBackgroundImage( export async function getBackgroundImage(
page: "home" | "events" | "leaderboard", page: "home" | "events" | "leaderboard",
defaultImage: string defaultImage: string
): Promise<string> { ): Promise<string> {
try { return sitePreferencesService.getBackgroundImage(page, defaultImage);
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;
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

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,131 @@
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,
},
});
}
/**
* 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,
Prisma,
} from "@/prisma/generated/prisma/client";
import { EventType } 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,252 @@
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 {
const user = await userService.getUserById(id, selectFields);
if (!user) {
throw new NotFoundError("Utilisateur");
}
updatedUser = user;
}
// 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,514 @@
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();