Compare commits
3 Commits
ce0697a908
...
db01c25de7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db01c25de7 | ||
|
|
494ac3f503 | ||
|
|
fd095246a3 |
32
.cursor/rules/api-routes.mdc
Normal file
32
.cursor/rules/api-routes.mdc
Normal 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
|
||||||
167
.cursor/rules/business-logic-separation.mdc
Normal file
167
.cursor/rules/business-logic-separation.mdc
Normal 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
31
.cursor/rules/clients.mdc
Normal 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
|
||||||
28
.cursor/rules/components.mdc
Normal file
28
.cursor/rules/components.mdc
Normal 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
|
||||||
167
.cursor/rules/css-variables-theme.mdc
Normal file
167
.cursor/rules/css-variables-theme.mdc
Normal 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.**
|
||||||
30
.cursor/rules/project-structure.mdc
Normal file
30
.cursor/rules/project-structure.mdc
Normal 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/
|
||||||
113
.cursor/rules/server-actions.mdc
Normal file
113
.cursor/rules/server-actions.mdc
Normal 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**.
|
||||||
42
.cursor/rules/services.mdc
Normal file
42
.cursor/rules/services.mdc
Normal 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
|
||||||
54
.cursor/rules/todo-tracking.mdc
Normal file
54
.cursor/rules/todo-tracking.mdc
Normal 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
131
actions/admin/events.ts
Normal 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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
48
actions/admin/preferences.ts
Normal file
48
actions/admin/preferences.ts
Normal 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
116
actions/admin/users.ts
Normal 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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
45
actions/events/feedback.ts
Normal file
45
actions/events/feedback.ts
Normal 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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
65
actions/events/register.ts
Normal file
65
actions/events/register.ts
Normal 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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
46
actions/profile/update-password.ts
Normal file
46
actions/profile/update-password.ts
Normal 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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
63
actions/profile/update-profile.ts
Normal file
63
actions/profile/update-profile.ts
Normal 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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
19
lib/auth.ts
19
lib/auth.ts
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
7
services/database.ts
Normal 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
31
services/errors.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
179
services/events/event-feedback.service.ts
Normal file
179
services/events/event-feedback.service.ts
Normal 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();
|
||||||
131
services/events/event-registration.service.ts
Normal file
131
services/events/event-registration.service.ts
Normal 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();
|
||||||
278
services/events/event.service.ts
Normal file
278
services/events/event.service.ts
Normal 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();
|
||||||
111
services/preferences/site-preferences.service.ts
Normal file
111
services/preferences/site-preferences.service.ts
Normal 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();
|
||||||
252
services/users/user-stats.service.ts
Normal file
252
services/users/user-stats.service.ts
Normal 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();
|
||||||
514
services/users/user.service.ts
Normal file
514
services/users/user.service.ts
Normal 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();
|
||||||
Reference in New Issue
Block a user