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 { 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 NavigationWrapper from "@/components/NavigationWrapper";
|
||||
import AdminPanel from "@/components/AdminPanel";
|
||||
@@ -18,22 +18,9 @@ export default async function AdminPage() {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
// Récupérer les préférences globales du site
|
||||
let sitePreferences = await prisma.sitePreferences.findUnique({
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Récupérer les préférences globales du site (ou créer si elles n'existent pas)
|
||||
const sitePreferences =
|
||||
await sitePreferencesService.getOrCreateSitePreferences();
|
||||
|
||||
return (
|
||||
<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 { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Role, EventType } from "@/prisma/generated/prisma/client";
|
||||
import { calculateEventStatus } from "@/lib/eventStatus";
|
||||
import { eventService } from "@/services/events/event.service";
|
||||
import { Role } from "@/prisma/generated/prisma/client";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -12,34 +11,22 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
registrations: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const events = await eventService.getEventsWithStatus();
|
||||
|
||||
// Transformer les données pour inclure le nombre d'inscriptions
|
||||
// Le statut est calculé automatiquement en fonction de la date
|
||||
// Transformer les données pour la sérialisation
|
||||
const eventsWithCount = events.map((event) => ({
|
||||
id: event.id,
|
||||
date: event.date.toISOString(),
|
||||
name: event.name,
|
||||
description: event.description,
|
||||
type: event.type,
|
||||
status: calculateEventStatus(event.date),
|
||||
status: event.status,
|
||||
room: event.room,
|
||||
time: event.time,
|
||||
maxPlaces: event.maxPlaces,
|
||||
createdAt: event.createdAt.toISOString(),
|
||||
updatedAt: event.updatedAt.toISOString(),
|
||||
registrationsCount: event._count.registrations,
|
||||
registrationsCount: event.registrationsCount,
|
||||
}));
|
||||
|
||||
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 { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { eventFeedbackService } from "@/services/events/event-feedback.service";
|
||||
import { Role } from "@/prisma/generated/prisma/client";
|
||||
|
||||
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
|
||||
const feedbacks = await prisma.eventFeedback.findMany({
|
||||
include: {
|
||||
event: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
date: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
const feedbacks = await eventFeedbackService.getAllFeedbacks();
|
||||
|
||||
// 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
|
||||
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,
|
||||
};
|
||||
});
|
||||
const statistics = await eventFeedbackService.getFeedbackStatistics();
|
||||
|
||||
return NextResponse.json({
|
||||
feedbacks,
|
||||
statistics: statsWithDetails,
|
||||
statistics,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching feedbacks:", error);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
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";
|
||||
|
||||
export async function GET() {
|
||||
@@ -11,22 +11,8 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Récupérer les préférences globales du site
|
||||
let sitePreferences = await prisma.sitePreferences.findUnique({
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Récupérer les préférences globales du site (ou créer si elles n'existent pas)
|
||||
const sitePreferences = await sitePreferencesService.getOrCreateSitePreferences();
|
||||
|
||||
return NextResponse.json(sitePreferences);
|
||||
} 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 { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
import { Role } from "@/prisma/generated/prisma/client";
|
||||
|
||||
export async function GET() {
|
||||
@@ -12,7 +12,10 @@ export async function GET() {
|
||||
}
|
||||
|
||||
// Récupérer tous les utilisateurs avec leurs stats
|
||||
const users = await prisma.user.findMany({
|
||||
const users = await userService.getAllUsers({
|
||||
orderBy: {
|
||||
score: "desc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
@@ -27,9 +30,6 @@ export async function GET() {
|
||||
avatar: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
score: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(users);
|
||||
|
||||
@@ -1,115 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { calculateEventStatus } from "@/lib/eventStatus";
|
||||
import { eventRegistrationService } from "@/services/events/event-registration.service";
|
||||
|
||||
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(
|
||||
request: Request,
|
||||
@@ -124,16 +16,12 @@ export async function GET(
|
||||
|
||||
const { id: eventId } = await params;
|
||||
|
||||
const registration = await prisma.eventRegistration.findUnique({
|
||||
where: {
|
||||
userId_eventId: {
|
||||
userId: session.user.id,
|
||||
eventId: eventId,
|
||||
},
|
||||
},
|
||||
});
|
||||
const isRegistered = await eventRegistrationService.checkUserRegistration(
|
||||
session.user.id,
|
||||
eventId
|
||||
);
|
||||
|
||||
return NextResponse.json({ registered: !!registration });
|
||||
return NextResponse.json({ registered: isRegistered });
|
||||
} catch (error) {
|
||||
console.error("Check registration error:", error);
|
||||
return NextResponse.json({ registered: false });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { eventService } from "@/services/events/event.service";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@@ -8,9 +8,7 @@ export async function GET(
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
const event = await eventService.getEventById(id);
|
||||
|
||||
if (!event) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { eventService } from "@/services/events/event.service";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const events = await prisma.event.findMany({
|
||||
orderBy: {
|
||||
date: "asc",
|
||||
},
|
||||
const events = await eventService.getAllEvents({
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(events);
|
||||
|
||||
@@ -1,70 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
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(
|
||||
request: Request,
|
||||
@@ -79,23 +16,10 @@ export async function GET(
|
||||
const { eventId } = await params;
|
||||
|
||||
// Récupérer le feedback de l'utilisateur pour cet événement
|
||||
const feedback = await prisma.eventFeedback.findUnique({
|
||||
where: {
|
||||
userId_eventId: {
|
||||
userId: session.user.id,
|
||||
eventId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
event: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
date: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const feedback = await eventFeedbackService.getUserFeedback(
|
||||
session.user.id,
|
||||
eventId
|
||||
);
|
||||
|
||||
return NextResponse.json({ feedback });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,49 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { userStatsService } from "@/services/users/user-stats.service";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
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,
|
||||
})
|
||||
);
|
||||
const leaderboard = await userStatsService.getLeaderboard(10);
|
||||
|
||||
return NextResponse.json(leaderboard);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Récupérer les préférences globales du site (pas besoin d'authentification)
|
||||
let sitePreferences = await prisma.sitePreferences.findUnique({
|
||||
where: { id: "global" },
|
||||
});
|
||||
const sitePreferences = await sitePreferencesService.getSitePreferences();
|
||||
|
||||
// Si elles n'existent pas, retourner des valeurs par défaut
|
||||
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 { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { CharacterClass } from "@/prisma/generated/prisma/enums";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -11,23 +10,20 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
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,
|
||||
createdAt: true,
|
||||
},
|
||||
const user = await userService.getUserById(session.user.id, {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
bio: true,
|
||||
characterClass: true,
|
||||
hp: true,
|
||||
maxHp: true,
|
||||
xp: true,
|
||||
maxXp: true,
|
||||
level: true,
|
||||
score: true,
|
||||
createdAt: true,
|
||||
});
|
||||
|
||||
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 { prisma } from "@/lib/prisma";
|
||||
import { CharacterClass } from "@/prisma/generated/prisma/enums";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
import {
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
} from "@/services/errors";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
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 user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
const updatedUser = await userService.validateAndCompleteRegistration(
|
||||
userId,
|
||||
{ username, avatar, bio, characterClass }
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Profil finalisé avec succès",
|
||||
@@ -154,11 +29,20 @@ export async function POST(request: Request) {
|
||||
});
|
||||
} catch (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(
|
||||
{
|
||||
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 }
|
||||
);
|
||||
|
||||
@@ -1,73 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
import { ValidationError, ConflictError } from "@/services/errors";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, username, password, bio, characterClass, avatar } = body;
|
||||
|
||||
if (!email || !username || !password) {
|
||||
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,
|
||||
username,
|
||||
password: hashedPassword,
|
||||
bio: bio || null,
|
||||
characterClass: characterClass || null,
|
||||
avatar: avatar || null,
|
||||
},
|
||||
const user = await userService.validateAndCreateUser({
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
bio,
|
||||
characterClass,
|
||||
avatar,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
@@ -76,6 +22,11 @@ export async function POST(request: Request) {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
|
||||
if (error instanceof ValidationError || error instanceof ConflictError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Une erreur est survenue lors de l'inscription" },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@@ -19,19 +19,16 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
hp: true,
|
||||
maxHp: true,
|
||||
xp: true,
|
||||
maxXp: true,
|
||||
level: true,
|
||||
score: true,
|
||||
},
|
||||
const user = await userService.getUserById(id, {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
hp: true,
|
||||
maxHp: true,
|
||||
xp: true,
|
||||
maxXp: true,
|
||||
level: true,
|
||||
score: true,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import NavigationWrapper from "@/components/NavigationWrapper";
|
||||
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 { auth } from "@/lib/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EventsPage() {
|
||||
const events = await prisma.event.findMany({
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
const events = await eventService.getAllEvents({
|
||||
orderBy: { date: "desc" },
|
||||
});
|
||||
|
||||
// Sérialiser les dates pour le client
|
||||
@@ -29,14 +28,8 @@ export default async function EventsPage() {
|
||||
|
||||
if (session?.user?.id) {
|
||||
// Récupérer toutes les inscriptions (passées et à venir) pour permettre le feedback
|
||||
const allRegistrations = await prisma.eventRegistration.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
},
|
||||
select: {
|
||||
eventId: true,
|
||||
},
|
||||
});
|
||||
const allRegistrations =
|
||||
await eventRegistrationService.getUserRegistrations(session.user.id);
|
||||
|
||||
allRegistrations.forEach((reg) => {
|
||||
initialRegistrations[reg.eventId] = true;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type FormEvent } from "react";
|
||||
import { useState, useEffect, useTransition, type FormEvent } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import Navigation from "@/components/Navigation";
|
||||
import { createFeedback } from "@/actions/events/feedback";
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
@@ -38,6 +39,7 @@ export default function FeedbackPageClient({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [rating, setRating] = useState(0);
|
||||
const [comment, setComment] = useState("");
|
||||
@@ -95,37 +97,38 @@ export default function FeedbackPageClient({
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/feedback/${eventId}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await createFeedback(eventId, {
|
||||
rating,
|
||||
comment: comment.trim() || null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!result.success) {
|
||||
setError(result.error || "Erreur lors de l'enregistrement");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || "Erreur lors de l'enregistrement");
|
||||
return;
|
||||
setSuccess(true);
|
||||
if (result.data) {
|
||||
setExistingFeedback({
|
||||
id: result.data.id,
|
||||
rating: result.data.rating,
|
||||
comment: result.data.comment,
|
||||
});
|
||||
}
|
||||
|
||||
// Rediriger après 2 secondes
|
||||
setTimeout(() => {
|
||||
router.push("/events");
|
||||
}, 2000);
|
||||
} catch {
|
||||
setError("Erreur lors de l'enregistrement");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setExistingFeedback(data.feedback);
|
||||
|
||||
// Rediriger après 2 secondes
|
||||
setTimeout(() => {
|
||||
router.push("/events");
|
||||
}, 2000);
|
||||
} catch {
|
||||
setError("Erreur lors de l'enregistrement");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (status === "loading" || loading) {
|
||||
@@ -262,4 +265,3 @@ export default function FeedbackPageClient({
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +1,12 @@
|
||||
import NavigationWrapper from "@/components/NavigationWrapper";
|
||||
import LeaderboardSection from "@/components/LeaderboardSection";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { userStatsService } from "@/services/users/user-stats.service";
|
||||
import { getBackgroundImage } from "@/lib/preferences";
|
||||
|
||||
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() {
|
||||
const users = await prisma.user.findMany({
|
||||
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 leaderboard = await userStatsService.getLeaderboard(10);
|
||||
|
||||
const backgroundImage = await getBackgroundImage(
|
||||
"leaderboard",
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import NavigationWrapper from "@/components/NavigationWrapper";
|
||||
import HeroSection from "@/components/HeroSection";
|
||||
import EventsSection from "@/components/EventsSection";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { eventService } from "@/services/events/event.service";
|
||||
import { getBackgroundImage } from "@/lib/preferences";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Home() {
|
||||
const events = await prisma.event.findMany({
|
||||
orderBy: {
|
||||
date: "asc",
|
||||
},
|
||||
take: 3,
|
||||
});
|
||||
const events = await eventService.getUpcomingEvents(3);
|
||||
|
||||
// Convert Date objects to strings for serialization
|
||||
const serializedEvents = events.map((event) => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
import { getBackgroundImage } from "@/lib/preferences";
|
||||
import NavigationWrapper from "@/components/NavigationWrapper";
|
||||
import ProfileForm from "@/components/ProfileForm";
|
||||
@@ -12,23 +12,20 @@ export default async function ProfilePage() {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
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,
|
||||
createdAt: true,
|
||||
},
|
||||
const user = await userService.getUserById(session.user.id, {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
bio: true,
|
||||
characterClass: true,
|
||||
hp: true,
|
||||
maxHp: true,
|
||||
xp: true,
|
||||
maxXp: true,
|
||||
level: true,
|
||||
score: true,
|
||||
createdAt: true,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import ImageSelector from "@/components/ImageSelector";
|
||||
import { updateSitePreferences } from "@/actions/admin/preferences";
|
||||
|
||||
interface SitePreferences {
|
||||
id: string;
|
||||
@@ -90,40 +91,33 @@ export default function BackgroundPreferences({
|
||||
),
|
||||
};
|
||||
|
||||
const response = await fetch("/api/admin/preferences", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(apiData),
|
||||
});
|
||||
const result = await updateSitePreferences(apiData);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPreferences(data);
|
||||
if (result.success && result.data) {
|
||||
setPreferences(result.data);
|
||||
// Réinitialiser formData avec les nouvelles valeurs (ou images par défaut)
|
||||
setFormData({
|
||||
homeBackground: getFormValue(
|
||||
data.homeBackground,
|
||||
result.data.homeBackground,
|
||||
DEFAULT_IMAGES.home
|
||||
),
|
||||
eventsBackground: getFormValue(
|
||||
data.eventsBackground,
|
||||
result.data.eventsBackground,
|
||||
DEFAULT_IMAGES.events
|
||||
),
|
||||
leaderboardBackground: getFormValue(
|
||||
data.leaderboardBackground,
|
||||
result.data.leaderboardBackground,
|
||||
DEFAULT_IMAGES.leaderboard
|
||||
),
|
||||
});
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error("Error updating preferences:", errorData);
|
||||
alert(errorData.error || "Erreur lors de la mise à jour");
|
||||
console.error("Error updating preferences:", result.error);
|
||||
alert(result.error || "Erreur lors de la mise à jour");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating preferences:", error);
|
||||
alert("Erreur lors de la mise à jour");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import { calculateEventStatus } from "@/lib/eventStatus";
|
||||
import { createEvent, updateEvent, deleteEvent } from "@/actions/admin/events";
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
@@ -124,51 +125,42 @@ export default function EventManagement() {
|
||||
});
|
||||
};
|
||||
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
let response;
|
||||
if (isCreating) {
|
||||
response = await fetch("/api/admin/events", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
} else if (editingEvent) {
|
||||
response = await fetch(`/api/admin/events/${editingEvent.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
let result;
|
||||
if (isCreating) {
|
||||
result = await createEvent(formData);
|
||||
} else if (editingEvent) {
|
||||
result = await updateEvent(editingEvent.id, formData);
|
||||
}
|
||||
|
||||
if (response?.ok) {
|
||||
await fetchEvents();
|
||||
setEditingEvent(null);
|
||||
setIsCreating(false);
|
||||
setFormData({
|
||||
date: "",
|
||||
name: "",
|
||||
description: "",
|
||||
type: "ATELIER",
|
||||
room: "",
|
||||
time: "",
|
||||
maxPlaces: undefined,
|
||||
});
|
||||
} else {
|
||||
const error = await response?.json();
|
||||
alert(error.error || "Erreur lors de la sauvegarde");
|
||||
if (result?.success) {
|
||||
await fetchEvents();
|
||||
setEditingEvent(null);
|
||||
setIsCreating(false);
|
||||
setFormData({
|
||||
date: "",
|
||||
name: "",
|
||||
description: "",
|
||||
type: "ATELIER",
|
||||
room: "",
|
||||
time: "",
|
||||
maxPlaces: undefined,
|
||||
});
|
||||
} else {
|
||||
alert(result?.error || "Erreur lors de la sauvegarde");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving event:", error);
|
||||
alert("Erreur lors de la sauvegarde");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving event:", error);
|
||||
alert("Erreur lors de la sauvegarde");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (eventId: string) => {
|
||||
@@ -176,21 +168,20 @@ export default function EventManagement() {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/events/${eventId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteEvent(eventId);
|
||||
|
||||
if (response.ok) {
|
||||
await fetchEvents();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || "Erreur lors de la suppression");
|
||||
if (result.success) {
|
||||
await fetchEvents();
|
||||
} else {
|
||||
alert(result.error || "Erreur lors de la suppression");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting event:", error);
|
||||
alert("Erreur lors de la suppression");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting event:", error);
|
||||
alert("Erreur lors de la suppression");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { useState, useEffect, useMemo, useRef, useTransition } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { calculateEventStatus } from "@/lib/eventStatus";
|
||||
import FeedbackModal from "@/components/FeedbackModal";
|
||||
import {
|
||||
registerForEvent,
|
||||
unregisterFromEvent,
|
||||
} from "@/actions/events/register";
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
@@ -526,6 +530,8 @@ export default function EventsPageSection({
|
||||
</div>
|
||||
);
|
||||
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const handleRegister = async (eventId: string) => {
|
||||
if (!session?.user?.id) {
|
||||
router.push("/login");
|
||||
@@ -535,53 +541,38 @@ export default function EventsPageSection({
|
||||
setLoading((prev) => ({ ...prev, [eventId]: true }));
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/events/${eventId}/register`, {
|
||||
method: "POST",
|
||||
});
|
||||
startTransition(async () => {
|
||||
const result = await registerForEvent(eventId);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || "Une erreur est survenue");
|
||||
return;
|
||||
if (result.success) {
|
||||
setRegistrations((prev) => ({
|
||||
...prev,
|
||||
[eventId]: true,
|
||||
}));
|
||||
} else {
|
||||
setError(result.error || "Une erreur est survenue");
|
||||
}
|
||||
|
||||
setRegistrations((prev) => ({
|
||||
...prev,
|
||||
[eventId]: true,
|
||||
}));
|
||||
} catch {
|
||||
setError("Une erreur est survenue");
|
||||
} finally {
|
||||
setLoading((prev) => ({ ...prev, [eventId]: false }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnregister = async (eventId: string) => {
|
||||
setLoading((prev) => ({ ...prev, [eventId]: true }));
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/events/${eventId}/register`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
startTransition(async () => {
|
||||
const result = await unregisterFromEvent(eventId);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.error || "Une erreur est survenue");
|
||||
return;
|
||||
if (result.success) {
|
||||
setRegistrations((prev) => ({
|
||||
...prev,
|
||||
[eventId]: false,
|
||||
}));
|
||||
} else {
|
||||
setError(result.error || "Une erreur est survenue");
|
||||
}
|
||||
|
||||
setRegistrations((prev) => ({
|
||||
...prev,
|
||||
[eventId]: false,
|
||||
}));
|
||||
} catch {
|
||||
setError("Une erreur est survenue");
|
||||
} finally {
|
||||
setLoading((prev) => ({ ...prev, [eventId]: false }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type FormEvent } from "react";
|
||||
import { useState, useEffect, useTransition, type FormEvent } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { createFeedback } from "@/actions/events/feedback";
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
@@ -36,6 +37,7 @@ export default function FeedbackModal({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [rating, setRating] = useState(0);
|
||||
const [comment, setComment] = useState("");
|
||||
@@ -118,37 +120,38 @@ export default function FeedbackModal({
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/feedback/${eventId}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await createFeedback(eventId, {
|
||||
rating,
|
||||
comment: comment.trim() || null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!result.success) {
|
||||
setError(result.error || "Erreur lors de l'enregistrement");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || "Erreur lors de l'enregistrement");
|
||||
return;
|
||||
setSuccess(true);
|
||||
if (result.data) {
|
||||
setExistingFeedback({
|
||||
id: result.data.id,
|
||||
rating: result.data.rating,
|
||||
comment: result.data.comment,
|
||||
});
|
||||
}
|
||||
|
||||
// Fermer la modale après 1.5 secondes
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch {
|
||||
setError("Erreur lors de l'enregistrement");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setExistingFeedback(data.feedback);
|
||||
|
||||
// Fermer la modale après 1.5 secondes
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch {
|
||||
setError("Erreur lors de l'enregistrement");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
import Navigation from "./Navigation";
|
||||
|
||||
interface UserData {
|
||||
@@ -19,17 +19,14 @@ export default async function NavigationWrapper() {
|
||||
const isAdmin = session?.user?.role === "ADMIN";
|
||||
|
||||
if (session?.user?.id) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: {
|
||||
username: true,
|
||||
avatar: true,
|
||||
hp: true,
|
||||
maxHp: true,
|
||||
xp: true,
|
||||
maxXp: true,
|
||||
level: true,
|
||||
},
|
||||
const user = await userService.getUserById(session.user.id, {
|
||||
username: true,
|
||||
avatar: true,
|
||||
hp: true,
|
||||
maxHp: true,
|
||||
xp: true,
|
||||
maxXp: true,
|
||||
level: true,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, type ChangeEvent } from "react";
|
||||
import { useState, useRef, useTransition, type ChangeEvent } from "react";
|
||||
import Avatar from "./Avatar";
|
||||
import { updateProfile } from "@/actions/profile/update-profile";
|
||||
import { updatePassword } from "@/actions/profile/update-password";
|
||||
|
||||
type CharacterClass =
|
||||
| "WARRIOR"
|
||||
@@ -46,7 +48,7 @@ export default function ProfileForm({
|
||||
backgroundImage,
|
||||
}: ProfileFormProps) {
|
||||
const [profile, setProfile] = useState<UserProfile>(initialProfile);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
@@ -64,7 +66,7 @@ export default function ProfileForm({
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const [isChangingPassword, startPasswordTransition] = useTransition();
|
||||
|
||||
const handleAvatarUpload = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -104,81 +106,57 @@ export default function ProfileForm({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/profile", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
avatar,
|
||||
bio,
|
||||
characterClass,
|
||||
}),
|
||||
startTransition(async () => {
|
||||
const result = await updateProfile({
|
||||
username,
|
||||
avatar,
|
||||
bio,
|
||||
characterClass,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProfile(data);
|
||||
setBio(data.bio || null);
|
||||
setCharacterClass(data.characterClass || null);
|
||||
if (result.success && result.data) {
|
||||
setProfile({
|
||||
...result.data,
|
||||
createdAt: result.data.createdAt instanceof Date
|
||||
? 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");
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || "Erreur lors de la mise à jour");
|
||||
setError(result.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) => {
|
||||
e.preventDefault();
|
||||
setChangingPassword(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/profile/password", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
}),
|
||||
startPasswordTransition(async () => {
|
||||
const result = await updatePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess("Mot de passe modifié avec succès");
|
||||
if (result.success) {
|
||||
setSuccess(result.message || "Mot de passe modifié avec succès");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setShowPasswordForm(false);
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(
|
||||
errorData.error || "Erreur lors de la modification du mot de passe"
|
||||
);
|
||||
setError(result.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;
|
||||
@@ -529,10 +507,10 @@ export default function ProfileForm({
|
||||
<div className="flex justify-end gap-4 pt-4 border-t border-pixel-gold/20">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{saving ? "Enregistrement..." : "Enregistrer les modifications"}
|
||||
{isPending ? "Enregistrement..." : "Enregistrer les modifications"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -616,10 +594,10 @@ export default function ProfileForm({
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{changingPassword
|
||||
{isChangingPassword
|
||||
? "Modification..."
|
||||
: "Modifier le mot de passe"}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import Avatar from "./Avatar";
|
||||
import { updateUser, deleteUser } from "@/actions/admin/users";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -35,6 +36,7 @@ export default function UserManagement() {
|
||||
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,60 +74,55 @@ export default function UserManagement() {
|
||||
if (!editingUser) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const body: {
|
||||
username?: string;
|
||||
avatar?: string | null;
|
||||
hpDelta?: number;
|
||||
xpDelta?: number;
|
||||
score?: number;
|
||||
level?: number;
|
||||
role?: string;
|
||||
} = {};
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const body: {
|
||||
username?: string;
|
||||
avatar?: string | null;
|
||||
hpDelta?: number;
|
||||
xpDelta?: number;
|
||||
score?: number;
|
||||
level?: number;
|
||||
role?: string;
|
||||
} = {};
|
||||
|
||||
if (editingUser.username !== null) {
|
||||
body.username = editingUser.username;
|
||||
}
|
||||
if (editingUser.avatar !== undefined) {
|
||||
body.avatar = editingUser.avatar;
|
||||
}
|
||||
if (editingUser.hpDelta !== 0) {
|
||||
body.hpDelta = editingUser.hpDelta;
|
||||
}
|
||||
if (editingUser.xpDelta !== 0) {
|
||||
body.xpDelta = editingUser.xpDelta;
|
||||
}
|
||||
if (editingUser.score !== null) {
|
||||
body.score = editingUser.score;
|
||||
}
|
||||
if (editingUser.level !== null) {
|
||||
body.level = editingUser.level;
|
||||
}
|
||||
if (editingUser.role !== null) {
|
||||
body.role = editingUser.role;
|
||||
}
|
||||
if (editingUser.username !== null) {
|
||||
body.username = editingUser.username;
|
||||
}
|
||||
if (editingUser.avatar !== undefined) {
|
||||
body.avatar = editingUser.avatar;
|
||||
}
|
||||
if (editingUser.hpDelta !== 0) {
|
||||
body.hpDelta = editingUser.hpDelta;
|
||||
}
|
||||
if (editingUser.xpDelta !== 0) {
|
||||
body.xpDelta = editingUser.xpDelta;
|
||||
}
|
||||
if (editingUser.score !== null) {
|
||||
body.score = editingUser.score;
|
||||
}
|
||||
if (editingUser.level !== null) {
|
||||
body.level = editingUser.level;
|
||||
}
|
||||
if (editingUser.role !== null) {
|
||||
body.role = editingUser.role;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/admin/users/${editingUser.userId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const result = await updateUser(editingUser.userId, body);
|
||||
|
||||
if (response.ok) {
|
||||
await fetchUsers();
|
||||
setEditingUser(null);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || "Erreur lors de la mise à jour");
|
||||
if (result.success) {
|
||||
await fetchUsers();
|
||||
setEditingUser(null);
|
||||
} else {
|
||||
alert(result.error || "Erreur lors de la mise à jour");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating user:", error);
|
||||
alert("Erreur lors de la mise à jour");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating user:", error);
|
||||
alert("Erreur lors de la mise à jour");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -143,15 +140,12 @@ export default function UserManagement() {
|
||||
|
||||
setDeletingUserId(userId);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const result = await deleteUser(userId);
|
||||
|
||||
if (response.ok) {
|
||||
if (result.success) {
|
||||
await fetchUsers();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || "Erreur lors de la suppression");
|
||||
alert(result.error || "Erreur lors de la suppression");
|
||||
}
|
||||
} catch (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 Credentials from "next-auth/providers/credentials";
|
||||
import { prisma } from "./prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
import type { Role } from "@/prisma/generated/prisma/client";
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
@@ -17,20 +16,12 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email as string },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(
|
||||
credentials.password as string,
|
||||
user.password
|
||||
const user = await userService.verifyCredentials(
|
||||
credentials.email as string,
|
||||
credentials.password as string
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { normalizeBackgroundUrl } from "@/lib/avatars";
|
||||
import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
|
||||
|
||||
export async function 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;
|
||||
}
|
||||
return sitePreferencesService.getBackgroundImage(page, 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