Refactoring - Bonne version améliorée

This commit is contained in:
dahoud
2026-02-05 14:14:45 +00:00
parent a515963a4a
commit dd4dbe111e
56 changed files with 4274 additions and 2142 deletions

78
.gitignore vendored
View File

@@ -1,51 +1,103 @@
#Maven
# ====================
# Maven
# ====================
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
release.properties
.flattened-pom.xml
dependency-reduced-pom.xml
# Eclipse
# ====================
# IDE - Eclipse
# ====================
.project
.classpath
.settings/
bin/
# IntelliJ
.idea
# ====================
# IDE - IntelliJ IDEA
# ====================
.idea/
*.ipr
*.iml
*.iws
out/
# NetBeans
# ====================
# IDE - NetBeans
# ====================
nb-configuration.xml
# Visual Studio Code
.vscode
# ====================
# IDE - Visual Studio Code
# ====================
.vscode/
.factorypath
# OSX
# ====================
# OS - macOS
# ====================
.DS_Store
._*
# Vim
# ====================
# OS - Windows
# ====================
Thumbs.db
Desktop.ini
ehthumbs.db
# ====================
# Vim / Editors
# ====================
*.swp
*.swo
*~
# patch
# ====================
# Patch files
# ====================
*.orig
*.rej
# Local environment
# ====================
# Environment & Secrets
# ====================
.env
.env.*
!.env.example
application-local.properties
*-secrets.yaml
*.pem
*.key
*.p12
*.jks
# Plugin directory
# ====================
# Quarkus
# ====================
/.quarkus/cli/plugins/
# TLS Certificates
.certs/
# ====================
# Logs
# ====================
*.log
logs/
hs_err_pid*.log
replay_pid*.log
backend_log.txt
# ====================
# Test output
# ====================
test-output/
surefire-reports/
# ====================
# Docker (local)
# ====================
docker-compose.override.yml

View File

@@ -0,0 +1,198 @@
# Audit intégral Frontend (Flutter) & Backend (Quarkus)
**Date** : 4 février 2026
**Périmètre** : `afterwork` (Flutter), `mic-after-work-server-impl-quarkus-main` (Quarkus)
**Références** : bonnes pratiques Quarkus REST, Flutter clean architecture, REST/JWT, WebSocket, Kafka (recherches web et documentation officielle).
---
## 1. Résumé exécutif
| Domaine | État global | Points critiques |
|--------|-------------|------------------|
| **Sécurité API (auth/authz)** | Critique | Aucune vérification JWT ; userId pris de lURL/body sans preuve didentité |
| **Couches backend** | Partiel | Resource accède parfois au repository ; validation incohérente (manuel vs Bean Validation) |
| **Gestion derreurs backend** | Partiel | Réponse derreur JSON construite à la main (risque dinjection) ; exceptions métier non gérées |
| **Frontend auth** | Critique | Aucun en-tête `Authorization` sur les requêtes API |
| **Frontend architecture** | Correct | data/domain/presentation présents ; pas de couche use-case systématique |
| **WebSocket** | Correct | Heartbeat + reconnexion présents ; pas de backoff exponentiel côté Flutter |
---
## 2. Backend (Quarkus) Analyse détaillée
### 2.1 Architecture des couches
**Recommandation (bonnes pratiques Quarkus)** :
Resource (REST, DTO) → Service (métier) → Repository (persistance). La resource ne doit pas appeler le repository pour de la logique métier.
**Constat :**
- **MessageResource** (lignes 98104, 168174) : appelle directement `usersRepository.findById(userId)` pour vérifier lexistence de lutilisateur, au lieu de déléguer au service (ex. `messageService.getUserConversations(userId)` qui lèverait `UserNotFoundException`).
- **MessageResource** injecte `UsersRepository` en plus de `MessageService` → mélange des responsabilités et duplication de la règle “utilisateur doit exister”.
**Recommandation :** Déplacer la résolution/utilisateur dans `MessageService` et faire lever `UserNotFoundException` ; supprimer linjection de `UsersRepository` dans `MessageResource`.
---
### 2.2 Validation des entrées
**Recommandation :** Utiliser Bean Validation (Hibernate Validator) sur les DTO avec `@Valid` sur les paramètres des endpoints. Éviter la validation manuelle dans la resource.
**Constat :**
- **SendMessageRequestDTO** : pas dannotations `@NotNull`, `@NotBlank` ; validation manuelle via `isValid()`.
- **MessageResource.sendMessage** : pas de `@Valid` ; utilise `request.isValid()` et retourne 400 manuellement.
- Autres ressources (UsersResource, EstablishmentResource, FriendshipResource, etc.) : utilisent correctement `@Valid` et DTO avec contraintes.
**Recommandation :** Ajouter sur `SendMessageRequestDTO` les annotations (`@NotNull` pour senderId, recipientId, `@NotBlank` pour content, etc.) et appeler lendpoint avec `@Valid SendMessageRequestDTO request`. Supprimer `isValid()` et le bloc manuel 400.
---
### 2.3 Authentification et autorisation
**Recommandation (OWASP / JWT)** :
Chaque endpoint protégé doit valider un JWT (ou session) et dériver lidentité du token. Les paramètres comme `userId` dans lURL ne doivent pas être la seule source de vérité : vérifier que le sujet du token correspond à la ressource demandée.
**Constat :**
- Aucun usage de `@RolesAllowed`, `@PermitAll`, ni de filtre/filtre JWT dans le projet.
- Les endpoints utilisent `userId` en `@PathParam` (ex. `/notifications/user/{userId}`, `/messages/conversations/{userId}`) ou dans le body (ex. `SendMessageRequestDTO.senderId`) sans aucune preuve que lappelant est cet utilisateur.
- Le commentaire dans `NotificationResource` indique : *“En production, le userId doit être dérivé du contexte d'authentification (JWT/session), pas de l'URL.”* → non implémenté.
**Impact :** Un attaquant peut lire/modifier les données dun autre utilisateur en devinant ou en énumérant des UUID.
**Recommandation :** Introduire lauthentification JWT (ex. `quarkus-oidc` ou filtre custom), extraire le `userId` (ou subject) du token, et pour chaque endpoint : soit utiliser ce `userId` comme source de vérité, soit vérifier que le `userId` en path/body est égal au sujet du token (pour les rôles appropriés).
---
### 2.4 Gestion globale des exceptions
**Recommandation :** Un seul point de sortie pour les erreurs (ExceptionMapper), réponses en JSON structuré (ex. `{"error": "..."}`) avec échappement correct. Gérer toutes les exceptions métier connues.
**Constat :**
- **GlobalExceptionHandler** : gère `BadRequestException`, `UserNotFoundException`, `EventNotFoundException`, `NotFoundException`, `UnauthorizedException`, `ServerException`, `RuntimeException`, et cas par défaut.
- **FriendshipNotFoundException** et **EstablishmentHasDependenciesException** ne sont pas gérées explicitement → elles tombent dans `RuntimeException` ou “Unexpected error”, avec un message potentiellement générique ou une stack trace.
- **buildResponse** (ligne 6265) :
`entity("{\"error\":\"" + message + "\"}")`
Concaténation directe de `message` dans le JSON. Si `message` contient `"` ou `\`, le JSON est mal formé et peut poser des risques (injection / parsing côté client). Il faut sérialiser le message en JSON (ex. via Jackson/JSON-B) au lieu de concaténer une chaîne.
**Recommandation :**
1) Ajouter des branches pour `FriendshipNotFoundException` (ex. 404) et `EstablishmentHasDependenciesException` (ex. 409 Conflict).
2) Remplacer la concaténation par un DTO derreur sérialisé (ex. `Map.of("error", message)` ou classe dédiée) avec le moteur JSON du framework.
---
### 2.5 Ressources qui gèrent les erreurs en local
**Recommandation :** La resource ne doit pas faire de try/catch générique qui transforme tout en 500. Elle doit déléguer au service ; les exceptions métier doivent être mappées par le GlobalExceptionHandler.
**Constat :**
- **MessageResource** : plusieurs méthodes avec `try { ... } catch (Exception e) { return 500 ... }`. Les exceptions métier (ex. utilisateur inexistant, conversation inexistante) ne sont pas levées sous forme dexceptions typées ; elles sont noyées dans un message générique 500.
**Recommandation :** Faire lever par le service des exceptions métier (ex. `UserNotFoundException`, `NotFoundException`) et supprimer les try/catch larges dans la resource ; laisser le GlobalExceptionHandler produire 404/400/500 de façon cohérente.
---
### 2.6 Kafka (déjà traité)
- Tuning prod (`max.poll.interval.ms`, `max.poll.records`, `session.timeout.ms`) déjà ajouté dans `application-prod.properties`.
- Bonnes pratiques SmallRye : en cas déchec critique après consommation, envisager `message.nack()` et stratégie de commit manuel si nécessaire (au-delà du scope de cet audit).
---
## 3. Frontend (Flutter) Analyse détaillée
### 3.1 Structure (clean architecture)
**Recommandation :** Séparation nette data / domain / presentation ; repositories en abstraction dans domain ; use cases optionnels mais utiles pour une logique métier réutilisable.
**Constat :**
- Présence de `data/` (datasources, models, repositories impl, services), `domain/` (entities, repositories abstraits, usecases partiels), `presentation/` (screens, state_management avec BLoC).
- Les datasources sont bien séparés ; les repositories implémentent les contrats du domain. Use cases présents seulement pour une partie des flux (ex. `get_user`).
**Verdict :** Conforme à une clean architecture légère. On peut étendre progressivement les use cases pour les flux critiques.
---
### 3.2 Appels API et authentification
**Recommandation :** Toute requête vers une API protégée doit envoyer le token (ex. `Authorization: Bearer <token>`). Le token doit être lu depuis un stockage sécurisé et rafraîchi si nécessaire.
**Constat :**
- Aucun datasource (user, notification, chat, event, social, reservation, establishment, etc.) najoute den-tête `Authorization` ou `Bearer`.
- Les headers utilisés sont principalement `Content-Type` et `Accept`. Aucune utilisation de `SecureStorage` (ou équivalent) pour récupérer un token et lattacher aux requêtes.
- LAPI backend nexige aujourdhui pas de JWT ; en revanche, dès que lauth sera activée côté backend, tous les appels devront envoyer le token.
**Recommandation :**
1) Créer un client HTTP unique (wrapper ou interceptor) qui récupère le token (ex. depuis `SecureStorage`) et ajoute `Authorization: Bearer <token>` à chaque requête.
2) Utiliser ce client dans tous les datasources au lieu dutiliser `http.Client` brut sans headers dauth.
3) Gérer le cas “token absent ou expiré” (401) : redirection vers login ou refresh.
---
### 3.3 WebSocket (notifications et chat)
**Recommandation (bonnes pratiques WebSocket)** : Heartbeat régulier, reconnexion avec backoff exponentiel, file dattente des messages en cas de déconnexion si besoin.
**Constat :**
- **RealtimeNotificationService** et **ChatWebSocketService** :
- Connexion avec `WebSocketChannel.connect`.
- Heartbeat toutes les 30 s (`_heartbeatInterval`).
- Reconnexion avec délai fixe (`_initialReconnectDelay = 5 s`) et plafond de tentatives (`_maxReconnectAttempts = 5`).
- Pas de backoff exponentiel (délai constant entre les tentatives). Pour réduire la charge serveur en cas de panne, un backoff exponentiel est préférable.
**Recommandation :** Conserver le heartbeat et la reconnexion ; ajouter un backoff exponentiel (ex. 2s, 4s, 8s, 16s, 30s) pour les tentatives de reconnexion, avec un plafond (ex. 30 s).
---
### 3.4 Gestion des erreurs et parsing
- Les datasources gèrent les timeouts, `SocketException`, et codes HTTP (401, 404, etc.) et lèvent des exceptions métier (ex. `ServerException`, `UnauthorizedException`). Cest cohérent.
- Vérifier que partout où lon parse le body derreur, on utilise une clé unique (ex. `error` ou `message`) alignée avec le backend. Après correction du backend (réponse derreur en JSON structuré), adapter si nécessaire le parsing côté Flutter pour lire `error` ou `message`.
---
## 4. Tableau de synthèse des écarts
| # | Composant | Écart | Sévérité | Action recommandée |
|---|-----------|--------|----------|---------------------|
| 1 | Backend | Aucune auth JWT ; userId pris de lURL/body sans preuve | Critique | Introduire JWT et dériver userId du token |
| 2 | Frontend | Aucun en-tête Authorization sur les requêtes API | Critique | Client HTTP centralisé avec Bearer token |
| 3 | Backend | MessageResource : accès direct au repository + validation manuelle | Moyen | Déléguer au service ; Bean Validation sur SendMessageRequestDTO |
| 4 | Backend | buildResponse : concaténation JSON pour le message derreur | Moyen | Utiliser un DTO/Map sérialisé en JSON |
| 5 | Backend | FriendshipNotFoundException, EstablishmentHasDependenciesException non gérées dans GlobalExceptionHandler | Moyen | Ajouter les branches et codes HTTP appropriés |
| 6 | Backend | MessageResource : try/catch générique qui masque les exceptions métier | Moyen | Lever des exceptions typées et laisser le handler global gérer |
| 7 | Frontend | Reconnexion WebSocket avec délai fixe | Faible | Implémenter backoff exponentiel |
---
## 5. Bonnes pratiques croisées (références)
- **Quarkus REST** : Resource → Service → Repository ; DTO + `@Valid` ; ExceptionMapper unique ; pas de logique métier dans la resource.
- **Sécurité REST/JWT** : Vérifier le token sur chaque requête ; ne pas faire confiance au userId passé par le client pour lautorisation.
- **Flutter** : Clean architecture avec repositories abstraits ; couche data qui envoie toujours lauth (client commun avec token).
- **WebSocket** : Heartbeat + reconnexion avec backoff exponentiel pour limiter la charge et les reconnexions agressives.
---
## 6. Conclusion
Les points les plus critiques concernent **lauthentification et lautorisation** : côté backend, aucun contrôle sur lidentité de lappelant ; côté frontend, aucun token nest envoyé. La cohérence des couches (resource sans accès direct au repository pour la logique métier), la validation (Bean Validation partout, y compris chat), et la gestion derreurs (réponse JSON sûre, exceptions métier gérées centralement) sont à renforcer pour aligner le projet sur les bonnes pratiques et sécuriser la production.
---
## 7. Corrections appliquées (suite à l'audit)
- **GlobalExceptionHandler** : Réponse d'erreur en JSON via ObjectMapper ; prise en charge de `FriendshipNotFoundException` (404) et `EstablishmentHasDependenciesException` (409).
- **SendMessageRequestDTO** : Bean Validation ; suppression de `isValid()`.
- **MessageResource** : `@Valid`, `UsersService` au lieu de `UsersRepository`, suppression des try/catch locaux.
- **MessageService** : `NotFoundException` si conversation ou message absent.
- **JWT** : `JwtService`, token au login (HS256), `UserAuthenticateResponseDTO.token`, config `afterwork.jwt.secret`.
- **Frontend** : `SecureStorage.saveAuthToken`/`getAuthToken`, `ApiClient` (Authorization Bearer), tous datasources + FriendsRepositoryImpl ; sauvegarde du token à l'authentification.
- **WebSocket** : Backoff exponentiel (2^attempt s, max 30 s) dans ChatWebSocketService et RealtimeNotificationService.

View File

@@ -1,115 +0,0 @@
# Endpoints Backend à Créer - AfterWork
## 📋 Vue d'ensemble
Ce document liste tous les endpoints backend manquants nécessaires pour une application complète et professionnelle.
---
## 🔔 1. Notifications (`/notifications`)
### Entité à créer
- `com.lions.dev.entity.notification.Notification.java`
### Endpoints à créer
- `GET /notifications/user/{userId}` - Récupérer les notifications d'un utilisateur
- `PUT /notifications/{id}/read` - Marquer une notification comme lue
- `PUT /notifications/user/{userId}/mark-all-read` - Marquer toutes comme lues
- `DELETE /notifications/{id}` - Supprimer une notification
- `POST /notifications` - Créer une notification
### Resource à créer
- `com.lions.dev.resource.NotificationResource.java`
### Service à créer
- `com.lions.dev.service.NotificationService.java`
### Repository à créer
- `com.lions.dev.repository.NotificationRepository.java`
---
## 📱 2. Posts Sociaux (`/posts`)
### Entité à créer
- `com.lions.dev.entity.social.SocialPost.java`
### Endpoints à créer
- `GET /posts` - Récupérer tous les posts (avec pagination)
- `POST /posts` - Créer un post
- `GET /posts/{id}` - Récupérer un post par ID
- `PUT /posts/{id}` - Mettre à jour un post
- `DELETE /posts/{id}` - Supprimer un post
- `GET /posts/search?q={query}` - Rechercher des posts
- `POST /posts/{id}/like` - Liker un post
- `POST /posts/{id}/comment` - Commenter un post
- `POST /posts/{id}/share` - Partager un post
### Resource à créer
- `com.lions.dev.resource.SocialPostResource.java`
### Service à créer
- `com.lions.dev.service.SocialPostService.java`
### Repository à créer
- `com.lions.dev.repository.SocialPostRepository.java`
---
## 📝 Structure des Entités
### Notification
```java
@Entity
@Table(name = "notifications")
public class Notification extends BaseEntity {
private String title;
private String message;
private String type; // event, friend, reminder, other
private boolean isRead;
private UUID userId;
private UUID eventId; // optionnel
private Map<String, Object> metadata; // optionnel
}
```
### SocialPost
```java
@Entity
@Table(name = "social_posts")
public class SocialPost extends BaseEntity {
private String content;
private UUID userId;
private String imageUrl; // optionnel
private int likesCount;
private int commentsCount;
private int sharesCount;
@ManyToOne
private Users user;
}
```
---
## 🚀 Ordre d'Implémentation Recommandé
1. **Notifications** (plus simple, moins de dépendances)
2. **Posts Sociaux** (plus complexe, nécessite interactions)
---
## ✅ Checklist
- [ ] Créer entité Notification
- [ ] Créer NotificationRepository
- [ ] Créer NotificationService
- [ ] Créer NotificationResource
- [ ] Créer entité SocialPost
- [ ] Créer SocialPostRepository
- [ ] Créer SocialPostService
- [ ] Créer SocialPostResource
- [ ] Créer les DTOs correspondants
- [ ] Tester tous les endpoints
- [ ] Documenter l'API (OpenAPI)

View File

@@ -1,281 +0,0 @@
# ✅ Statut du Déploiement AfterWork API
**Date** : 2026-01-10
**Statut** : ✅ Prêt pour le déploiement
---
## 📋 Résumé de la Préparation
### ✅ Backend (Quarkus)
| Élément | Statut | Description |
|---------|--------|-------------|
| **Build Maven** | ✅ Validé | Build réussi avec uber-jar (73M) |
| **Tests** | ✅ Configuré | Non-bloquants (`testFailureIgnore=true`) |
| **Dockerfile.prod** | ✅ Créé | Multi-stage build avec UBI8 OpenJDK 17 |
| **.dockerignore** | ✅ Créé | Optimisation du contexte Docker |
| **application-prod.properties** | ✅ Créé | Configuration production avec context path `/afterwork` |
| **Kubernetes Manifests** | ✅ Créés | Deployment, Service, Ingress, ConfigMap, Secrets |
| **Scripts de déploiement** | ✅ Créés | `deploy.ps1` et documentation complète |
### ✅ Frontend (Flutter)
| Élément | Statut | Description |
|---------|--------|-------------|
| **env_config.dart** | ✅ Configuré | Support `--dart-define` pour API_BASE_URL |
| **build-prod.ps1** | ✅ Créé | Build APK/AAB avec `https://api.lions.dev/afterwork` |
| **Configuration API** | ✅ Prête | Pointe vers `https://api.lions.dev/afterwork` |
---
## 🔧 Fichiers Créés/Modifiés
### Backend
```
mic-after-work-server-impl-quarkus-main/
├── Dockerfile.prod ✅ NOUVEAU
├── .dockerignore ✅ NOUVEAU
├── pom.xml ✅ MODIFIÉ (tests non-bloquants)
├── deploy.ps1 ✅ NOUVEAU
├── DEPLOYMENT.md ✅ NOUVEAU
├── QUICK_DEPLOY.md ✅ NOUVEAU
├── DEPLOYMENT_STATUS.md ✅ NOUVEAU (ce fichier)
├── src/main/resources/
│ └── application-prod.properties ✅ NOUVEAU
└── kubernetes/
├── afterwork-configmap.yaml ✅ NOUVEAU
├── afterwork-secrets.yaml ✅ NOUVEAU (⚠️ MODIFIER MOT DE PASSE)
├── afterwork-deployment.yaml ✅ NOUVEAU
├── afterwork-service.yaml ✅ NOUVEAU
└── afterwork-ingress.yaml ✅ NOUVEAU
```
### Frontend
```
afterwork/
├── lib/core/constants/env_config.dart ✅ EXISTE (configuré)
└── build-prod.ps1 ✅ NOUVEAU
```
---
## 🚀 Prochaines Étapes pour le Déploiement
### 1⃣ Modifier le Secret de Base de Données
```bash
# Éditer le fichier
notepad C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main\kubernetes\afterwork-secrets.yaml
# Changer cette ligne:
DB_PASSWORD: "CHANGE_ME_IN_PRODUCTION"
# Par le vrai mot de passe (encodé en base64 ou en clair avec stringData)
```
### 2⃣ Déployer via PowerShell Script (Recommandé)
```powershell
cd C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main
# Déploiement complet
.\deploy.ps1 -Action all -Version 1.0.0
# Ou étape par étape
.\deploy.ps1 -Action build # Build Maven + Docker
.\deploy.ps1 -Action push # Push vers registry
.\deploy.ps1 -Action deploy # Déploiement K8s
```
### 3⃣ Déployer via lionsctl pipeline (Alternative)
```bash
cd C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main
# Build local
mvn clean package -DskipTests -Dquarkus.package.type=uber-jar
docker build -f Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 .
docker push registry.lions.dev/afterwork-api:1.0.0
# Déploiement
lionsctl pipeline -u https://git.lions.dev/<org>/mic-after-work-server-impl-quarkus-main -b develop -j 17 -e dev -c k1 -m <email>
```
### 4⃣ Vérifier le Déploiement
```bash
# Pods
kubectl get pods -n applications -l app=afterwork-api
# Logs
kubectl logs -n applications -l app=afterwork-api -f
# Health check
curl https://api.lions.dev/afterwork/q/health/ready
curl https://api.lions.dev/afterwork/q/health/live
# Statut complet
.\deploy.ps1 -Action status
```
### 5⃣ Builder l'Application Flutter
```powershell
cd C:\Users\dadyo\PersonalProjects\lions-workspace\afterwork
# Build APK production
.\build-prod.ps1 -Target apk
# Ou AAB pour Play Store
.\build-prod.ps1 -Target appbundle
# Les artefacts seront dans:
# build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
```
---
## 📊 Tests de Build Effectués
### Build Maven (Validé ✅)
```
[INFO] BUILD SUCCESS
[INFO] Total time: 59.644 s
[INFO] Finished at: 2026-01-10T00:10:21Z
Artefact créé:
✅ target/mic-after-work-server-impl-quarkus-main-1.0.0-SNAPSHOT-runner.jar (73M)
```
**Notes:**
- Les tests sont skippés comme demandé
- Quelques warnings sur des configurations non reconnues (micrometer, health checks)
- Ces extensions sont probablement manquantes dans le pom.xml
- Cela n'empêche pas le déploiement
- Les health checks Quarkus fonctionneront avec les chemins par défaut
---
## ⚠️ Avertissements et Prérequis
### Prérequis pour le Déploiement
- [ ] PostgreSQL installé sur le cluster K8s
- [ ] Base de données `afterwork_db` créée
- [ ] Utilisateur `afterwork` avec droits appropriés
- [ ] Mot de passe DB configuré dans `kubernetes/afterwork-secrets.yaml`
- [ ] Docker installé et fonctionnel
- [ ] Accès au registry `registry.lions.dev`
- [ ] kubectl configuré avec accès au cluster
- [ ] Ingress Controller (nginx) installé
- [ ] Cert-Manager installé pour les certificats SSL
### Warnings Maven (Non-bloquants)
Les warnings suivants apparaissent lors du build mais n'empêchent pas le fonctionnement :
```
[WARNING] Unrecognized configuration key "quarkus.micrometer.*"
[WARNING] Unrecognized configuration key "quarkus.smallrye-health.*"
[WARNING] Unrecognized configuration key "quarkus.http.body.multipart.*"
```
**Solutions (Optionnel):**
Pour éliminer ces warnings, ajouter dans `pom.xml`:
```xml
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
```
Mais ce n'est pas nécessaire pour le déploiement initial.
---
## 🎯 Configuration des URLs
### Backend (Production)
- **API Base URL** : `https://api.lions.dev/afterwork`
- **Health Ready** : `https://api.lions.dev/afterwork/q/health/ready`
- **Health Live** : `https://api.lions.dev/afterwork/q/health/live`
- **Métriques** : `https://api.lions.dev/afterwork/q/metrics`
### WebSocket (Production)
- **Notifications** : `wss://api.lions.dev/afterwork/ws/notifications/{userId}`
- **Chat** : `wss://api.lions.dev/afterwork/ws/chat/{userId}`
### Frontend
- Configuré pour pointer vers `https://api.lions.dev/afterwork`
- Build production via `.\build-prod.ps1`
- Variables d'environnement injectées via `--dart-define`
---
## 📚 Documentation Disponible
1. **DEPLOYMENT.md** - Guide complet de déploiement (~566 lignes)
- Prérequis détaillés
- Structure Kubernetes complète
- Troubleshooting
- Monitoring et sécurité
2. **QUICK_DEPLOY.md** - Guide de déploiement rapide
- Commandes copier-coller
- 3 options de déploiement
- Checklist pré-déploiement
- Troubleshooting rapide
3. **deploy.ps1** - Script PowerShell automatisé
- Actions: build, push, deploy, all, rollback, status
- Validation et vérification automatique
- Gestion des erreurs
4. **DEPLOYMENT_STATUS.md** - Ce fichier
- Résumé de la préparation
- Statut actuel
- Prochaines étapes
---
## 🎉 Résumé
### ✅ Tous les fichiers nécessaires ont été créés
### ✅ Le build Maven fonctionne correctement
### ✅ L'uber-jar est généré avec succès (73M)
### ✅ Les tests sont configurés pour ne pas bloquer
### ✅ La documentation complète est disponible
### ✅ Le frontend est configuré pour production
## 🚀 L'API AfterWork est prête à être déployée !
---
**Commande recommandée pour déployer:**
```powershell
cd C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main
# 1. Modifier le mot de passe DB dans kubernetes/afterwork-secrets.yaml
# 2. Lancer le déploiement
.\deploy.ps1 -Action all -Version 1.0.0
# 3. Vérifier
.\deploy.ps1 -Action status
curl https://api.lions.dev/afterwork/q/health/ready
```
---
**Pour toute question ou problème, consulter:**
- DEPLOYMENT.md (guide complet)
- QUICK_DEPLOY.md (guide rapide)
- Logs: `kubectl logs -n applications -l app=afterwork-api -f`

View File

@@ -1,257 +0,0 @@
# 🔍 DIAGNOSTIC COMPLET - Kafka & WebSocket Temps Réel
**Date** : 28 janvier 2026
**Problème** : Messagerie, notifications et actualisations événementielles en temps réel ne fonctionnent pas avec Kafka
---
## 📋 RÉSUMÉ EXÉCUTIF
L'architecture temps réel utilise Kafka comme bus de messages entre les services métier et les WebSockets. Plusieurs problèmes critiques empêchent le bon fonctionnement :
1.**Heartbeat non démarré** côté Flutter
2. ⚠️ **Serializers Kafka manquants** (Quarkus auto-génère mais peut échouer)
3. ⚠️ **Configuration Kafka incomplète** (bootstrap servers, health checks)
4. ⚠️ **WebSocket paths** avec root-path `/afterwork`
5. ⚠️ **Bridges Kafka** peuvent ne pas démarrer si Kafka indisponible
---
## 🔴 PROBLÈMES CRITIQUES IDENTIFIÉS
### 1. Heartbeat Non Démarré (Flutter)
**Fichier** : `afterwork/lib/data/services/realtime_notification_service.dart`
**Problème** : La méthode `_startHeartbeat()` existe mais n'est **jamais appelée** après une connexion réussie.
**Ligne 88-101** : Après `_isConnected = true`, le heartbeat n'est pas démarré.
**Impact** :
- La connexion WebSocket peut timeout côté serveur
- Le statut de présence n'est pas maintenu
- Les notifications peuvent être perdues
**Solution** : Appeler `_startHeartbeat()` après la connexion réussie.
---
### 2. Serializers Kafka Non Explicites
**Fichier** : `application.properties`
**Problème** : Les serializers/deserializers sont omis avec le commentaire "Quarkus génère automatiquement". Cependant :
- Quarkus utilise Jackson pour sérialiser les DTOs
- Les DTOs doivent être correctement annotés
- En cas d'échec, les messages ne sont pas publiés dans Kafka
**Solution** : Ajouter explicitement les serializers Jackson ou vérifier que les DTOs sont sérialisables.
---
### 3. Configuration Kafka Bootstrap Servers
**Fichier** : `application.properties` et `application-prod.properties`
**Problème** :
- Production : `kafka-service.kafka.svc.cluster.local:9092` (Kubernetes DNS)
- Dev : `localhost:9092` (override dans `application-dev.properties`)
**Vérifications nécessaires** :
- Kafka est-il déployé en production ?
- Le service DNS `kafka-service.kafka.svc.cluster.local` est-il résolvable ?
- Les health checks Kafka sont-ils activés ?
**Solution** : Vérifier le déploiement Kafka et ajouter des health checks.
---
### 4. WebSocket Paths avec Root-Path
**Fichier** : `application-prod.properties`
**Problème** : `quarkus.http.root-path=/afterwork` est configuré.
**Impact potentiel** :
- Les WebSockets peuvent nécessiter le path complet : `wss://api.lions.dev/afterwork/notifications/{userId}`
- Le frontend Flutter utilise : `ws://{baseUrl}/notifications/{userId}`
**Vérification** : Tester si les WebSockets fonctionnent avec ou sans le root-path.
---
### 5. Bridges Kafka Peuvent Échouer Silencieusement
**Fichiers** : `NotificationKafkaBridge.java`, `ChatKafkaBridge.java`, etc.
**Problème** : Si Kafka n'est pas disponible au démarrage :
- Les bridges peuvent ne pas démarrer
- Aucune erreur visible si les exceptions sont catchées
- Les messages sont perdus sans notification
**Solution** : Ajouter des logs de démarrage et vérifier que les bridges sont actifs.
---
## 🟡 PROBLÈMES MOYENS
### 6. DTOs Événements Sans Annotations Jackson
**Fichiers** : `NotificationEvent.java`, `ChatMessageEvent.java`, etc.
**Problème** : Les DTOs utilisent Lombok mais peuvent manquer d'annotations Jackson pour la sérialisation JSON.
**Solution** : Vérifier que les DTOs sont correctement sérialisables avec Jackson.
---
### 7. Gestion d'Erreurs Kafka Silencieuse
**Fichiers** : `MessageService.java`, `EventService.java`, etc.
**Problème** : Les erreurs Kafka sont catchées et loggées mais ne remontent pas :
```java
} catch (Exception e) {
System.out.println("[ERROR] Erreur lors de la publication dans Kafka : " + e.getMessage());
// Ne pas bloquer l'envoi du message si Kafka échoue
}
```
**Impact** : Les messages sont sauvegardés en DB mais pas diffusés en temps réel, sans notification à l'utilisateur.
---
## ✅ CORRECTIONS À APPLIQUER
### Correction 1 : Démarrer le Heartbeat après Connexion
**Fichier** : `afterwork/lib/data/services/realtime_notification_service.dart`
```dart
_isConnected = true;
_startHeartbeat(); // ← AJOUTER CETTE LIGNE
if (!_isDisposed) {
notifyListeners();
}
```
---
### Correction 2 : Ajouter Serializers Kafka Explicites
**Fichier** : `application.properties`
Ajouter pour chaque topic outgoing :
```properties
mp.messaging.outgoing.notifications.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
mp.messaging.outgoing.chat-messages.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
mp.messaging.outgoing.reactions.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
mp.messaging.outgoing.presence.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
```
Et pour chaque topic incoming :
```properties
mp.messaging.incoming.kafka-notifications.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer
mp.messaging.incoming.kafka-chat.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer
mp.messaging.incoming.kafka-reactions.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer
mp.messaging.incoming.kafka-presence.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer
```
**Note** : Quarkus peut utiliser Jackson au lieu de Jsonb. Vérifier la dépendance dans `pom.xml`.
---
### Correction 3 : Vérifier les DTOs avec Annotations Jackson
**Fichiers** : Tous les DTOs d'événements
Ajouter si nécessaire :
```java
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public class NotificationEvent {
@JsonProperty("userId")
private String userId;
// ...
}
```
---
### Correction 4 : Ajouter Logs de Démarrage des Bridges
**Fichiers** : Tous les bridges Kafka
Ajouter dans chaque bridge :
```java
@PostConstruct
public void init() {
Log.info("[KAFKA-BRIDGE] Bridge démarré pour topic: notifications");
}
```
---
### Correction 5 : Améliorer la Gestion d'Erreurs Kafka
**Fichiers** : Services qui publient dans Kafka
Au lieu de :
```java
} catch (Exception e) {
System.out.println("[ERROR] Erreur Kafka");
}
```
Utiliser :
```java
} catch (Exception e) {
Log.error("[ERROR] Erreur publication Kafka", e);
// Optionnel: Notifier l'utilisateur ou retry
}
```
---
## 🧪 TESTS DE VALIDATION
### Test 1 : Vérifier Connexion Kafka
```bash
# En production (Kubernetes)
kubectl exec -it kafka-pod -- kafka-console-consumer --bootstrap-server localhost:9092 --topic notifications --from-beginning
```
### Test 2 : Vérifier WebSocket
```javascript
// Dans la console navigateur
const ws = new WebSocket('wss://api.lions.dev/afterwork/notifications/{userId}');
ws.onopen = () => console.log('Connected');
ws.onmessage = (e) => console.log('Message:', e.data);
```
### Test 3 : Vérifier Heartbeat Flutter
- Ouvrir les logs Flutter
- Vérifier que des "Ping envoyé" apparaissent toutes les 30 secondes
---
## 📊 CHECKLIST DE VÉRIFICATION
- [ ] Kafka est déployé et accessible
- [ ] Les topics Kafka existent (`notifications`, `chat.messages`, `reactions`, `presence.updates`)
- [ ] Les bridges Kafka démarrent sans erreur
- [ ] Les WebSockets se connectent correctement
- [ ] Le heartbeat Flutter fonctionne
- [ ] Les messages sont publiés dans Kafka
- [ ] Les messages sont routés vers WebSocket
- [ ] Les clients reçoivent les notifications
---
## 🔗 RESSOURCES
- [Quarkus Reactive Messaging Kafka](https://quarkus.io/guides/kafka)
- [Quarkus WebSockets Next](https://quarkus.io/guides/websockets-next)
- [SmallRye Reactive Messaging](https://smallrye.io/smallrye-reactive-messaging/)

View File

@@ -1,450 +0,0 @@
# 🚀 Architecture Temps Réel - Brainstorming & Plan d'Implémentation
## 📋 Contexte Actuel
### État des Lieux
-**Backend**: Jakarta WebSocket (`@ServerEndpoint`) - API legacy
-**Frontend**: `web_socket_channel` pour WebSocket
-**Services existants**:
- `NotificationWebSocket` (`/notifications/ws/{userId}`)
- `ChatWebSocket` (`/chat/ws/{userId}`)
- Services Flutter: `RealtimeNotificationService`, `ChatWebSocketService`
### Limitations Actuelles
1. **Pas de persistance des événements** : Si un utilisateur est déconnecté, les messages sont perdus
2. **Pas de scalabilité horizontale** : Les sessions WebSocket sont en mémoire, ne fonctionnent pas avec plusieurs instances
3. **Pas de garantie de livraison** : Pas de mécanisme de retry ou de queue
4. **Pas de découplage** : Services directement couplés aux WebSockets
---
## 🎯 Objectifs
1. **Garantir la livraison** : Aucun événement ne doit être perdu
2. **Scalabilité horizontale** : Support de plusieurs instances Quarkus
3. **Temps réel garanti** : Latence < 100ms pour les notifications critiques
4. **Durabilité** : Persistance des événements pour récupération après déconnexion
5. **Découplage** : Services métier indépendants des WebSockets
---
## 🏗️ Architecture Proposée
### Option 1 : WebSockets Next + Kafka (Recommandée) ⭐
```
┌─────────────┐
│ Flutter │
│ Client │
└──────┬──────┘
│ WebSocket (wss://)
┌──────▼─────────────────────────────────────┐
│ Quarkus WebSockets Next │
│ ┌──────────────────────────────────────┐ │
│ │ @WebSocket("/notifications/{userId}")│ │
│ │ @WebSocket("/chat/{userId}") │ │
│ └──────┬───────────────────────────────┘ │
│ │ │
│ ┌──────▼──────────────────────────────┐ │
│ │ Reactive Messaging Bridge │ │
│ │ @Incoming("kafka-notifications") │ │
│ │ @Outgoing("websocket-notifications") │ │
│ └──────┬───────────────────────────────┘ │
└─────────┼──────────────────────────────────┘
│ Kafka Topics
┌─────────▼──────────────────────────────────┐
│ Apache Kafka Cluster │
│ ┌──────────────────────────────────────┐ │
│ │ Topics: │ │
│ │ - notifications.{userId} │ │
│ │ - chat.messages │ │
│ │ - reactions.{postId} │ │
│ │ - presence.updates │ │
│ └──────────────────────────────────────┘ │
└─────────┬──────────────────────────────────┘
│ Producers
┌─────────▼──────────────────────────────────┐
│ Services Métier (Quarkus) │
│ - FriendshipService │
│ - MessageService │
│ - SocialPostService │
│ - EventService │
└────────────────────────────────────────────┘
```
### Avantages
- **Scalabilité** : Kafka gère la distribution entre instances
- **Durabilité** : Messages persistés dans Kafka (rétention configurable)
- **Découplage** : Services publient dans Kafka, WebSocket consomme
- **Performance** : WebSockets Next est plus performant que Jakarta WS
- **Replay** : Possibilité de rejouer les événements pour récupération
- **Monitoring** : Kafka fournit des métriques natives
### Inconvénients
- **Complexité** : Nécessite un cluster Kafka (mais Quarkus Dev Services le gère automatiquement)
- **Latence** : Légèrement plus élevée (Kafka + WebSocket vs WebSocket direct)
- **Ressources** : Kafka consomme plus de mémoire
---
## 📦 Technologies & Dépendances
### Backend (Quarkus)
#### 1. WebSockets Next (Remplace Jakarta WebSocket)
```xml
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets-next</artifactId>
</dependency>
```
**Documentation**: https://quarkus.io/guides/websockets-next
#### 2. Kafka Reactive Messaging
```xml
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-messaging-kafka</artifactId>
</dependency>
```
**Documentation**: https://quarkus.io/guides/kafka
#### 3. Reactive Messaging HTTP (Bridge Kafka ↔ WebSocket)
```xml
<dependency>
<groupId>io.quarkiverse.reactivemessaginghttp</groupId>
<artifactId>quarkus-reactive-messaging-http</artifactId>
<version>1.0.0</version>
</dependency>
```
**Documentation**: https://docs.quarkiverse.io/quarkus-reactive-messaging-http/dev/reactive-messaging-websocket.html
### Frontend (Flutter)
#### Package WebSocket (Déjà utilisé)
```yaml
dependencies:
web_socket_channel: ^2.4.0
```
**Documentation**: https://pub.dev/packages/web_socket_channel
---
## 🔧 Configuration
### application.properties (Quarkus)
```properties
# ============================================
# Kafka Configuration
# ============================================
kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
# Topics
mp.messaging.outgoing.notifications.connector=smallrye-kafka
mp.messaging.outgoing.notifications.topic=notifications
mp.messaging.outgoing.notifications.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.notifications.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
mp.messaging.outgoing.chat-messages.connector=smallrye-kafka
mp.messaging.outgoing.chat-messages.topic=chat.messages
mp.messaging.outgoing.chat-messages.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.chat-messages.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
mp.messaging.outgoing.reactions.connector=smallrye-kafka
mp.messaging.outgoing.reactions.topic=reactions
mp.messaging.outgoing.reactions.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.reactions.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
# ============================================
# WebSocket Configuration
# ============================================
# WebSockets Next
quarkus.websockets-next.server.enabled=true
quarkus.websockets-next.server.port=8080
# ============================================
# Reactive Messaging HTTP (Bridge)
# ============================================
# Incoming Kafka → Outgoing WebSocket
mp.messaging.incoming.kafka-notifications.connector=smallrye-kafka
mp.messaging.incoming.kafka-notifications.topic=notifications
mp.messaging.incoming.kafka-notifications.group.id=websocket-bridge
mp.messaging.incoming.kafka-notifications.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.kafka-notifications.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer
mp.messaging.outgoing.ws-notifications.connector=quarkus-websocket
mp.messaging.outgoing.ws-notifications.path=/notifications/{userId}
```
---
## 💻 Implémentation
### Backend : Migration vers WebSockets Next
#### 1. Nouveau NotificationWebSocket (WebSockets Next)
```java
package com.lions.dev.websocket;
import io.quarkus.websockets.next.OnOpen;
import io.quarkus.websockets.next.OnClose;
import io.quarkus.websockets.next.OnTextMessage;
import io.quarkus.websockets.next.WebSocket;
import io.quarkus.websockets.next.WebSocketConnection;
import io.smallrye.mutiny.Multi;
import jakarta.inject.Inject;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Outgoing;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;
/**
* WebSocket endpoint pour les notifications en temps réel (WebSockets Next).
*
* Architecture:
* - Services métier → Kafka Topic → WebSocket Bridge → Client
*/
@WebSocket(path = "/notifications/{userId}")
public class NotificationWebSocketNext {
@Inject
@Channel("ws-notifications")
Emitter<String> notificationEmitter;
// Stockage des connexions actives (pour routing)
private static final Map<UUID, WebSocketConnection> connections = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(WebSocketConnection connection, String userId) {
UUID userUUID = UUID.fromString(userId);
connections.put(userUUID, connection);
// Envoyer confirmation de connexion
connection.sendText("{\"type\":\"connected\",\"timestamp\":" + System.currentTimeMillis() + "}");
}
@OnClose
public void onClose(String userId) {
UUID userUUID = UUID.fromString(userId);
connections.remove(userUUID);
}
@OnTextMessage
public void onMessage(String message, String userId) {
// Gérer les messages du client (ping, ack, etc.)
// ...
}
/**
* Bridge: Consomme depuis Kafka et envoie via WebSocket
*/
@Incoming("kafka-notifications")
@Outgoing("ws-notifications")
public Multi<String> bridgeNotifications(String kafkaMessage) {
// Parser le message Kafka
// Extraire userId depuis la clé Kafka
// Router vers la bonne connexion WebSocket
return Multi.createFrom().item(kafkaMessage);
}
}
```
#### 2. Service Métier Publie dans Kafka
```java
package com.lions.dev.service;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import jakarta.inject.Inject;
@ApplicationScoped
public class FriendshipService {
@Inject
@Channel("notifications")
Emitter<NotificationEvent> notificationEmitter;
public void sendFriendRequest(UUID fromUserId, UUID toUserId) {
// ... logique métier ...
// Publier dans Kafka au lieu d'appeler directement WebSocket
NotificationEvent event = new NotificationEvent(
toUserId.toString(),
"friend_request",
Map.of("fromUserId", fromUserId.toString(), "fromName", fromUser.getFirstName())
);
notificationEmitter.send(event);
}
}
```
### Frontend : Amélioration du Service WebSocket
```dart
// afterwork/lib/data/services/realtime_notification_service_v2.dart
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status;
class RealtimeNotificationServiceV2 {
WebSocketChannel? _channel;
Timer? _heartbeatTimer;
Timer? _reconnectTimer;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
static const Duration _heartbeatInterval = Duration(seconds: 30);
static const Duration _reconnectDelay = Duration(seconds: 5);
Future<void> connect(String userId, String authToken) async {
final uri = Uri.parse('wss://api.afterwork.lions.dev/notifications/$userId');
_channel = WebSocketChannel.connect(
uri,
protocols: ['notifications-v2'],
headers: {
'Authorization': 'Bearer $authToken',
},
);
// Heartbeat pour maintenir la connexion
_heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) {
_channel?.sink.add(jsonEncode({'type': 'ping'}));
});
// Écouter les messages
_channel!.stream.listen(
_handleMessage,
onError: _handleError,
onDone: _handleDisconnection,
cancelOnError: false,
);
}
void _handleDisconnection() {
_heartbeatTimer?.cancel();
_scheduleReconnect();
}
void _scheduleReconnect() {
if (_reconnectAttempts < _maxReconnectAttempts) {
_reconnectTimer = Timer(_reconnectDelay * (_reconnectAttempts + 1), () {
_reconnectAttempts++;
connect(_userId, _authToken);
});
}
}
}
```
---
## 📊 Comparaison des Options
| Critère | Jakarta WS (Actuel) | WebSockets Next | WebSockets Next + Kafka |
|---------|---------------------|-----------------|-------------------------|
| **Performance** | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| **Scalabilité** | (1 instance) | (limité) | (illimitée) |
| **Durabilité** | | | |
| **Découplage** | | | |
| **Complexité** | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| **Latence** | < 50ms | < 50ms | < 100ms |
| **Replay Events** | | | |
| **Monitoring** | | | (Kafka metrics) |
---
## 🎯 Plan d'Implémentation Recommandé
### Phase 1 : Migration WebSockets Next (Sans Kafka)
**Durée**: 1-2 semaines
- Migrer `NotificationWebSocket` vers WebSockets Next
- Migrer `ChatWebSocket` vers WebSockets Next
- Tester avec le frontend existant
- **Avantage**: Amélioration immédiate des performances
### Phase 2 : Intégration Kafka
**Durée**: 2-3 semaines
- Ajouter dépendances Kafka
- Créer les topics Kafka
- Implémenter les bridges Kafka WebSocket
- Migrer les services pour publier dans Kafka
- **Avantage**: Scalabilité et durabilité
### Phase 3 : Optimisations
**Durée**: 1 semaine
- Compression des messages
- Batching pour les notifications
- Monitoring et alertes
- **Avantage**: Performance et observabilité
---
## 🔍 Alternatives Considérées
### Option 2 : Server-Sent Events (SSE)
- Plus simple que WebSocket
- Unidirectionnel (serveur client uniquement)
- Pas adapté pour le chat bidirectionnel
### Option 3 : gRPC Streaming
- Performant
- Plus complexe à configurer
- Nécessite HTTP/2
### Option 4 : Socket.IO
- Reconnexion automatique
- Nécessite Node.js (pas compatible Quarkus)
---
## 📚 Ressources & Documentation
### Quarkus
- [WebSockets Next Guide](https://quarkus.io/guides/websockets-next)
- [Kafka Guide](https://quarkus.io/guides/kafka)
- [Reactive Messaging](https://quarkus.io/guides/messaging)
### Kafka
- [Kafka Documentation](https://kafka.apache.org/documentation/)
- [Quarkus Kafka Dev Services](https://quarkus.io/guides/kafka-dev-services)
### Flutter
- [web_socket_channel Package](https://pub.dev/packages/web_socket_channel)
- [Flutter WebSocket Best Practices](https://ably.com/topic/websockets-flutter)
---
## ✅ Recommandation Finale
**Architecture recommandée**: **WebSockets Next + Kafka**
**Raisons**:
1. Scalabilité horizontale garantie
2. Durabilité des événements
3. Découplage des services
4. Compatible avec l'existant (migration progressive)
5. Support natif Quarkus (Dev Services pour Kafka)
6. Monitoring intégré
**Prochaines étapes**:
1. Ajouter les dépendances dans `pom.xml`
2. Créer un POC avec un seul endpoint (notifications)
3. Tester la scalabilité avec 2 instances Quarkus
4. Migrer progressivement les autres endpoints

View File

@@ -1,412 +0,0 @@
# 🎉 Session de Travail Complétée - AfterWork
**Date** : 2026-01-10
**Projet** : AfterWork (Backend Quarkus + Frontend Flutter)
---
## 📋 Travail Effectué
Cette session a couvert deux grandes phases de travail :
### Phase 1 : Corrections et Implémentation des TODOs ✅
#### 1.1 Correction Critique - Race Condition Chat
**Problème** : Les icônes de statut des messages (✓, ✓✓, ✓✓ bleu) ne s'affichaient pas.
**Cause** : Les confirmations WebSocket de délivrance arrivaient AVANT que les messages ne soient ajoutés à la liste locale (race condition entre HTTP response et WebSocket event).
**Solution** : Implémentation du pattern **Optimistic UI** dans `chat_bloc.dart`
- Création d'un message temporaire avec ID temporaire immédiatement
- Ajout à la liste AVANT la requête HTTP
- Remplacement du message temporaire par le message serveur à la réponse
**Fichiers modifiés:**
- `lib/presentation/state_management/chat_bloc.dart`
**Résultat** : ✅ Les statuts de message fonctionnent maintenant correctement
---
#### 1.2 Implémentation des TODOs (13/21)
| Fichier | TODOs Implémentés | Description |
|---------|-------------------|-------------|
| **social_header_widget.dart** | 3 | Copier lien, partage natif, signalement de post |
| **share_post_dialog.dart** | 2 | Sélection d'amis, partage externe |
| **media_upload_service.dart** | 3 | Parsing JSON, suppression média, génération miniature |
| **edit_post_dialog.dart** | 1 | Documentation chargement média |
| **create_post_dialog.dart** | 1 | Extraction URL depuis uploads |
| **conversations_screen.dart** | 2 | Navigation notifications, recherche conversations |
**Détails des implémentations:**
1. **social_header_widget.dart**
- ✅ Copier le lien du post dans le presse-papiers
- ✅ Partage natif via Share.share()
- ✅ Dialogue de signalement avec 5 raisons
2. **share_post_dialog.dart**
- ✅ Interface de sélection d'amis avec checkboxes
- ✅ Partage externe via Share API
3. **media_upload_service.dart**
- ✅ Parsing JSON de la réponse backend
- ✅ Méthode deleteMedia() pour supprimer les médias
- ✅ Génération de miniature vidéo avec video_thumbnail
4. **edit_post_dialog.dart**
- ✅ Documentation sur le chargement des médias existants
5. **create_post_dialog.dart**
- ✅ Extraction automatique des URLs depuis les médias uploadés
6. **conversations_screen.dart**
- ✅ Navigation vers écran de notifications depuis conversations
- ✅ ConversationSearchDelegate pour rechercher conversations par nom ou message
**Documentation créée:**
- `TODOS_IMPLEMENTED.md` (documentation complète de tous les TODOs)
---
### Phase 2 : Préparation du Déploiement Production ✅
#### 2.1 Infrastructure Backend
**Fichiers créés:**
1. **Dockerfile.prod** (Multi-stage build)
```dockerfile
- Stage 1: Build avec Maven + UBI8 OpenJDK 17
- Stage 2: Runtime optimisé avec uber-jar
- Healthcheck intégré
- User non-root (185) pour sécurité
```
2. **.dockerignore**
```
- Exclusion target/, tests, IDE, docs
- Optimisation du contexte Docker
```
3. **application-prod.properties**
```properties
- Context path: /afterwork
- CORS: https://afterwork.lions.dev
- Health checks: /q/health/ready, /q/health/live
- Compression HTTP activée
```
4. **pom.xml** (Modifié)
```xml
- testFailureIgnore: true
- skipTests: ${skipTests}
- Tests non-bloquants comme demandé
```
**Manifests Kubernetes créés:**
1. **afterwork-configmap.yaml**
- Variables non-sensibles : DB_HOST, DB_PORT, DB_NAME, etc.
2. **afterwork-secrets.yaml**
- Variables sensibles : DB_PASSWORD
- ⚠️ À modifier avant déploiement
3. **afterwork-deployment.yaml**
- 2 replicas
- Resources: 512Mi-1Gi RAM, 250m-1000m CPU
- Health checks (liveness + readiness)
- Volume pour uploads temporaires
4. **afterwork-service.yaml**
- Type: ClusterIP
- SessionAffinity: ClientIP (pour WebSocket)
5. **afterwork-ingress.yaml**
- Host: api.lions.dev
- Path: /afterwork(/|$)(.*)
- TLS/SSL via Let's Encrypt
- CORS configuré
- Support WebSocket
- Rewrite target: /$2
**Scripts de déploiement:**
1. **deploy.ps1** (Script PowerShell complet)
```powershell
Actions disponibles:
- build : Build Maven + Docker
- push : Push vers registry
- deploy : Déploiement K8s
- all : Tout en une fois
- rollback : Retour arrière
- status : Statut du déploiement
```
**Documentation:**
1. **DEPLOYMENT.md** (~566 lignes)
- Guide complet avec prérequis
- Structure Kubernetes détaillée
- Troubleshooting
- Monitoring et sécurité
- Checklist de déploiement
2. **QUICK_DEPLOY.md**
- Commandes copier-coller
- 3 méthodes de déploiement
- Vérifications rapides
3. **DEPLOYMENT_STATUS.md**
- Statut actuel de la préparation
- Tests effectués
- Prochaines étapes
---
#### 2.2 Configuration Frontend Flutter
**Fichiers créés:**
1. **build-prod.ps1**
```powershell
- Build avec --dart-define pour API_BASE_URL
- Support APK, AAB, iOS, Web
- Configuration : https://api.lions.dev/afterwork
```
**Fichiers existants (vérifiés):**
1. **lib/core/constants/env_config.dart**
- Support --dart-define pour API_BASE_URL
- Validation des configurations
- Gestion environnements (dev, staging, prod)
---
## 🧪 Tests Effectués
### Build Maven
```bash
✅ mvn clean package -DskipTests
- BUILD SUCCESS (44.759s)
- JAR standard créé (189K)
✅ mvn clean package -DskipTests -Dquarkus.package.type=uber-jar
- BUILD SUCCESS (59.644s)
- Uber-jar créé (73M) ← Nécessaire pour Docker
```
### Warnings (Non-bloquants)
```
⚠️ quarkus.micrometer.* (extension manquante)
⚠️ quarkus.smallrye-health.* (extension manquante)
⚠️ quarkus.http.body.multipart.* (extension manquante)
Note: Ces warnings n'empêchent pas le fonctionnement.
Les health checks Quarkus fonctionnent avec les chemins par défaut.
```
---
## 📊 Récapitulatif des Fichiers
### Backend - Nouveaux Fichiers
```
mic-after-work-server-impl-quarkus-main/
├── Dockerfile.prod ✅ NOUVEAU
├── .dockerignore ✅ NOUVEAU
├── deploy.ps1 ✅ NOUVEAU
├── DEPLOYMENT.md ✅ NOUVEAU
├── QUICK_DEPLOY.md ✅ NOUVEAU
├── DEPLOYMENT_STATUS.md ✅ NOUVEAU
├── SESSION_COMPLETE.md ✅ NOUVEAU (ce fichier)
├── src/main/resources/
│ └── application-prod.properties ✅ NOUVEAU
└── kubernetes/
├── afterwork-configmap.yaml ✅ NOUVEAU
├── afterwork-secrets.yaml ✅ NOUVEAU
├── afterwork-deployment.yaml ✅ NOUVEAU
├── afterwork-service.yaml ✅ NOUVEAU
└── afterwork-ingress.yaml ✅ NOUVEAU
```
### Backend - Fichiers Modifiés
```
├── pom.xml ✅ MODIFIÉ (tests non-bloquants)
```
### Frontend - Nouveaux Fichiers
```
afterwork/
└── build-prod.ps1 ✅ NOUVEAU
```
### Frontend - Fichiers Modifiés
```
afterwork/lib/
├── presentation/
│ ├── state_management/
│ │ └── chat_bloc.dart ✅ MODIFIÉ (Optimistic UI)
│ ├── widgets/
│ │ └── social_header_widget.dart ✅ MODIFIÉ (share, report)
│ └── screens/
│ ├── dialogs/
│ │ ├── share_post_dialog.dart ✅ MODIFIÉ (friend selection)
│ │ ├── create_post_dialog.dart ✅ MODIFIÉ (URL extraction)
│ │ └── edit_post_dialog.dart ✅ MODIFIÉ (documentation)
│ └── chat/
│ └── conversations_screen.dart ✅ MODIFIÉ (search, navigation)
└── data/
└── services/
└── media_upload_service.dart ✅ MODIFIÉ (JSON, delete, thumbnail)
```
### Documentation
```
afterwork/
└── TODOS_IMPLEMENTED.md ✅ NOUVEAU
```
---
## 🎯 URLs de Production
### Backend
- **API Base** : `https://api.lions.dev/afterwork`
- **Health Ready** : `https://api.lions.dev/afterwork/q/health/ready`
- **Health Live** : `https://api.lions.dev/afterwork/q/health/live`
- **Métriques** : `https://api.lions.dev/afterwork/q/health/metrics`
### WebSocket
- **Notifications** : `wss://api.lions.dev/afterwork/ws/notifications/{userId}`
- **Chat** : `wss://api.lions.dev/afterwork/ws/chat/{userId}`
---
## 🚀 Prochaines Étapes
### Pour Déployer l'API Backend
```powershell
# 1. Modifier le secret
notepad C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main\kubernetes\afterwork-secrets.yaml
# Changer: DB_PASSWORD: "CHANGE_ME_IN_PRODUCTION"
# 2. Déployer
cd C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main
.\deploy.ps1 -Action all -Version 1.0.0
# 3. Vérifier
.\deploy.ps1 -Action status
curl https://api.lions.dev/afterwork/q/health/ready
```
### Pour Builder l'Application Flutter
```powershell
cd C:\Users\dadyo\PersonalProjects\lions-workspace\afterwork
# Build APK production
.\build-prod.ps1 -Target apk
# Artefacts dans:
# build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
```
---
## 📈 Statistiques
| Catégorie | Quantité |
|-----------|----------|
| **Fichiers créés** | 14 |
| **Fichiers modifiés** | 8 |
| **TODOs implémentés** | 13 |
| **Bugs corrigés** | 1 (race condition) |
| **Lignes de documentation** | ~800 |
| **Manifests K8s** | 5 |
| **Scripts d'automatisation** | 2 |
---
## ✅ Checklist Finale
### Préparation Complétée
- [x] Build Maven fonctionnel
- [x] Uber-jar généré (73M)
- [x] Tests non-bloquants
- [x] Dockerfile.prod créé
- [x] Manifests Kubernetes créés
- [x] Scripts de déploiement créés
- [x] Documentation complète
- [x] Configuration frontend prête
- [x] Race condition corrigée
- [x] TODOs majeurs implémentés
### Reste à Faire (Par l'utilisateur)
- [ ] Modifier le mot de passe DB dans afterwork-secrets.yaml
- [ ] Exécuter le déploiement (deploy.ps1 ou lionesctl)
- [ ] Vérifier que l'API est accessible
- [ ] Builder l'application Flutter
- [ ] Tester l'application en production
---
## 📚 Documentation Disponible
1. **SESSION_COMPLETE.md** (ce fichier)
- Récapitulatif complet de la session
- Tous les changements effectués
2. **DEPLOYMENT.md**
- Guide complet de déploiement
- ~566 lignes
3. **QUICK_DEPLOY.md**
- Guide rapide avec commandes
- Troubleshooting
4. **DEPLOYMENT_STATUS.md**
- Statut actuel
- Tests effectués
5. **TODOS_IMPLEMENTED.md**
- Documentation des TODOs
- Détails d'implémentation
---
## 🎉 Conclusion
### ✅ Tous les Objectifs Atteints
1. **Race Condition Corrigée**
- Les statuts de message s'affichent correctement
- Pattern Optimistic UI implémenté
2. **TODOs Implémentés**
- 13 TODOs majeurs complétés
- Fonctionnalités sociales enrichies
- Gestion média améliorée
3. **Infrastructure de Déploiement Complète**
- Backend prêt pour production
- Frontend configuré pour HTTPS
- Documentation exhaustive
- Scripts d'automatisation
### 🚀 L'Application AfterWork est Prête pour la Production!
**L'API peut être déployée sur le VPS en exécutant simplement:**
```powershell
.\deploy.ps1 -Action all -Version 1.0.0
```
---
**Fin de la Session**
**Temps total estimé de travail** : ~3-4 heures
**Résultat** : ✅ Succès complet

View File

@@ -55,6 +55,15 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<!-- JWT : émission au login et validation sur les requêtes -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt-build</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-logging-json</artifactId>

View File

@@ -1,7 +1,9 @@
package com.lions.dev.config;
import org.eclipse.microprofile.openapi.annotations.OpenAPIDefinition;
import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType;
import org.eclipse.microprofile.openapi.annotations.info.Info;
import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme;
import org.eclipse.microprofile.openapi.annotations.servers.Server;
import jakarta.ws.rs.core.Application;
@@ -9,8 +11,9 @@ import jakarta.ws.rs.core.Application;
/**
* Configuration OpenAPI pour l'API AfterWork.
*
* Cette classe configure les métadonnées OpenAPI et le serveur de base
* pour que Swagger UI génère correctement les URLs avec le root-path.
* Cette classe configure les métadonnées OpenAPI, le serveur de base
* et les schémas de sécurité (JWT Bearer) pour que Swagger UI génère
* correctement les URLs avec le root-path et permette l'authentification.
*/
@OpenAPIDefinition(
info = @Info(
@@ -22,9 +25,20 @@ import jakarta.ws.rs.core.Application;
@Server(
url = "https://api.lions.dev/afterwork",
description = "Serveur de production"
),
@Server(
url = "http://localhost:8080",
description = "Serveur de développement local"
)
}
)
@SecurityScheme(
securitySchemeName = "bearerAuth",
type = SecuritySchemeType.HTTP,
scheme = "bearer",
bearerFormat = "JWT",
description = "Authentification JWT. Utilisez le token obtenu via /auth/login"
)
public class OpenAPIConfig extends Application {
// Classe de configuration OpenAPI
}

View File

@@ -1,17 +0,0 @@
package com.lions.dev.core.errors;
/**
* Classe de base pour les exceptions personnalisées dans l'application AfterWork.
* Toutes les exceptions spécifiques peuvent étendre cette classe pour centraliser la gestion des erreurs.
*/
public abstract class Exceptions extends Exception {
/**
* Constructeur de base pour les exceptions personnalisées.
*
* @param message Le message d'erreur associé à l'exception.
*/
public Exceptions(String message) {
super(message);
}
}

View File

@@ -1,31 +1,35 @@
package com.lions.dev.core.errors;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lions.dev.core.errors.exceptions.BadRequestException;
import com.lions.dev.core.errors.exceptions.EventNotFoundException;
import com.lions.dev.core.errors.exceptions.NotFoundException;
import com.lions.dev.core.errors.exceptions.ServerException;
import com.lions.dev.core.errors.exceptions.UnauthorizedException;
import com.lions.dev.exception.EstablishmentHasDependenciesException;
import com.lions.dev.exception.FriendshipNotFoundException;
import com.lions.dev.exception.UserNotFoundException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import org.jboss.logging.Logger;
import java.util.Collections;
import java.util.Map;
/**
* Gestionnaire global des exceptions pour l'API.
* Ce gestionnaire intercepte les exceptions spécifiques et renvoie des réponses appropriées.
* Les réponses d'erreur sont sérialisées en JSON de façon sûre (pas de concaténation de chaînes).
*/
@Provider
public class GlobalExceptionHandler implements ExceptionMapper<Throwable> {
private static final Logger logger = Logger.getLogger(GlobalExceptionHandler.class);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/**
* Gère les exceptions non traitées et retourne une réponse appropriée.
*
* @param exception L'exception interceptée.
* @return Une réponse HTTP avec un message d'erreur et le code de statut approprié.
*/
@Override
public Response toResponse(Throwable exception) {
if (exception instanceof BadRequestException) {
@@ -34,6 +38,12 @@ public class GlobalExceptionHandler implements ExceptionMapper<Throwable> {
} else if (exception instanceof UserNotFoundException) {
logger.warn("UserNotFoundException (404): " + exception.getMessage());
return buildResponse(Response.Status.NOT_FOUND, exception.getMessage());
} else if (exception instanceof FriendshipNotFoundException) {
logger.warn("FriendshipNotFoundException (404): " + exception.getMessage());
return buildResponse(Response.Status.NOT_FOUND, exception.getMessage());
} else if (exception instanceof EstablishmentHasDependenciesException) {
logger.warn("EstablishmentHasDependenciesException (409): " + exception.getMessage());
return buildResponse(Response.Status.CONFLICT, exception.getMessage());
} else if (exception instanceof EventNotFoundException || exception instanceof NotFoundException) {
logger.warn("NotFoundException intercepted: " + exception.getMessage());
return buildResponse(Response.Status.NOT_FOUND, exception.getMessage());
@@ -54,14 +64,18 @@ public class GlobalExceptionHandler implements ExceptionMapper<Throwable> {
/**
* Crée une réponse HTTP avec un code de statut et un message d'erreur.
*
* @param status Le code de statut HTTP.
* @param message Le message d'erreur.
* @return La réponse HTTP formée.
* Le message est sérialisé en JSON de façon sûre (échappement automatique).
*/
private Response buildResponse(Response.Status status, String message) {
return Response.status(status)
.entity("{\"error\":\"" + message + "\"}")
.build();
Map<String, String> body = Collections.singletonMap("error", message != null ? message : "");
try {
return Response.status(status)
.type(MediaType.APPLICATION_JSON)
.entity(OBJECT_MAPPER.writeValueAsString(body))
.build();
} catch (JsonProcessingException e) {
logger.error("Impossible de sérialiser la réponse d'erreur", e);
return Response.status(status).type(MediaType.APPLICATION_JSON).entity("{\"error\":\"Erreur serveur\"}").build();
}
}
}

View File

@@ -1,4 +0,0 @@
package com.lions.dev.core.errors;
public class ServerException {
}

View File

@@ -0,0 +1,83 @@
package com.lions.dev.core.security;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import org.jboss.logging.Logger;
import java.io.IOException;
import java.util.Optional;
import java.util.UUID;
/**
* Filtre JAX-RS pour l'authentification JWT.
*
* Ce filtre intercepte les requêtes vers les endpoints marqués avec @RequiresAuth
* et vérifie la validité du token JWT.
*
* Le filtre stocke l'ID de l'utilisateur authentifié dans le contexte de la requête
* sous la clé "authenticatedUserId" pour utilisation ultérieure.
*/
@Provider
@RequiresAuth
@Priority(Priorities.AUTHENTICATION)
public class JwtAuthFilter implements ContainerRequestFilter {
private static final Logger LOG = Logger.getLogger(JwtAuthFilter.class);
/**
* Clé utilisée pour stocker l'ID de l'utilisateur authentifié dans le contexte.
*/
public static final String AUTHENTICATED_USER_ID = "authenticatedUserId";
@Inject
JwtValidationService jwtValidationService;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
String path = requestContext.getUriInfo().getPath();
String method = requestContext.getMethod();
LOG.debug("[JwtAuthFilter] Vérification de l'authentification pour: " + method + " " + path);
// Récupérer le header Authorization
String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
if (authHeader == null || authHeader.isBlank()) {
LOG.warn("[JwtAuthFilter] Token manquant pour: " + method + " " + path);
abortWithUnauthorized(requestContext, "Token d'authentification manquant");
return;
}
// Valider le token et extraire l'userId
Optional<UUID> userIdOpt = jwtValidationService.validateTokenAndGetUserId(authHeader);
if (userIdOpt.isEmpty()) {
LOG.warn("[JwtAuthFilter] Token invalide pour: " + method + " " + path);
abortWithUnauthorized(requestContext, "Token d'authentification invalide ou expiré");
return;
}
// Stocker l'userId dans le contexte pour utilisation ultérieure
UUID authenticatedUserId = userIdOpt.get();
requestContext.setProperty(AUTHENTICATED_USER_ID, authenticatedUserId);
LOG.debug("[JwtAuthFilter] Authentification réussie pour l'utilisateur: " + authenticatedUserId);
}
/**
* Interrompt la requête avec une réponse 401 Unauthorized.
*/
private void abortWithUnauthorized(ContainerRequestContext requestContext, String message) {
requestContext.abortWith(
Response.status(Response.Status.UNAUTHORIZED)
.entity("{\"message\": \"" + message + "\"}")
.type("application/json")
.build()
);
}
}

View File

@@ -0,0 +1,187 @@
package com.lions.dev.core.security;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Optional;
import java.util.UUID;
/**
* Service de validation des tokens JWT.
*
* Ce service valide les tokens JWT HMAC-SHA256 envoyés par les clients et extrait
* l'identifiant de l'utilisateur authentifié.
*
* Utilise une validation manuelle pour supporter HMAC-SHA256 sans dépendance
* sur la configuration complexe de SmallRye JWT.
*/
@ApplicationScoped
public class JwtValidationService {
private static final Logger LOG = Logger.getLogger(JwtValidationService.class);
private static final String ISSUER = "afterwork";
private static final String BEARER_PREFIX = "Bearer ";
private static final ObjectMapper MAPPER = new ObjectMapper();
@ConfigProperty(name = "afterwork.jwt.secret", defaultValue = "afterwork-jwt-secret-min-32-bytes-for-hs256!")
String secret;
/**
* Valide un token JWT et retourne l'ID de l'utilisateur.
*
* @param authorizationHeader Le header Authorization (avec ou sans préfixe "Bearer ")
* @return L'ID de l'utilisateur si le token est valide, Optional.empty() sinon
*/
public Optional<UUID> validateTokenAndGetUserId(String authorizationHeader) {
if (authorizationHeader == null || authorizationHeader.isBlank()) {
LOG.debug("[JwtValidation] Authorization header absent");
return Optional.empty();
}
String token = extractToken(authorizationHeader);
if (token == null || token.isBlank()) {
LOG.debug("[JwtValidation] Token non trouvé dans le header");
return Optional.empty();
}
try {
// Séparer les parties du token
String[] parts = token.split("\\.");
if (parts.length != 3) {
LOG.warn("[JwtValidation] Format de token invalide (attendu: 3 parties)");
return Optional.empty();
}
String headerPart = parts[0];
String payloadPart = parts[1];
String signaturePart = parts[2];
// Vérifier la signature HMAC-SHA256
if (!verifySignature(headerPart, payloadPart, signaturePart)) {
LOG.warn("[JwtValidation] Signature invalide");
return Optional.empty();
}
// Décoder et parser le payload
String payloadJson = new String(Base64.getUrlDecoder().decode(payloadPart), StandardCharsets.UTF_8);
JsonNode payload = MAPPER.readTree(payloadJson);
// Vérifier l'issuer
JsonNode issNode = payload.get("iss");
if (issNode == null || !ISSUER.equals(issNode.asText())) {
LOG.warn("[JwtValidation] Issuer invalide: " + (issNode != null ? issNode.asText() : "null"));
return Optional.empty();
}
// Vérifier l'expiration
JsonNode expNode = payload.get("exp");
if (expNode != null) {
long expiration = expNode.asLong();
long now = System.currentTimeMillis() / 1000;
if (expiration < now) {
LOG.warn("[JwtValidation] Token expiré (exp: " + expiration + ", now: " + now + ")");
return Optional.empty();
}
}
// Extraire le subject (userId)
JsonNode subNode = payload.get("sub");
if (subNode == null || subNode.asText().isBlank()) {
LOG.warn("[JwtValidation] Subject (userId) absent du token");
return Optional.empty();
}
UUID userId = UUID.fromString(subNode.asText());
LOG.debug("[JwtValidation] Token valide pour l'utilisateur: " + userId);
return Optional.of(userId);
} catch (IllegalArgumentException e) {
LOG.warn("[JwtValidation] Subject invalide (pas un UUID): " + e.getMessage());
return Optional.empty();
} catch (Exception e) {
LOG.error("[JwtValidation] Erreur lors de la validation du token: " + e.getMessage(), e);
return Optional.empty();
}
}
/**
* Vérifie la signature HMAC-SHA256 du token.
*/
private boolean verifySignature(String header, String payload, String signature) {
try {
SecretKey key = getSecretKey();
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(key);
String dataToSign = header + "." + payload;
byte[] expectedSignature = mac.doFinal(dataToSign.getBytes(StandardCharsets.UTF_8));
String expectedSignatureBase64 = Base64.getUrlEncoder().withoutPadding().encodeToString(expectedSignature);
return expectedSignatureBase64.equals(signature);
} catch (Exception e) {
LOG.error("[JwtValidation] Erreur lors de la vérification de la signature: " + e.getMessage());
return false;
}
}
/**
* Vérifie si le token appartient à l'utilisateur spécifié.
*
* @param authorizationHeader Le header Authorization
* @param expectedUserId L'ID de l'utilisateur attendu
* @return true si le token appartient à cet utilisateur
*/
public boolean isTokenOwner(String authorizationHeader, UUID expectedUserId) {
if (expectedUserId == null) {
return false;
}
Optional<UUID> tokenUserId = validateTokenAndGetUserId(authorizationHeader);
return tokenUserId.isPresent() && tokenUserId.get().equals(expectedUserId);
}
/**
* Vérifie si le token est valide sans retourner l'utilisateur.
*
* @param authorizationHeader Le header Authorization
* @return true si le token est valide
*/
public boolean isValidToken(String authorizationHeader) {
return validateTokenAndGetUserId(authorizationHeader).isPresent();
}
/**
* Extrait le token du header Authorization.
*
* @param authorizationHeader Le header complet
* @return Le token sans le préfixe "Bearer ", ou null si invalide
*/
private String extractToken(String authorizationHeader) {
if (authorizationHeader.startsWith(BEARER_PREFIX)) {
return authorizationHeader.substring(BEARER_PREFIX.length()).trim();
}
// Si pas de préfixe, retourner tel quel (pour compatibilité)
return authorizationHeader.trim();
}
/**
* Génère la clé secrète à partir de la configuration.
*/
private SecretKey getSecretKey() {
byte[] decoded = secret.getBytes(StandardCharsets.UTF_8);
if (decoded.length < 32) {
byte[] padded = new byte[32];
System.arraycopy(decoded, 0, padded, 0, decoded.length);
decoded = padded;
}
return new SecretKeySpec(decoded, "HmacSHA256");
}
}

View File

@@ -0,0 +1,33 @@
package com.lions.dev.core.security;
import jakarta.ws.rs.NameBinding;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotation pour marquer les endpoints qui nécessitent une authentification JWT.
*
* Lorsque cette annotation est présente sur une méthode ou une classe,
* le filtre {@link JwtAuthFilter} vérifiera la présence et la validité
* du token JWT dans le header Authorization.
*
* Usage:
* <pre>
* @RequiresAuth
* @POST
* public Response createPost(...) { ... }
* </pre>
*/
@NameBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RequiresAuth {
/**
* Si true, vérifie que l'utilisateur du token correspond au userId de la requête.
* Par défaut, seule la validité du token est vérifiée.
*/
boolean verifyOwnership() default false;
}

View File

@@ -1,5 +1,7 @@
package com.lions.dev.dto.request.chat;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@@ -8,27 +10,22 @@ import java.util.UUID;
/**
* DTO pour l'envoi d'un message.
* Validation déclarative via Bean Validation (Hibernate Validator).
*/
@Getter
@Setter
@NoArgsConstructor
public class SendMessageRequestDTO {
private UUID senderId; // L'ID de l'expéditeur
private UUID recipientId; // L'ID du destinataire
private String content; // Le contenu du message
private String messageType; // Le type de message (text, image, video, file)
private String mediaUrl; // L'URL du média (optionnel)
@NotNull(message = "L'ID de l'expéditeur est obligatoire")
private UUID senderId;
/**
* Valide les données du DTO.
*
* @return true si les données sont valides, false sinon
*/
public boolean isValid() {
return senderId != null
&& recipientId != null
&& content != null
&& !content.trim().isEmpty();
}
@NotNull(message = "L'ID du destinataire est obligatoire")
private UUID recipientId;
@NotBlank(message = "Le contenu du message est obligatoire")
private String content;
private String messageType; // text, image, video, file (optionnel, défaut text)
private String mediaUrl; // optionnel
}

View File

@@ -0,0 +1,49 @@
package com.lions.dev.dto.request.promotion;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO pour la création d'une promotion.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PromotionCreateRequestDTO {
@NotNull(message = "L'ID de l'établissement est obligatoire")
private UUID establishmentId;
@NotBlank(message = "Le titre est obligatoire")
@Size(max = 200, message = "Le titre ne peut pas dépasser 200 caractères")
private String title;
private String description;
@Size(max = 50, message = "Le code promo ne peut pas dépasser 50 caractères")
private String promoCode;
@NotBlank(message = "Le type de réduction est obligatoire")
private String discountType; // PERCENTAGE, FIXED_AMOUNT, FREE_ITEM
@NotNull(message = "La valeur de réduction est obligatoire")
@Positive(message = "La valeur de réduction doit être positive")
private BigDecimal discountValue;
@NotNull(message = "La date de début est obligatoire")
private LocalDateTime validFrom;
@NotNull(message = "La date de fin est obligatoire")
private LocalDateTime validUntil;
}

View File

@@ -0,0 +1,41 @@
package com.lions.dev.dto.request.promotion;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* DTO pour la mise à jour d'une promotion.
* Tous les champs sont optionnels.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PromotionUpdateRequestDTO {
@Size(max = 200, message = "Le titre ne peut pas dépasser 200 caractères")
private String title;
private String description;
@Size(max = 50, message = "Le code promo ne peut pas dépasser 50 caractères")
private String promoCode;
private String discountType; // PERCENTAGE, FIXED_AMOUNT, FREE_ITEM
@Positive(message = "La valeur de réduction doit être positive")
private BigDecimal discountValue;
private LocalDateTime validFrom;
private LocalDateTime validUntil;
private Boolean isActive;
}

View File

@@ -0,0 +1,37 @@
package com.lions.dev.dto.request.review;
import jakarta.validation.constraints.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.Map;
import java.util.UUID;
/**
* DTO pour la création d'un avis sur un établissement.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReviewCreateRequestDTO {
@NotNull(message = "L'ID de l'établissement est obligatoire")
private UUID establishmentId;
@NotNull(message = "La note globale est obligatoire")
@Min(value = 1, message = "La note doit être au minimum 1")
@Max(value = 5, message = "La note doit être au maximum 5")
private Integer overallRating;
@Size(max = 2000, message = "Le commentaire ne peut pas dépasser 2000 caractères")
private String comment;
/**
* Notes par critères (optionnel).
* Clés possibles: "ambiance", "service", "qualite", "rapport_qualite_prix", "proprete"
*/
private Map<String, Integer> criteriaRatings;
}

View File

@@ -0,0 +1,34 @@
package com.lions.dev.dto.request.review;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.Map;
/**
* DTO pour la mise à jour d'un avis.
* Tous les champs sont optionnels.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReviewUpdateRequestDTO {
@Min(value = 1, message = "La note doit être au minimum 1")
@Max(value = 5, message = "La note doit être au maximum 5")
private Integer overallRating;
@Size(max = 2000, message = "Le commentaire ne peut pas dépasser 2000 caractères")
private String comment;
/**
* Notes par critères (optionnel).
*/
private Map<String, Integer> criteriaRatings;
}

View File

@@ -0,0 +1,30 @@
package com.lions.dev.dto.request.social;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.UUID;
/**
* DTO (Data Transfer Object) pour la création d'un commentaire sur un post social.
*
* Valide que le contenu n'est pas vide et ne dépasse pas 1000 caractères.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PostCommentCreateRequestDTO {
@NotBlank(message = "Le contenu du commentaire est obligatoire")
@Size(max = 1000, message = "Le commentaire ne peut pas dépasser 1000 caractères")
private String content;
@NotNull(message = "L'identifiant de l'utilisateur est obligatoire")
private UUID userId;
}

View File

@@ -0,0 +1,24 @@
package com.lions.dev.dto.request.users;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
/**
* DTO pour la mise à jour de l'image de profil (URL après upload).
* Le client envoie l'URL retournée par l'endpoint d'upload de médias.
* Accepte profile_image_url (snake_case) ou profileImageUrl (camelCase).
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UpdateProfileImageRequestDTO {
@NotBlank(message = "L'URL de l'image de profil est obligatoire.")
@JsonProperty("profile_image_url")
private String profileImageUrl;
}

View File

@@ -0,0 +1,79 @@
package com.lions.dev.dto.response.promotion;
import com.lions.dev.entity.promotion.Promotion;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO pour la réponse d'une promotion.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PromotionResponseDTO {
private UUID id;
private UUID establishmentId;
private String establishmentName;
private String title;
private String description;
private String promoCode;
private String discountType;
private BigDecimal discountValue;
private LocalDateTime validFrom;
private LocalDateTime validUntil;
private Boolean isActive;
private boolean isValid;
private boolean isExpired;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* Constructeur à partir d'une entité Promotion.
*
* @param promotion L'entité Promotion
*/
public PromotionResponseDTO(Promotion promotion) {
if (promotion != null) {
this.id = promotion.getId();
this.establishmentId = promotion.getEstablishment() != null ? promotion.getEstablishment().getId() : null;
this.establishmentName = promotion.getEstablishment() != null ? promotion.getEstablishment().getName() : null;
this.title = promotion.getTitle();
this.description = promotion.getDescription();
this.promoCode = promotion.getPromoCode();
this.discountType = promotion.getDiscountType();
this.discountValue = promotion.getDiscountValue();
this.validFrom = promotion.getValidFrom();
this.validUntil = promotion.getValidUntil();
this.isActive = promotion.getIsActive();
this.isValid = promotion.isValid();
this.isExpired = promotion.isExpired();
this.createdAt = promotion.getCreatedAt();
this.updatedAt = promotion.getUpdatedAt();
}
}
/**
* Formate la réduction pour l'affichage.
*
* @return La réduction formatée (ex: "20%", "10€", "1 article offert")
*/
public String getFormattedDiscount() {
if (discountValue == null || discountType == null) {
return "";
}
return switch (discountType.toUpperCase()) {
case "PERCENTAGE" -> discountValue.stripTrailingZeros().toPlainString() + "%";
case "FIXED_AMOUNT" -> discountValue.stripTrailingZeros().toPlainString() + "";
case "FREE_ITEM" -> discountValue.intValue() + " article(s) offert(s)";
default -> discountValue.toString();
};
}
}

View File

@@ -0,0 +1,73 @@
package com.lions.dev.dto.response.review;
import com.lions.dev.entity.establishment.Review;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
/**
* DTO pour la réponse d'un avis.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReviewResponseDTO {
private UUID id;
private UUID userId;
private String userFirstName;
private String userLastName;
private String userProfileImageUrl;
private UUID establishmentId;
private String establishmentName;
private Integer overallRating;
private String comment;
private Map<String, Integer> criteriaRatings;
private Boolean isVerifiedVisit;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* Constructeur à partir d'une entité Review.
*
* @param review L'entité Review
*/
public ReviewResponseDTO(Review review) {
if (review != null) {
this.id = review.getId();
this.userId = review.getUser() != null ? review.getUser().getId() : null;
this.userFirstName = review.getUser() != null ? review.getUser().getFirstName() : null;
this.userLastName = review.getUser() != null ? review.getUser().getLastName() : null;
this.userProfileImageUrl = review.getUser() != null ? review.getUser().getProfileImageUrl() : null;
this.establishmentId = review.getEstablishment() != null ? review.getEstablishment().getId() : null;
this.establishmentName = review.getEstablishment() != null ? review.getEstablishment().getName() : null;
this.overallRating = review.getOverallRating();
this.comment = review.getComment();
this.criteriaRatings = review.getCriteriaRatings();
this.isVerifiedVisit = review.getIsVerifiedVisit();
this.createdAt = review.getCreatedAt();
this.updatedAt = review.getUpdatedAt();
}
}
/**
* Retourne le nom complet de l'auteur de l'avis.
*/
public String getUserFullName() {
StringBuilder sb = new StringBuilder();
if (userFirstName != null) {
sb.append(userFirstName);
}
if (userLastName != null) {
if (sb.length() > 0) sb.append(" ");
sb.append(userLastName);
}
return sb.toString();
}
}

View File

@@ -0,0 +1,69 @@
package com.lions.dev.dto.response.social;
import com.lions.dev.entity.social.PostComment;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO (Data Transfer Object) pour la réponse d'un commentaire de post social.
*
* Cette classe représente un commentaire avec les informations de l'auteur
* pour l'affichage dans l'interface utilisateur.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PostCommentResponseDTO {
private UUID id;
private String content;
private UUID postId;
private UUID userId;
private String userFirstName;
private String userLastName;
private String userProfileImageUrl;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* Constructeur à partir d'une entité PostComment.
*
* @param comment L'entité PostComment
*/
public PostCommentResponseDTO(PostComment comment) {
if (comment != null) {
this.id = comment.getId();
this.content = comment.getContent();
this.postId = comment.getPost() != null ? comment.getPost().getId() : null;
this.userId = comment.getUser() != null ? comment.getUser().getId() : null;
this.userFirstName = comment.getUser() != null ? comment.getUser().getFirstName() : null;
this.userLastName = comment.getUser() != null ? comment.getUser().getLastName() : null;
this.userProfileImageUrl = comment.getUser() != null ? comment.getUser().getProfileImageUrl() : null;
this.createdAt = comment.getCreatedAt();
this.updatedAt = comment.getUpdatedAt();
}
}
/**
* Retourne le nom complet de l'auteur du commentaire.
*
* @return Le nom complet (prénom + nom)
*/
public String getUserFullName() {
StringBuilder sb = new StringBuilder();
if (userFirstName != null) {
sb.append(userFirstName);
}
if (userLastName != null) {
if (sb.length() > 0) sb.append(" ");
sb.append(userLastName);
}
return sb.toString();
}
}

View File

@@ -48,6 +48,11 @@ public class UserAuthenticateResponseDTO {
*/
private String role;
/**
* Token JWT à envoyer dans l'en-tête Authorization: Bearer &lt;token&gt; pour les requêtes protégées.
*/
private String token;
// Champs de compatibilité v1.0 (dépréciés)
/**
* @deprecated Utiliser {@link #id} à la place.

View File

@@ -0,0 +1,69 @@
package com.lions.dev.entity.social;
import com.lions.dev.entity.BaseEntity;
import com.lions.dev.entity.users.Users;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
/**
* Entité représentant un commentaire sur un post social dans le système AfterWork.
*
* Chaque commentaire est lié à un utilisateur (auteur) et à un post social.
* Les commentaires sont limités à 1000 caractères.
*/
@Entity
@Table(name = "post_comments")
@Getter
@Setter
@NoArgsConstructor
@ToString(exclude = {"post", "user"})
public class PostComment extends BaseEntity {
@Column(name = "content", nullable = false, length = 1000)
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private SocialPost post;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private Users user;
/**
* Constructeur pour créer un nouveau commentaire.
*
* @param content Le contenu du commentaire
* @param post Le post social commenté
* @param user L'utilisateur auteur du commentaire
*/
public PostComment(String content, SocialPost post, Users user) {
this.content = content;
this.post = post;
this.user = user;
}
/**
* Met à jour le contenu du commentaire.
*
* @param newContent Le nouveau contenu
*/
public void updateContent(String newContent) {
if (newContent != null && !newContent.isBlank() && newContent.length() <= 1000) {
this.content = newContent;
}
}
/**
* Vérifie si l'utilisateur donné est l'auteur du commentaire.
*
* @param userId L'ID de l'utilisateur à vérifier
* @return true si l'utilisateur est l'auteur, false sinon
*/
public boolean isAuthor(java.util.UUID userId) {
return this.user != null && this.user.getId() != null && this.user.getId().equals(userId);
}
}

View File

@@ -1,21 +0,0 @@
package com.lions.dev.exception;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import java.util.UUID;
/**
* Exception levée lorsque l'événement demandé n'est pas trouvé dans la base de données.
* Cette exception renvoie une réponse HTTP 404 (NOT FOUND).
*/
public class EventNotFoundException extends WebApplicationException {
/**
* Constructeur qui prend un UUID et convertit l'UUID en message détaillant l'erreur.
*
* @param eventId L'UUID de l'événement qui n'a pas été trouvé.
*/
public EventNotFoundException(UUID eventId) {
super("Événement non trouvé avec l'ID : " + eventId.toString(), Response.Status.NOT_FOUND);
}
}

View File

@@ -0,0 +1,96 @@
package com.lions.dev.repository;
import com.lions.dev.entity.social.PostComment;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.UUID;
/**
* Repository pour l'entité PostComment.
*
* Ce repository gère les opérations CRUD sur les commentaires de posts sociaux
* ainsi que des méthodes personnalisées pour la pagination et la recherche.
*/
@ApplicationScoped
public class PostCommentRepository implements PanacheRepositoryBase<PostComment, UUID> {
private static final Logger LOG = Logger.getLogger(PostCommentRepository.class);
/**
* Récupère tous les commentaires d'un post avec pagination.
*
* @param postId L'ID du post
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des commentaires, triés par date de création croissante
*/
public List<PostComment> findByPostId(UUID postId, int page, int size) {
LOG.debug("[PostCommentRepository] Recherche des commentaires pour le post: " + postId);
return find("post.id", Sort.by("createdAt", Sort.Direction.Ascending), postId)
.page(Page.of(page, size))
.list();
}
/**
* Récupère tous les commentaires d'un post sans pagination.
*
* @param postId L'ID du post
* @return Liste de tous les commentaires du post
*/
public List<PostComment> findAllByPostId(UUID postId) {
LOG.debug("[PostCommentRepository] Recherche de tous les commentaires pour le post: " + postId);
return find("post.id", Sort.by("createdAt", Sort.Direction.Ascending), postId).list();
}
/**
* Compte le nombre de commentaires pour un post.
*
* @param postId L'ID du post
* @return Le nombre de commentaires
*/
public long countByPostId(UUID postId) {
return count("post.id", postId);
}
/**
* Récupère tous les commentaires d'un utilisateur.
*
* @param userId L'ID de l'utilisateur
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des commentaires de l'utilisateur
*/
public List<PostComment> findByUserId(UUID userId, int page, int size) {
LOG.debug("[PostCommentRepository] Recherche des commentaires de l'utilisateur: " + userId);
return find("user.id", Sort.by("createdAt", Sort.Direction.Descending), userId)
.page(Page.of(page, size))
.list();
}
/**
* Supprime tous les commentaires d'un post.
*
* @param postId L'ID du post
* @return Le nombre de commentaires supprimés
*/
public long deleteByPostId(UUID postId) {
LOG.info("[PostCommentRepository] Suppression de tous les commentaires du post: " + postId);
return delete("post.id", postId);
}
/**
* Vérifie si un utilisateur a déjà commenté un post.
*
* @param postId L'ID du post
* @param userId L'ID de l'utilisateur
* @return true si l'utilisateur a commenté, false sinon
*/
public boolean hasUserCommented(UUID postId, UUID userId) {
return count("post.id = ?1 AND user.id = ?2", postId, userId) > 0;
}
}

View File

@@ -0,0 +1,140 @@
package com.lions.dev.repository;
import com.lions.dev.entity.promotion.Promotion;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository pour l'entité Promotion.
*
* Ce repository gère les opérations CRUD sur les promotions
* ainsi que des méthodes personnalisées pour la recherche et la pagination.
*/
@ApplicationScoped
public class PromotionRepository implements PanacheRepositoryBase<Promotion, UUID> {
private static final Logger LOG = Logger.getLogger(PromotionRepository.class);
/**
* Récupère toutes les promotions d'un établissement avec pagination.
*
* @param establishmentId L'ID de l'établissement
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des promotions
*/
public List<Promotion> findByEstablishmentId(UUID establishmentId, int page, int size) {
LOG.debug("[PromotionRepository] Recherche des promotions pour l'établissement: " + establishmentId);
return find("establishment.id", Sort.by("validFrom", Sort.Direction.Descending), establishmentId)
.page(Page.of(page, size))
.list();
}
/**
* Récupère toutes les promotions actives et valides d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @return Liste des promotions actives et valides
*/
public List<Promotion> findActiveByEstablishmentId(UUID establishmentId) {
LOG.debug("[PromotionRepository] Recherche des promotions actives pour l'établissement: " + establishmentId);
LocalDateTime now = LocalDateTime.now();
return find("establishment.id = ?1 AND isActive = true AND validFrom <= ?2 AND validUntil >= ?2",
Sort.by("validFrom", Sort.Direction.Ascending), establishmentId, now)
.list();
}
/**
* Récupère toutes les promotions actives et valides (tous établissements).
*
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des promotions actives
*/
public List<Promotion> findAllActive(int page, int size) {
LOG.debug("[PromotionRepository] Recherche de toutes les promotions actives");
LocalDateTime now = LocalDateTime.now();
return find("isActive = true AND validFrom <= ?1 AND validUntil >= ?1",
Sort.by("validFrom", Sort.Direction.Ascending), now)
.page(Page.of(page, size))
.list();
}
/**
* Recherche une promotion par son code promo.
*
* @param promoCode Le code promo
* @return La promotion si trouvée
*/
public Optional<Promotion> findByPromoCode(String promoCode) {
LOG.debug("[PromotionRepository] Recherche de la promotion avec le code: " + promoCode);
return find("promoCode", promoCode).firstResultOptional();
}
/**
* Vérifie si un code promo existe déjà.
*
* @param promoCode Le code promo à vérifier
* @return true si le code existe déjà
*/
public boolean promoCodeExists(String promoCode) {
return count("promoCode", promoCode) > 0;
}
/**
* Recherche les promotions par type de réduction.
*
* @param discountType Le type de réduction (PERCENTAGE, FIXED_AMOUNT, FREE_ITEM)
* @param page Le numéro de la page
* @param size La taille de la page
* @return Liste des promotions du type spécifié
*/
public List<Promotion> findByDiscountType(String discountType, int page, int size) {
LOG.debug("[PromotionRepository] Recherche des promotions de type: " + discountType);
return find("discountType", Sort.by("createdAt", Sort.Direction.Descending), discountType)
.page(Page.of(page, size))
.list();
}
/**
* Recherche les promotions expirées.
*
* @return Liste des promotions expirées
*/
public List<Promotion> findExpired() {
LOG.debug("[PromotionRepository] Recherche des promotions expirées");
LocalDateTime now = LocalDateTime.now();
return find("validUntil < ?1", now).list();
}
/**
* Désactive les promotions expirées.
*
* @return Nombre de promotions désactivées
*/
public long deactivateExpired() {
LOG.info("[PromotionRepository] Désactivation des promotions expirées");
LocalDateTime now = LocalDateTime.now();
return update("isActive = false WHERE validUntil < ?1 AND isActive = true", now);
}
/**
* Compte les promotions actives d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @return Le nombre de promotions actives
*/
public long countActiveByEstablishmentId(UUID establishmentId) {
LocalDateTime now = LocalDateTime.now();
return count("establishment.id = ?1 AND isActive = true AND validFrom <= ?2 AND validUntil >= ?2",
establishmentId, now);
}
}

View File

@@ -0,0 +1,130 @@
package com.lions.dev.repository;
import com.lions.dev.entity.establishment.Review;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository pour l'entité Review.
*
* Ce repository gère les opérations CRUD sur les avis d'établissements
* ainsi que des méthodes personnalisées pour la recherche et la pagination.
*/
@ApplicationScoped
public class ReviewRepository implements PanacheRepositoryBase<Review, UUID> {
private static final Logger LOG = Logger.getLogger(ReviewRepository.class);
/**
* Récupère tous les avis d'un établissement avec pagination.
*
* @param establishmentId L'ID de l'établissement
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des avis
*/
public List<Review> findByEstablishmentId(UUID establishmentId, int page, int size) {
LOG.debug("[ReviewRepository] Recherche des avis pour l'établissement: " + establishmentId);
return find("establishment.id", Sort.by("createdAt", Sort.Direction.Descending), establishmentId)
.page(Page.of(page, size))
.list();
}
/**
* Récupère tous les avis d'un utilisateur avec pagination.
*
* @param userId L'ID de l'utilisateur
* @param page Le numéro de la page
* @param size La taille de la page
* @return Liste paginée des avis
*/
public List<Review> findByUserId(UUID userId, int page, int size) {
LOG.debug("[ReviewRepository] Recherche des avis de l'utilisateur: " + userId);
return find("user.id", Sort.by("createdAt", Sort.Direction.Descending), userId)
.page(Page.of(page, size))
.list();
}
/**
* Récupère l'avis d'un utilisateur pour un établissement spécifique.
*
* @param establishmentId L'ID de l'établissement
* @param userId L'ID de l'utilisateur
* @return L'avis si trouvé
*/
public Optional<Review> findByEstablishmentAndUser(UUID establishmentId, UUID userId) {
LOG.debug("[ReviewRepository] Recherche de l'avis pour établissement " + establishmentId + " et utilisateur " + userId);
return find("establishment.id = ?1 AND user.id = ?2", establishmentId, userId).firstResultOptional();
}
/**
* Vérifie si un utilisateur a déjà écrit un avis pour un établissement.
*
* @param establishmentId L'ID de l'établissement
* @param userId L'ID de l'utilisateur
* @return true si l'utilisateur a déjà un avis
*/
public boolean hasUserReviewed(UUID establishmentId, UUID userId) {
return count("establishment.id = ?1 AND user.id = ?2", establishmentId, userId) > 0;
}
/**
* Compte le nombre d'avis pour un établissement.
*
* @param establishmentId L'ID de l'établissement
* @return Le nombre d'avis
*/
public long countByEstablishmentId(UUID establishmentId) {
return count("establishment.id", establishmentId);
}
/**
* Récupère uniquement les avis vérifiés d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @param page Le numéro de la page
* @param size La taille de la page
* @return Liste des avis vérifiés
*/
public List<Review> findVerifiedByEstablishmentId(UUID establishmentId, int page, int size) {
LOG.debug("[ReviewRepository] Recherche des avis vérifiés pour l'établissement: " + establishmentId);
return find("establishment.id = ?1 AND isVerifiedVisit = true",
Sort.by("createdAt", Sort.Direction.Descending), establishmentId)
.page(Page.of(page, size))
.list();
}
/**
* Calcule la note moyenne d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @return La note moyenne, ou null si aucun avis
*/
public Double getAverageRating(UUID establishmentId) {
return find("establishment.id", establishmentId)
.project(Double.class)
.stream()
.mapToInt(r -> 0) // Placeholder - needs aggregate query
.average()
.orElse(0.0);
}
/**
* Supprime l'avis d'un utilisateur pour un établissement.
*
* @param establishmentId L'ID de l'établissement
* @param userId L'ID de l'utilisateur
* @return Le nombre d'avis supprimés (0 ou 1)
*/
public long deleteByEstablishmentAndUser(UUID establishmentId, UUID userId) {
LOG.info("[ReviewRepository] Suppression de l'avis pour établissement " + establishmentId + " et utilisateur " + userId);
return delete("establishment.id = ?1 AND user.id = ?2", establishmentId, userId);
}
}

View File

@@ -45,6 +45,21 @@ public class SocialPostRepository implements PanacheRepositoryBase<SocialPost, U
return posts;
}
/**
* Récupère tous les posts d'un utilisateur avec pagination.
*
* @param userId L'ID de l'utilisateur
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des posts de l'utilisateur
*/
public List<SocialPost> findByUserIdWithPagination(UUID userId, int page, int size) {
List<SocialPost> posts = find("user.id", Sort.by("createdAt", Sort.Direction.Descending), userId)
.page(Page.of(page, size))
.list();
return posts;
}
/**
* Recherche des posts par contenu (recherche textuelle).
*
@@ -57,6 +72,22 @@ public class SocialPostRepository implements PanacheRepositoryBase<SocialPost, U
return posts;
}
/**
* Recherche des posts par contenu avec pagination.
*
* @param query Le terme de recherche
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des posts correspondant à la recherche
*/
public List<SocialPost> searchByContentWithPagination(String query, int page, int size) {
String searchPattern = "%" + query.toLowerCase() + "%";
List<SocialPost> posts = find("LOWER(content) LIKE ?1", Sort.by("createdAt", Sort.Direction.Descending), searchPattern)
.page(Page.of(page, size))
.list();
return posts;
}
/**
* Récupère les posts les plus populaires (par nombre de likes).
*

View File

@@ -1,5 +1,7 @@
package com.lions.dev.resource;
import com.lions.dev.core.security.JwtAuthFilter;
import com.lions.dev.core.security.RequiresAuth;
import com.lions.dev.dto.request.establishment.EstablishmentRatingRequestDTO;
import com.lions.dev.dto.response.establishment.EstablishmentRatingResponseDTO;
import com.lions.dev.dto.response.establishment.EstablishmentRatingStatsResponseDTO;
@@ -9,9 +11,13 @@ import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
@@ -33,30 +39,37 @@ public class EstablishmentRatingResource {
private static final Logger LOG = Logger.getLogger(EstablishmentRatingResource.class);
/**
* Extrait l'ID de l'utilisateur authentifié du contexte de la requête.
*/
private UUID getAuthenticatedUserId(ContainerRequestContext requestContext) {
return (UUID) requestContext.getProperty(JwtAuthFilter.AUTHENTICATED_USER_ID);
}
/**
* Soumet une nouvelle note pour un établissement.
* Requiert une authentification JWT.
*/
@POST
@Transactional
@RequiresAuth
@Operation(summary = "Soumettre une note pour un établissement",
description = "Soumet une nouvelle note (1 à 5 étoiles) pour un établissement")
description = "Soumet une nouvelle note (1 à 5 étoiles) pour un établissement. Requiert une authentification JWT.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "201", description = "Note soumise avec succès")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "400", description = "Données invalides")
public Response submitRating(
@Context ContainerRequestContext requestContext,
@PathParam("establishmentId") String establishmentId,
@QueryParam("userId") String userIdStr,
@Valid EstablishmentRatingRequestDTO requestDTO) {
if (userIdStr == null || userIdStr.isBlank()) {
LOG.warn("Soumission de note sans userId pour l'établissement " + establishmentId);
return Response.status(Response.Status.BAD_REQUEST)
.entity("Le paramètre userId est requis")
.build();
}
LOG.info("Soumission d'une note pour l'établissement " + establishmentId + " par l'utilisateur " + userIdStr);
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("Soumission d'une note pour l'établissement " + establishmentId + " par l'utilisateur " + authenticatedUserId);
try {
UUID id = UUID.fromString(establishmentId);
UUID userId = UUID.fromString(userIdStr);
EstablishmentRating rating = ratingService.submitRating(id, userId, requestDTO);
EstablishmentRating rating = ratingService.submitRating(id, authenticatedUserId, requestDTO);
EstablishmentRatingResponseDTO responseDTO = new EstablishmentRatingResponseDTO(rating);
return Response.status(Response.Status.CREATED).entity(responseDTO).build();
} catch (IllegalArgumentException e) {
@@ -79,28 +92,28 @@ public class EstablishmentRatingResource {
/**
* Met à jour une note existante.
* Requiert une authentification JWT.
*/
@PUT
@Transactional
@RequiresAuth
@Operation(summary = "Modifier une note existante",
description = "Met à jour une note existante pour un établissement")
description = "Met à jour une note existante pour un établissement. Requiert une authentification JWT.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Note mise à jour")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "404", description = "Note non trouvée")
public Response updateRating(
@Context ContainerRequestContext requestContext,
@PathParam("establishmentId") String establishmentId,
@QueryParam("userId") String userIdStr,
@Valid EstablishmentRatingRequestDTO requestDTO) {
if (userIdStr == null || userIdStr.isBlank()) {
LOG.warn("Mise à jour de note sans userId pour l'établissement " + establishmentId);
return Response.status(Response.Status.BAD_REQUEST)
.entity("Le paramètre userId est requis")
.build();
}
LOG.info("Mise à jour de la note pour l'établissement " + establishmentId + " par l'utilisateur " + userIdStr);
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("Mise à jour de la note pour l'établissement " + establishmentId + " par l'utilisateur " + authenticatedUserId);
try {
UUID id = UUID.fromString(establishmentId);
UUID userId = UUID.fromString(userIdStr);
EstablishmentRating rating = ratingService.updateRating(id, userId, requestDTO);
EstablishmentRating rating = ratingService.updateRating(id, authenticatedUserId, requestDTO);
EstablishmentRatingResponseDTO responseDTO = new EstablishmentRatingResponseDTO(rating);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {

View File

@@ -1,6 +1,8 @@
package com.lions.dev.resource;
import com.lions.dev.core.errors.exceptions.EventNotFoundException;
import com.lions.dev.core.security.JwtAuthFilter;
import com.lions.dev.core.security.RequiresAuth;
import com.lions.dev.dto.request.events.EventCreateRequestDTO;
import com.lions.dev.dto.request.events.EventReadManyByIdRequestDTO;
import com.lions.dev.dto.request.events.EventUpdateRequestDTO;
@@ -22,6 +24,8 @@ import com.lions.dev.service.FriendshipService;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.*;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.Response;
import java.io.File;
import java.time.LocalDateTime;
@@ -32,6 +36,8 @@ import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
@@ -64,14 +70,33 @@ public class EventsResource {
private static final Logger LOG = Logger.getLogger(EventsResource.class);
/**
* Extrait l'ID de l'utilisateur authentifié du contexte de la requête.
*
* @param requestContext Le contexte de la requête
* @return L'ID de l'utilisateur authentifié
*/
private UUID getAuthenticatedUserId(ContainerRequestContext requestContext) {
return (UUID) requestContext.getProperty(JwtAuthFilter.AUTHENTICATED_USER_ID);
}
// *********** Création d'un événement ***********
@POST
@Transactional
@Operation(summary = "Créer un nouvel événement", description = "Crée un nouvel événement et retourne ses détails")
public Response createEvent(EventCreateRequestDTO eventCreateRequestDTO) {
@RequiresAuth
@Operation(summary = "Créer un nouvel événement", description = "Crée un nouvel événement. Requiert une authentification JWT.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "201", description = "Événement créé avec succès")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "L'utilisateur authentifié ne correspond pas au creatorId")
public Response createEvent(
@Context ContainerRequestContext requestContext,
EventCreateRequestDTO eventCreateRequestDTO) {
LOG.info("[LOG] Tentative de création d'un nouvel événement : " + eventCreateRequestDTO.getTitle());
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
// Valider que creatorId est fourni
if (eventCreateRequestDTO.getCreatorId() == null) {
LOG.error("[ERROR] creatorId est obligatoire pour créer un événement");
@@ -80,6 +105,14 @@ public class EventsResource {
.build();
}
// Vérifier que l'utilisateur authentifié correspond au creatorId
if (!authenticatedUserId.equals(eventCreateRequestDTO.getCreatorId())) {
LOG.warn("[WARN] Utilisateur " + authenticatedUserId + " tente de créer un événement pour " + eventCreateRequestDTO.getCreatorId());
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Vous ne pouvez créer un événement que pour votre propre compte.\"}")
.build();
}
// Récupérer le créateur par son ID
Users creator = usersRepository.findById(eventCreateRequestDTO.getCreatorId());
if (creator == null) {
@@ -118,17 +151,21 @@ public class EventsResource {
@DELETE
@Path("/{id}")
@Transactional
@Operation(summary = "Supprimer un événement", description = "Supprime un événement de la base de données")
public Response deleteEvent(@PathParam("id") UUID id, @QueryParam("userId") UUID userId) {
LOG.info("Tentative de suppression de l'événement avec l'ID : " + id + " par l'utilisateur : " + userId);
if (userId == null) {
LOG.error("[ERROR] userId est obligatoire pour supprimer un événement");
return Response.status(Response.Status.BAD_REQUEST).entity("L'identifiant de l'utilisateur (userId) est obligatoire").build();
}
@RequiresAuth
@Operation(summary = "Supprimer un événement", description = "Supprime un événement. Seul le créateur peut supprimer.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "204", description = "Événement supprimé")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à supprimer cet événement")
@APIResponse(responseCode = "404", description = "Événement non trouvé")
public Response deleteEvent(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID id) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("Tentative de suppression de l'événement avec l'ID : " + id + " par l'utilisateur : " + authenticatedUserId);
try {
boolean deleted = eventService.deleteEvent(id, userId);
boolean deleted = eventService.deleteEvent(id, authenticatedUserId);
if (deleted) {
LOG.info("Événement supprimé avec succès.");
return Response.noContent().build();
@@ -193,14 +230,19 @@ public class EventsResource {
@PUT
@Path("/{id}")
@Transactional
@Operation(summary = "Mettre à jour un événement", description = "Modifie un événement existant")
public Response updateEvent(@PathParam("id") UUID id, @QueryParam("userId") UUID userId, EventUpdateRequestDTO eventUpdateRequestDTO) {
LOG.info("[LOG] Tentative de mise à jour de l'événement avec l'ID : " + id + " par l'utilisateur : " + userId);
if (userId == null) {
LOG.error("[ERROR] userId est obligatoire pour mettre à jour un événement");
return Response.status(Response.Status.BAD_REQUEST).entity("L'identifiant de l'utilisateur (userId) est obligatoire").build();
}
@RequiresAuth
@Operation(summary = "Mettre à jour un événement", description = "Modifie un événement. Seul le créateur peut modifier.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Événement mis à jour")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à modifier cet événement")
@APIResponse(responseCode = "404", description = "Événement non trouvé")
public Response updateEvent(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID id,
EventUpdateRequestDTO eventUpdateRequestDTO) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Tentative de mise à jour de l'événement avec l'ID : " + id + " par l'utilisateur : " + authenticatedUserId);
Events event = eventsRepository.findById(id);
if (event == null) {
@@ -209,8 +251,8 @@ public class EventsResource {
}
// Vérifier que l'utilisateur est le créateur
if (!eventService.canModifyEvent(event, userId)) {
LOG.error("[ERROR] L'utilisateur " + userId + " n'a pas les permissions pour modifier l'événement " + id);
if (!eventService.canModifyEvent(event, authenticatedUserId)) {
LOG.error("[ERROR] L'utilisateur " + authenticatedUserId + " n'a pas les permissions pour modifier l'événement " + id);
return Response.status(Response.Status.FORBIDDEN).entity("Vous n'avez pas les permissions pour modifier cet événement").build();
}
@@ -274,19 +316,24 @@ public class EventsResource {
// *********** Récupérer les événements par catégorie ***********
/**
* Endpoint pour récupérer les événements par catégorie.
* Endpoint pour récupérer les événements par catégorie avec pagination.
*
* @param category La catégorie d'événement à filtrer.
* @return Une réponse HTTP contenant la liste des événements dans cette catégorie.
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Une réponse HTTP contenant la liste paginée des événements dans cette catégorie.
*/
@GET
@Path("/category/{category}")
@Operation(
summary = "Récupérer les événements par catégorie",
description = "Retourne la liste des événements correspondant à une catégorie donnée")
public Response getEventsByCategory(@PathParam("category") String category) {
LOG.info("[LOG] Récupération des événements dans la catégorie : " + category);
List<Events> events = eventService.findEventsByCategory(category);
description = "Retourne la liste paginée des événements correspondant à une catégorie donnée")
public Response getEventsByCategory(
@PathParam("category") String category,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération des événements dans la catégorie : " + category + " (page: " + page + ", size: " + size + ")");
List<Events> events = eventService.findEventsByCategory(category, page, size);
if (events.isEmpty()) {
LOG.warn("[LOG] Aucun événement trouvé pour la catégorie : " + category);
return Response.status(Response.Status.NOT_FOUND)
@@ -371,19 +418,24 @@ public class EventsResource {
// *********** Rechercher des événements par mots-clés ***********
/**
* Endpoint pour rechercher des événements par mots-clés.
* Endpoint pour rechercher des événements par mots-clés avec pagination.
*
* @param keyword Le mot-clé à rechercher.
* @return Une réponse HTTP contenant la liste des événements correspondant au mot-clé.
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Une réponse HTTP contenant la liste paginée des événements correspondant au mot-clé.
*/
@GET
@Path("/search")
@Operation(
summary = "Rechercher des événements par mots-clés",
description = "Retourne la liste des événements dont le titre ou la description contient les mots-clés spécifiés")
public Response searchEvents(@QueryParam("keyword") String keyword) {
LOG.info("[LOG] Recherche d'événements avec le mot-clé : " + keyword);
List<Events> events = eventService.searchEvents(keyword);
description = "Retourne la liste paginée des événements dont le titre ou la description contient les mots-clés spécifiés")
public Response searchEvents(
@QueryParam("keyword") String keyword,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Recherche d'événements avec le mot-clé : " + keyword + " (page: " + page + ", size: " + size + ")");
List<Events> events = eventService.searchEvents(keyword, page, size);
if (events.isEmpty()) {
LOG.warn("[LOG] Aucun événement trouvé avec le mot-clé : " + keyword);
return Response.status(Response.Status.NOT_FOUND)
@@ -401,7 +453,9 @@ public class EventsResource {
/**
* Endpoint pour mettre à jour le statut d'un événement.
* Requiert une authentification JWT.
*
* @param requestContext Le contexte de la requête (injecté)
* @param id L'ID de l'événement.
* @param status Le nouveau statut de l'événement.
* @return Une réponse HTTP indiquant la mise à jour du statut.
@@ -409,16 +463,20 @@ public class EventsResource {
@PUT
@Path("/{id}/status")
@Transactional
@RequiresAuth
@Operation(
summary = "Mettre à jour le statut d'un événement",
description = "Modifie le statut d'un événement (ouvert, fermé, annulé, etc.)")
public Response updateEventStatus(@PathParam("id") UUID id, @QueryParam("status") String status, @QueryParam("userId") UUID userId) {
LOG.info("[LOG] Mise à jour du statut de l'événement avec l'ID : " + id + " par l'utilisateur : " + userId);
if (userId == null) {
LOG.error("[ERROR] userId est obligatoire pour mettre à jour le statut d'un événement");
return Response.status(Response.Status.BAD_REQUEST).entity("L'identifiant de l'utilisateur (userId) est obligatoire").build();
}
description = "Modifie le statut d'un événement. Seul le créateur peut modifier.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Statut mis à jour")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à modifier cet événement")
public Response updateEventStatus(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID id,
@QueryParam("status") String status) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Mise à jour du statut de l'événement avec l'ID : " + id + " par l'utilisateur : " + authenticatedUserId);
Events event = eventsRepository.findById(id);
if (event == null) {
@@ -427,8 +485,8 @@ public class EventsResource {
}
// Vérifier que l'utilisateur est le créateur
if (!eventService.canModifyEvent(event, userId)) {
LOG.error("[ERROR] L'utilisateur " + userId + " n'a pas les permissions pour modifier le statut de l'événement " + id);
if (!eventService.canModifyEvent(event, authenticatedUserId)) {
LOG.error("[ERROR] L'utilisateur " + authenticatedUserId + " n'a pas les permissions pour modifier le statut de l'événement " + id);
return Response.status(Response.Status.FORBIDDEN).entity("Vous n'avez pas les permissions pour modifier cet événement").build();
}
@@ -478,20 +536,27 @@ public class EventsResource {
/**
* Endpoint pour mettre à jour l'image d'un événement.
* Requiert une authentification JWT.
*
* @param requestContext Le contexte de la requête (injecté)
* @param id L'identifiant de l'événement.
* @param imageFilePath Le chemin vers l'image de l'événement.
* @return Un message indiquant si la mise à jour a réussi ou non.
*/
@PUT
@Path("/{id}/image")
public Response updateEventImage(@PathParam("id") UUID id, @QueryParam("userId") UUID userId, String imageFilePath) {
LOG.info("[LOG] Tentative de mise à jour de l'image pour l'événement avec l'ID : " + id + " par l'utilisateur : " + userId);
if (userId == null) {
LOG.error("[ERROR] userId est obligatoire pour mettre à jour l'image d'un événement");
return Response.status(Response.Status.BAD_REQUEST).entity("L'identifiant de l'utilisateur (userId) est obligatoire").build();
}
@RequiresAuth
@Operation(summary = "Mettre à jour l'image d'un événement", description = "Modifie l'image de l'événement. Seul le créateur peut modifier.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Image mise à jour")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à modifier cet événement")
public Response updateEventImage(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID id,
String imageFilePath) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Tentative de mise à jour de l'image pour l'événement avec l'ID : " + id + " par l'utilisateur : " + authenticatedUserId);
try {
if (imageFilePath == null || imageFilePath.isEmpty()) {
@@ -512,14 +577,14 @@ public class EventsResource {
}
// Vérifier que l'utilisateur est le créateur
if (!eventService.canModifyEvent(event, userId)) {
LOG.error("[ERROR] L'utilisateur " + userId + " n'a pas les permissions pour modifier l'image de l'événement " + id);
if (!eventService.canModifyEvent(event, authenticatedUserId)) {
LOG.error("[ERROR] L'utilisateur " + authenticatedUserId + " n'a pas les permissions pour modifier l'image de l'événement " + id);
return Response.status(Response.Status.FORBIDDEN).entity("Vous n'avez pas les permissions pour modifier cet événement").build();
}
String imageUrl = file.getAbsolutePath();
event.setImageUrl(imageUrl);
eventService.updateEvent(event, userId);
eventService.updateEvent(event, authenticatedUserId);
LOG.info("[LOG] Image de l'événement mise à jour avec succès pour : " + event.getTitle());
return Response.ok("Image de l'événement mise à jour avec succès.").build();
@@ -533,14 +598,19 @@ public class EventsResource {
@PATCH
@Path("/{id}/partial-update")
@Transactional
@Operation(summary = "Mettre à jour partiellement un événement", description = "Mise à jour partielle des informations d'un événement")
public Response partialUpdateEvent(@PathParam("id") UUID id, @QueryParam("userId") UUID userId, Map<String, Object> updates) {
LOG.info("[LOG] Tentative de mise à jour partielle de l'événement avec l'ID : " + id + " par l'utilisateur : " + userId);
if (userId == null) {
LOG.error("[ERROR] userId est obligatoire pour mettre à jour un événement");
return Response.status(Response.Status.BAD_REQUEST).entity("L'identifiant de l'utilisateur (userId) est obligatoire").build();
}
@RequiresAuth
@Operation(summary = "Mettre à jour partiellement un événement", description = "Mise à jour partielle. Seul le créateur peut modifier.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Événement mis à jour")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à modifier cet événement")
@APIResponse(responseCode = "404", description = "Événement non trouvé")
public Response partialUpdateEvent(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID id,
Map<String, Object> updates) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Tentative de mise à jour partielle de l'événement avec l'ID : " + id + " par l'utilisateur : " + authenticatedUserId);
Events event = eventsRepository.findById(id);
if (event == null) {
@@ -549,36 +619,38 @@ public class EventsResource {
}
// Vérifier que l'utilisateur est le créateur
if (!eventService.canModifyEvent(event, userId)) {
LOG.error("[ERROR] L'utilisateur " + userId + " n'a pas les permissions pour modifier l'événement " + id);
if (!eventService.canModifyEvent(event, authenticatedUserId)) {
LOG.error("[ERROR] L'utilisateur " + authenticatedUserId + " n'a pas les permissions pour modifier l'événement " + id);
return Response.status(Response.Status.FORBIDDEN).entity("Vous n'avez pas les permissions pour modifier cet événement").build();
}
// Mise à jour des champs dynamiquement
updates.forEach((field, value) -> {
switch (field) {
case "title":
event.setTitle(value != null ? value.toString() : null);
break;
case "description":
event.setDescription(value != null ? value.toString() : null);
break;
case "category":
event.setCategory(value != null ? value.toString() : null);
break;
case "link":
event.setLink(value != null ? value.toString() : null);
break;
case "imageUrl":
event.setImageUrl(value != null ? value.toString() : null);
break;
case "status":
event.setStatus(value != null ? value.toString() : null);
break;
default:
LOG.warn("[LOG] Champ inconnu ignoré lors de la mise à jour partielle : " + field);
}
});
if (updates != null) {
updates.forEach((field, value) -> {
switch (field) {
case "title":
event.setTitle(value != null ? value.toString() : null);
break;
case "description":
event.setDescription(value != null ? value.toString() : null);
break;
case "category":
event.setCategory(value != null ? value.toString() : null);
break;
case "link":
event.setLink(value != null ? value.toString() : null);
break;
case "imageUrl":
event.setImageUrl(value != null ? value.toString() : null);
break;
case "status":
event.setStatus(value != null ? value.toString() : null);
break;
default:
LOG.warn("[LOG] Champ inconnu ignoré lors de la mise à jour partielle : " + field);
}
});
}
eventsRepository.persist(event);
LOG.info("[LOG] Événement mis à jour partiellement avec succès : " + event.getTitle());
@@ -588,11 +660,13 @@ public class EventsResource {
// *********** Récupérer les événements à venir ***********
@GET
@Path("/upcoming")
@Operation(summary = "Récupérer les événements à venir", description = "Retourne les événements futurs.")
public Response getUpcomingEvents() {
LOG.info("[LOG] Récupération des événements à venir.");
@Operation(summary = "Récupérer les événements à venir", description = "Retourne les événements futurs avec pagination.")
public Response getUpcomingEvents(
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération des événements à venir (page: " + page + ", size: " + size + ")");
List<Events> events = eventService.findUpcomingEvents();
List<Events> events = eventService.findUpcomingEvents(page, size);
if (events.isEmpty()) {
LOG.warn("[LOG] Aucun événement futur trouvé.");
return Response.status(Response.Status.NOT_FOUND).entity("Aucun événement futur trouvé.").build();
@@ -605,11 +679,13 @@ public class EventsResource {
// *********** Récupérer les événements passés ***********
@GET
@Path("/past")
@Operation(summary = "Récupérer les événements passés", description = "Retourne les événements déjà terminés.")
public Response getPastEvents() {
LOG.info("[LOG] Récupération des événements passés.");
@Operation(summary = "Récupérer les événements passés", description = "Retourne les événements déjà terminés avec pagination.")
public Response getPastEvents(
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération des événements passés (page: " + page + ", size: " + size + ")");
List<Events> events = eventService.findPastEvents();
List<Events> events = eventService.findPastEvents(page, size);
if (events.isEmpty()) {
LOG.warn("[LOG] Aucun événement passé trouvé.");
return Response.status(Response.Status.NOT_FOUND).entity("Aucun événement passé trouvé.").build();
@@ -623,14 +699,18 @@ public class EventsResource {
@POST
@Path("/{id}/cancel")
@Transactional
@Operation(summary = "Annuler un événement", description = "Annule un événement sans le supprimer.")
public Response cancelEvent(@PathParam("id") UUID id, @QueryParam("userId") UUID userId) {
LOG.info("[LOG] Annulation de l'événement avec l'ID : " + id + " par l'utilisateur : " + userId);
if (userId == null) {
LOG.error("[ERROR] userId est obligatoire pour annuler un événement");
return Response.status(Response.Status.BAD_REQUEST).entity("L'identifiant de l'utilisateur (userId) est obligatoire").build();
}
@RequiresAuth
@Operation(summary = "Annuler un événement", description = "Annule un événement sans le supprimer. Seul le créateur peut annuler.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Événement annulé")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à annuler cet événement")
@APIResponse(responseCode = "404", description = "Événement non trouvé")
public Response cancelEvent(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID id) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Annulation de l'événement avec l'ID : " + id + " par l'utilisateur : " + authenticatedUserId);
Events event = eventsRepository.findById(id);
if (event == null) {
@@ -639,8 +719,8 @@ public class EventsResource {
}
// Vérifier que l'utilisateur est le créateur
if (!eventService.canModifyEvent(event, userId)) {
LOG.error("[ERROR] L'utilisateur " + userId + " n'a pas les permissions pour annuler l'événement " + id);
if (!eventService.canModifyEvent(event, authenticatedUserId)) {
LOG.error("[ERROR] L'utilisateur " + authenticatedUserId + " n'a pas les permissions pour annuler l'événement " + id);
return Response.status(Response.Status.FORBIDDEN).entity("Vous n'avez pas les permissions pour annuler cet événement").build();
}
@@ -770,18 +850,30 @@ public class EventsResource {
@POST
@Path("/{id}/favorite")
@Transactional
@Operation(summary = "Toggle favori d'un événement", description = "Permet à un utilisateur d'ajouter ou retirer un événement de ses favoris (toggle).")
public Response favoriteEvent(@PathParam("id") UUID eventId, @QueryParam("userId") UUID userId) {
LOG.info("[LOG] Toggle favori de l'événement " + eventId + " pour l'utilisateur ID : " + userId);
@RequiresAuth
@Operation(summary = "Toggle favori d'un événement", description = "Permet à l'utilisateur authentifié d'ajouter ou retirer un événement de ses favoris.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Favori modifié")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "404", description = "Événement non trouvé")
public Response favoriteEvent(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID eventId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Toggle favori de l'événement " + eventId + " pour l'utilisateur ID : " + authenticatedUserId);
Events event = eventsRepository.findById(eventId);
Users user = usersRepository.findById(userId);
if (event == null || user == null) {
LOG.warn("[LOG] Événement ou utilisateur non trouvé.");
return Response.status(Response.Status.NOT_FOUND).entity("Événement ou utilisateur non trouvé.").build();
Users user = usersRepository.findById(authenticatedUserId);
if (event == null) {
LOG.warn("[LOG] Événement non trouvé.");
return Response.status(Response.Status.NOT_FOUND).entity("Événement non trouvé.").build();
}
if (user == null) {
LOG.warn("[LOG] Utilisateur non trouvé.");
return Response.status(Response.Status.NOT_FOUND).entity("Utilisateur non trouvé.").build();
}
// Toggle : ajouter si pas favori, retirer si déjà favori
// Toggle : ajouter si pas favori, retirer si déjà favori
boolean wasFavorite = user.hasFavoriteEvent(event);
if (wasFavorite) {
user.removeFavoriteEvent(event);
@@ -848,12 +940,18 @@ public class EventsResource {
@POST
@Path("/{id}/comments")
@Transactional
@Operation(summary = "Ajouter un commentaire à un événement", description = "Crée un nouveau commentaire pour un événement.")
@RequiresAuth
@Operation(summary = "Ajouter un commentaire à un événement", description = "Crée un nouveau commentaire pour un événement. Requiert une authentification JWT.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "201", description = "Commentaire créé")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "404", description = "Événement non trouvé")
public Response addComment(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID eventId,
@QueryParam("userId") UUID userId,
Map<String, String> requestBody) {
LOG.info("[LOG] Ajout d'un commentaire à l'événement ID : " + eventId + " par l'utilisateur ID : " + userId);
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Ajout d'un commentaire à l'événement ID : " + eventId + " par l'utilisateur ID : " + authenticatedUserId);
Events event = eventsRepository.findById(eventId);
if (event == null) {
@@ -861,13 +959,13 @@ public class EventsResource {
return Response.status(Response.Status.NOT_FOUND).entity("Événement non trouvé.").build();
}
Users user = usersRepository.findById(userId);
Users user = usersRepository.findById(authenticatedUserId);
if (user == null) {
LOG.warn("[LOG] Utilisateur non trouvé avec l'ID : " + userId);
LOG.warn("[LOG] Utilisateur non trouvé avec l'ID : " + authenticatedUserId);
return Response.status(Response.Status.NOT_FOUND).entity("Utilisateur non trouvé.").build();
}
String text = requestBody.get("text");
String text = requestBody != null ? requestBody.get("text") : null;
if (text == null || text.trim().isEmpty()) {
LOG.warn("[LOG] Le texte du commentaire est vide");
return Response.status(Response.Status.BAD_REQUEST).entity("Le texte du commentaire est requis.").build();
@@ -926,9 +1024,17 @@ public class EventsResource {
@POST
@Path("/{id}/share")
@Transactional
@Operation(summary = "Enregistrer un partage d'événement", description = "Enregistre qu'un utilisateur a partagé l'événement (incrémente le compteur).")
public Response shareEvent(@PathParam("id") UUID eventId, @QueryParam("userId") UUID userId) {
LOG.info("[LOG] Partage de l'événement ID : " + eventId + " par l'utilisateur ID : " + userId);
@RequiresAuth
@Operation(summary = "Enregistrer un partage d'événement", description = "Enregistre que l'utilisateur authentifié a partagé l'événement.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "201", description = "Partage enregistré")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "404", description = "Événement non trouvé")
public Response shareEvent(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID eventId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Partage de l'événement ID : " + eventId + " par l'utilisateur ID : " + authenticatedUserId);
Events event = eventsRepository.findById(eventId);
if (event == null) {
@@ -936,9 +1042,9 @@ public class EventsResource {
return Response.status(Response.Status.NOT_FOUND).entity("Événement non trouvé.").build();
}
Users user = usersRepository.findById(userId);
Users user = usersRepository.findById(authenticatedUserId);
if (user == null) {
LOG.warn("[LOG] Utilisateur non trouvé avec l'ID : " + userId);
LOG.warn("[LOG] Utilisateur non trouvé avec l'ID : " + authenticatedUserId);
return Response.status(Response.Status.NOT_FOUND).entity("Utilisateur non trouvé.").build();
}
@@ -956,19 +1062,31 @@ public class EventsResource {
/**
* Endpoint pour fermer un événement.
* Requiert une authentification JWT.
* Seul le créateur peut fermer l'événement.
*
* @param requestContext Le contexte de la requête (injecté)
* @param eventId L'ID de l'événement.
* @return Une réponse HTTP indiquant le succès de la fermeture.
*/
@PATCH
@Path("/{id}/close")
@Transactional
@RequiresAuth
@Operation(
summary = "Fermer un événement",
description = "Ferme un événement et empêche les nouvelles participations"
description = "Ferme un événement et empêche les nouvelles participations. Seul le créateur peut fermer."
)
public Response closeEvent(@PathParam("id") UUID eventId) {
LOG.info("Tentative de fermeture de l'événement avec l'ID : " + eventId);
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Événement fermé")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à fermer cet événement")
@APIResponse(responseCode = "404", description = "Événement non trouvé")
public Response closeEvent(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID eventId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("Tentative de fermeture de l'événement avec l'ID : " + eventId + " par l'utilisateur : " + authenticatedUserId);
// Recherche de l'événement par ID
Events event = eventsRepository.findById(eventId);
@@ -979,6 +1097,12 @@ public class EventsResource {
.build();
}
// Vérifier que l'utilisateur est le créateur
if (!eventService.canModifyEvent(event, authenticatedUserId)) {
LOG.error("[ERROR] L'utilisateur " + authenticatedUserId + " n'a pas les permissions pour fermer l'événement " + eventId);
return Response.status(Response.Status.FORBIDDEN).entity("Vous n'avez pas les permissions pour fermer cet événement").build();
}
// Marquer l'événement comme fermé
event.setStatus("fermé"); // Modification du statut de l'événement
eventsRepository.persist(event); // Persister les modifications dans la base
@@ -990,19 +1114,31 @@ public class EventsResource {
/**
* Endpoint pour réouvrir un événement.
* Requiert une authentification JWT.
* Seul le créateur peut réouvrir l'événement.
*
* @param requestContext Le contexte de la requête (injecté)
* @param eventId L'ID de l'événement à rouvrir.
* @return Une réponse HTTP indiquant le succès ou l'échec de la réouverture.
*/
@PATCH
@Path("{eventId}/reopen")
@Transactional
@RequiresAuth
@Operation(
summary = "Rouvrir un événement",
description = "Rouvre un événement existant qui est actuellement fermé"
description = "Rouvre un événement fermé. Seul le créateur peut réouvrir."
)
public Response reopenEvent(@PathParam("eventId") UUID eventId) {
LOG.info("Tentative de réouverture de l'événement avec l'ID : " + eventId);
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Événement rouvert")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à réouvrir cet événement")
@APIResponse(responseCode = "404", description = "Événement non trouvé")
public Response reopenEvent(
@Context ContainerRequestContext requestContext,
@PathParam("eventId") UUID eventId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("Tentative de réouverture de l'événement avec l'ID : " + eventId + " par l'utilisateur : " + authenticatedUserId);
// Recherche de l'événement par ID
Events event = eventsRepository.findById(eventId);
@@ -1013,6 +1149,12 @@ public class EventsResource {
.build();
}
// Vérifier que l'utilisateur est le créateur
if (!eventService.canModifyEvent(event, authenticatedUserId)) {
LOG.error("[ERROR] L'utilisateur " + authenticatedUserId + " n'a pas les permissions pour réouvrir l'événement " + eventId);
return Response.status(Response.Status.FORBIDDEN).entity("Vous n'avez pas les permissions pour réouvrir cet événement").build();
}
// Vérifier si l'événement est déjà ouvert
if ("ouvert".equals(event.getStatus())) {
LOG.warn("L'événement est déjà ouvert : " + eventId);

View File

@@ -6,9 +6,10 @@ import com.lions.dev.dto.response.chat.MessageResponseDTO;
import com.lions.dev.entity.chat.Conversation;
import com.lions.dev.entity.chat.Message;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.UsersRepository;
import com.lions.dev.service.MessageService;
import com.lions.dev.service.UsersService;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@@ -29,6 +30,9 @@ import java.util.stream.Collectors;
* - Récupérer les messages d'une conversation
* - Marquer les messages comme lus
* - Supprimer des messages et conversations
*
* La couche resource ne fait pas d'accès direct au repository : elle délègue au service
* et laisse le GlobalExceptionHandler gérer les exceptions métier.
*/
@Path("/messages")
@Produces(MediaType.APPLICATION_JSON)
@@ -42,90 +46,40 @@ public class MessageResource {
MessageService messageService;
@Inject
UsersRepository usersRepository;
UsersService usersService;
/**
* Envoie un nouveau message.
*
* @param request Le DTO contenant les informations du message
* @return Le message créé
*/
@POST
@Operation(summary = "Envoyer un message", description = "Envoie un nouveau message à un utilisateur")
public Response sendMessage(SendMessageRequestDTO request) {
public Response sendMessage(@Valid SendMessageRequestDTO request) {
LOG.info("[LOG] Réception d'une demande d'envoi de message");
try {
// Validation
if (!request.isValid()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"message\": \"Données invalides\"}")
.build();
}
Message message = messageService.sendMessage(
request.getSenderId(),
request.getRecipientId(),
request.getContent(),
request.getMessageType(),
request.getMediaUrl()
);
// Envoyer le message
Message message = messageService.sendMessage(
request.getSenderId(),
request.getRecipientId(),
request.getContent(),
request.getMessageType(),
request.getMediaUrl()
);
MessageResponseDTO response = new MessageResponseDTO(message);
return Response.status(Response.Status.CREATED).entity(response).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de l'envoi du message : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de l'envoi du message\"}")
.build();
}
MessageResponseDTO response = new MessageResponseDTO(message);
return Response.status(Response.Status.CREATED).entity(response).build();
}
/**
* Récupère toutes les conversations d'un utilisateur.
*
* @param userId L'ID de l'utilisateur
* @return Liste des conversations
*/
@GET
@Path("/conversations/{userId}")
@Operation(summary = "Récupérer les conversations", description = "Récupère toutes les conversations d'un utilisateur")
public Response getUserConversations(@PathParam("userId") UUID userId) {
LOG.info("[LOG] Récupération des conversations pour l'utilisateur ID : " + userId);
try {
Users user = usersRepository.findById(userId);
if (user == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Utilisateur non trouvé\"}")
.build();
}
Users user = usersService.getUserById(userId);
List<Conversation> conversations = messageService.getUserConversations(userId);
List<ConversationResponseDTO> response = conversations.stream()
.map(conv -> new ConversationResponseDTO(conv, user))
.collect(Collectors.toList());
List<Conversation> conversations = messageService.getUserConversations(userId);
List<ConversationResponseDTO> response = conversations.stream()
.map(conv -> new ConversationResponseDTO(conv, user))
.collect(Collectors.toList());
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des conversations : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la récupération des conversations\"}")
.build();
}
return Response.ok(response).build();
}
/**
* Récupère les messages d'une conversation.
*
* @param conversationId L'ID de la conversation
* @param page Le numéro de la page (défaut: 0)
* @param size La taille de la page (défaut: 50)
* @return Liste des messages
*/
@GET
@Path("/conversation/{conversationId}")
@Operation(summary = "Récupérer les messages", description = "Récupère les messages d'une conversation avec pagination")
@@ -135,29 +89,14 @@ public class MessageResource {
@QueryParam("size") @DefaultValue("50") int size) {
LOG.info("[LOG] Récupération des messages pour la conversation ID : " + conversationId);
try {
List<Message> messages = messageService.getConversationMessages(conversationId, page, size);
List<MessageResponseDTO> response = messages.stream()
.map(MessageResponseDTO::new)
.collect(Collectors.toList());
List<Message> messages = messageService.getConversationMessages(conversationId, page, size);
List<MessageResponseDTO> response = messages.stream()
.map(MessageResponseDTO::new)
.collect(Collectors.toList());
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des messages : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la récupération des messages\"}")
.build();
}
return Response.ok(response).build();
}
/**
* Récupère une conversation entre deux utilisateurs.
*
* @param user1Id L'ID du premier utilisateur
* @param user2Id L'ID du deuxième utilisateur
* @return La conversation
*/
@GET
@Path("/conversation/between/{user1Id}/{user2Id}")
@Operation(summary = "Récupérer une conversation", description = "Récupère la conversation entre deux utilisateurs")
@@ -166,65 +105,24 @@ public class MessageResource {
@PathParam("user2Id") UUID user2Id) {
LOG.info("[LOG] Recherche de conversation entre " + user1Id + " et " + user2Id);
try {
Users user1 = usersRepository.findById(user1Id);
if (user1 == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Utilisateur non trouvé\"}")
.build();
}
Users user1 = usersService.getUserById(user1Id);
Conversation conversation = messageService.getConversationBetweenUsers(user1Id, user2Id);
ConversationResponseDTO response = new ConversationResponseDTO(conversation, user1);
Conversation conversation = messageService.getConversationBetweenUsers(user1Id, user2Id);
if (conversation == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Conversation non trouvée\"}")
.build();
}
ConversationResponseDTO response = new ConversationResponseDTO(conversation, user1);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération de la conversation : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la récupération de la conversation\"}")
.build();
}
return Response.ok(response).build();
}
/**
* Marque un message comme lu.
*
* @param messageId L'ID du message
* @return Le message mis à jour
*/
@PUT
@Path("/{messageId}/read")
@Operation(summary = "Marquer comme lu", description = "Marque un message comme lu")
public Response markMessageAsRead(@PathParam("messageId") UUID messageId) {
LOG.info("[LOG] Marquage du message comme lu : " + messageId);
try {
Message message = messageService.markMessageAsRead(messageId);
MessageResponseDTO response = new MessageResponseDTO(message);
return Response.ok(response).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors du marquage du message : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors du marquage du message\"}")
.build();
}
Message message = messageService.markMessageAsRead(messageId);
MessageResponseDTO response = new MessageResponseDTO(message);
return Response.ok(response).build();
}
/**
* Marque tous les messages d'une conversation comme lus.
*
* @param conversationId L'ID de la conversation
* @param userId L'ID de l'utilisateur
* @return Le nombre de messages marqués comme lus
*/
@PUT
@Path("/conversation/{conversationId}/read/{userId}")
@Operation(summary = "Marquer tout comme lu", description = "Marque tous les messages d'une conversation comme lus")
@@ -233,100 +131,50 @@ public class MessageResource {
@PathParam("userId") UUID userId) {
LOG.info("[LOG] Marquage de tous les messages comme lus pour la conversation " + conversationId);
try {
int count = messageService.markAllMessagesAsRead(conversationId, userId);
return Response.ok("{\"messagesMarkedAsRead\": " + count + "}").build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors du marquage des messages : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors du marquage des messages\"}")
.build();
}
int count = messageService.markAllMessagesAsRead(conversationId, userId);
return Response.ok("{\"messagesMarkedAsRead\": " + count + "}").build();
}
/**
* Récupère le nombre total de messages non lus pour un utilisateur.
*
* @param userId L'ID de l'utilisateur
* @return Le nombre de messages non lus
*/
@GET
@Path("/unread/count/{userId}")
@Operation(summary = "Compter les non lus", description = "Compte le nombre total de messages non lus")
public Response getTotalUnreadCount(@PathParam("userId") UUID userId) {
LOG.info("[LOG] Récupération du nombre de messages non lus pour l'utilisateur " + userId);
try {
long count = messageService.getTotalUnreadCount(userId);
return Response.ok("{\"unreadCount\": " + count + "}").build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors du comptage des messages non lus : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors du comptage\"}")
.build();
}
long count = messageService.getTotalUnreadCount(userId);
return Response.ok("{\"unreadCount\": " + count + "}").build();
}
/**
* Supprime un message.
*
* @param messageId L'ID du message
* @return Confirmation de suppression
*/
@DELETE
@Path("/{messageId}")
@Operation(summary = "Supprimer un message", description = "Supprime un message")
public Response deleteMessage(@PathParam("messageId") UUID messageId) {
LOG.info("[LOG] Suppression du message ID : " + messageId);
try {
boolean deleted = messageService.deleteMessage(messageId);
boolean deleted = messageService.deleteMessage(messageId);
if (deleted) {
return Response.ok("{\"message\": \"Message supprimé avec succès\"}").build();
} else {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Message non trouvé\"}")
.build();
}
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la suppression du message : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la suppression\"}")
if (deleted) {
return Response.ok("{\"message\": \"Message supprimé avec succès\"}").build();
} else {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Message non trouvé\"}")
.build();
}
}
/**
* Supprime une conversation.
*
* @param conversationId L'ID de la conversation
* @return Confirmation de suppression
*/
@DELETE
@Path("/conversation/{conversationId}")
@Operation(summary = "Supprimer une conversation", description = "Supprime une conversation et tous ses messages")
public Response deleteConversation(@PathParam("conversationId") UUID conversationId) {
LOG.info("[LOG] Suppression de la conversation ID : " + conversationId);
try {
boolean deleted = messageService.deleteConversation(conversationId);
boolean deleted = messageService.deleteConversation(conversationId);
if (deleted) {
return Response.ok("{\"message\": \"Conversation supprimée avec succès\"}").build();
} else {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Conversation non trouvée\"}")
.build();
}
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la suppression de la conversation : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la suppression\"}")
if (deleted) {
return Response.ok("{\"message\": \"Conversation supprimée avec succès\"}").build();
} else {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Conversation non trouvée\"}")
.build();
}
}

View File

@@ -0,0 +1,423 @@
package com.lions.dev.resource;
import com.lions.dev.core.security.JwtAuthFilter;
import com.lions.dev.core.security.RequiresAuth;
import com.lions.dev.dto.request.promotion.PromotionCreateRequestDTO;
import com.lions.dev.dto.request.promotion.PromotionUpdateRequestDTO;
import com.lions.dev.dto.response.promotion.PromotionResponseDTO;
import com.lions.dev.entity.promotion.Promotion;
import com.lions.dev.service.PromotionService;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Ressource REST pour la gestion des promotions dans le système AfterWork.
*
* Cette classe expose des endpoints pour créer, récupérer, mettre à jour
* et supprimer des promotions d'établissements.
*/
@Path("/promotions")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Promotions", description = "Opérations liées à la gestion des promotions")
public class PromotionResource {
@Inject
PromotionService promotionService;
private static final Logger LOG = Logger.getLogger(PromotionResource.class);
/**
* Extrait l'ID de l'utilisateur authentifié du contexte de la requête.
*/
private UUID getAuthenticatedUserId(ContainerRequestContext requestContext) {
return (UUID) requestContext.getProperty(JwtAuthFilter.AUTHENTICATED_USER_ID);
}
// =====================================================================
// ENDPOINTS PUBLICS (LECTURE)
// =====================================================================
/**
* Récupère toutes les promotions actives et valides.
*
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des promotions actives
*/
@GET
@Operation(
summary = "Récupérer toutes les promotions actives",
description = "Retourne une liste paginée de toutes les promotions actives et valides")
@APIResponse(responseCode = "200", description = "Liste des promotions")
public Response getAllActivePromotions(
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération de toutes les promotions actives (page: " + page + ", size: " + size + ")");
try {
List<Promotion> promotions = promotionService.getAllActivePromotions(page, size);
List<PromotionResponseDTO> responseDTOs = promotions.stream()
.map(PromotionResponseDTO::new)
.collect(Collectors.toList());
return Response.ok(responseDTOs).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des promotions : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la récupération des promotions.\"}")
.build();
}
}
/**
* Récupère une promotion par son ID.
*
* @param promotionId L'ID de la promotion
* @return La promotion trouvée
*/
@GET
@Path("/{id}")
@Operation(
summary = "Récupérer une promotion par ID",
description = "Retourne les détails d'une promotion spécifique")
@APIResponse(responseCode = "200", description = "Promotion trouvée")
@APIResponse(responseCode = "404", description = "Promotion non trouvée")
public Response getPromotionById(@PathParam("id") UUID promotionId) {
LOG.info("[LOG] Récupération de la promotion ID : " + promotionId);
try {
Promotion promotion = promotionService.getPromotionById(promotionId);
PromotionResponseDTO responseDTO = new PromotionResponseDTO(promotion);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] Promotion non trouvée : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Promotion non trouvée.\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération de la promotion : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la récupération de la promotion.\"}")
.build();
}
}
/**
* Recherche une promotion par code promo.
*
* @param code Le code promo à rechercher
* @return La promotion trouvée
*/
@GET
@Path("/code/{code}")
@Operation(
summary = "Rechercher une promotion par code promo",
description = "Retourne la promotion correspondant au code promo")
@APIResponse(responseCode = "200", description = "Promotion trouvée")
@APIResponse(responseCode = "404", description = "Code promo non trouvé")
public Response getPromotionByCode(@PathParam("code") String code) {
LOG.info("[LOG] Recherche de la promotion avec le code : " + code);
try {
Optional<Promotion> promotionOpt = promotionService.getPromotionByCode(code);
if (promotionOpt.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Code promo non trouvé.\"}")
.build();
}
PromotionResponseDTO responseDTO = new PromotionResponseDTO(promotionOpt.get());
return Response.ok(responseDTO).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la recherche du code promo : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la recherche du code promo.\"}")
.build();
}
}
/**
* Récupère les promotions d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @param activeOnly Si true, retourne uniquement les promotions actives et valides
* @param page Le numéro de la page
* @param size La taille de la page
* @return Liste des promotions
*/
@GET
@Path("/establishment/{establishmentId}")
@Operation(
summary = "Récupérer les promotions d'un établissement",
description = "Retourne les promotions d'un établissement spécifique")
@APIResponse(responseCode = "200", description = "Liste des promotions")
public Response getPromotionsByEstablishment(
@PathParam("establishmentId") UUID establishmentId,
@QueryParam("activeOnly") @DefaultValue("false") boolean activeOnly,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération des promotions pour l'établissement : " + establishmentId);
try {
List<Promotion> promotions;
if (activeOnly) {
promotions = promotionService.getActivePromotionsByEstablishment(establishmentId);
} else {
promotions = promotionService.getPromotionsByEstablishment(establishmentId, page, size);
}
List<PromotionResponseDTO> responseDTOs = promotions.stream()
.map(PromotionResponseDTO::new)
.collect(Collectors.toList());
return Response.ok(responseDTOs).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des promotions : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la récupération des promotions.\"}")
.build();
}
}
// =====================================================================
// ENDPOINTS PROTÉGÉS (CRÉATION, MODIFICATION, SUPPRESSION)
// =====================================================================
/**
* Crée une nouvelle promotion.
* Requiert une authentification JWT.
* Seul le responsable de l'établissement peut créer des promotions.
*
* @param requestContext Le contexte de la requête (injecté)
* @param requestDTO Le DTO de création
* @return La promotion créée
*/
@POST
@Transactional
@RequiresAuth
@Operation(
summary = "Créer une promotion",
description = "Crée une nouvelle promotion pour un établissement. Seul le responsable peut créer.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "201", description = "Promotion créée avec succès")
@APIResponse(responseCode = "400", description = "Données invalides")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à créer des promotions pour cet établissement")
public Response createPromotion(
@Context ContainerRequestContext requestContext,
@Valid PromotionCreateRequestDTO requestDTO) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Création d'une promotion pour l'établissement : " + requestDTO.getEstablishmentId() +
" par l'utilisateur : " + authenticatedUserId);
try {
Promotion promotion = promotionService.createPromotion(requestDTO);
// Vérifier que l'utilisateur est bien le responsable
if (!promotionService.canModifyPromotion(promotion, authenticatedUserId)) {
promotionService.deletePromotion(promotion.getId()); // Rollback
LOG.warn("[WARN] Utilisateur " + authenticatedUserId + " non autorisé à créer des promotions pour cet établissement");
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Vous n'êtes pas autorisé à créer des promotions pour cet établissement.\"}")
.build();
}
PromotionResponseDTO responseDTO = new PromotionResponseDTO(promotion);
return Response.status(Response.Status.CREATED).entity(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] " + e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la création de la promotion : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la création de la promotion.\"}")
.build();
}
}
/**
* Met à jour une promotion.
* Requiert une authentification JWT.
* Seul le responsable de l'établissement peut modifier.
*
* @param requestContext Le contexte de la requête (injecté)
* @param promotionId L'ID de la promotion
* @param requestDTO Le DTO de mise à jour
* @return La promotion mise à jour
*/
@PUT
@Path("/{id}")
@Transactional
@RequiresAuth
@Operation(
summary = "Mettre à jour une promotion",
description = "Met à jour une promotion existante. Seul le responsable peut modifier.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Promotion mise à jour")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à modifier cette promotion")
@APIResponse(responseCode = "404", description = "Promotion non trouvée")
public Response updatePromotion(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID promotionId,
@Valid PromotionUpdateRequestDTO requestDTO) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Mise à jour de la promotion : " + promotionId + " par l'utilisateur : " + authenticatedUserId);
try {
// Vérifier les permissions
Promotion existingPromotion = promotionService.getPromotionById(promotionId);
if (!promotionService.canModifyPromotion(existingPromotion, authenticatedUserId)) {
LOG.warn("[WARN] Utilisateur " + authenticatedUserId + " non autorisé à modifier la promotion " + promotionId);
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Vous n'êtes pas autorisé à modifier cette promotion.\"}")
.build();
}
Promotion promotion = promotionService.updatePromotion(promotionId, requestDTO);
PromotionResponseDTO responseDTO = new PromotionResponseDTO(promotion);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la mise à jour de la promotion : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la mise à jour de la promotion.\"}")
.build();
}
}
/**
* Supprime une promotion.
* Requiert une authentification JWT.
* Seul le responsable de l'établissement peut supprimer.
*
* @param requestContext Le contexte de la requête (injecté)
* @param promotionId L'ID de la promotion
* @return Confirmation de suppression
*/
@DELETE
@Path("/{id}")
@Transactional
@RequiresAuth
@Operation(
summary = "Supprimer une promotion",
description = "Supprime une promotion. Seul le responsable peut supprimer.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "204", description = "Promotion supprimée")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à supprimer cette promotion")
@APIResponse(responseCode = "404", description = "Promotion non trouvée")
public Response deletePromotion(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID promotionId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Suppression de la promotion : " + promotionId + " par l'utilisateur : " + authenticatedUserId);
try {
// Vérifier les permissions
Promotion existingPromotion = promotionService.getPromotionById(promotionId);
if (!promotionService.canModifyPromotion(existingPromotion, authenticatedUserId)) {
LOG.warn("[WARN] Utilisateur " + authenticatedUserId + " non autorisé à supprimer la promotion " + promotionId);
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Vous n'êtes pas autorisé à supprimer cette promotion.\"}")
.build();
}
boolean deleted = promotionService.deletePromotion(promotionId);
if (deleted) {
return Response.noContent().build();
} else {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Promotion non trouvée.\"}")
.build();
}
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Promotion non trouvée.\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la suppression de la promotion : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la suppression de la promotion.\"}")
.build();
}
}
/**
* Active ou désactive une promotion.
* Requiert une authentification JWT.
*
* @param requestContext Le contexte de la requête (injecté)
* @param promotionId L'ID de la promotion
* @param isActive L'état à appliquer
* @return La promotion mise à jour
*/
@PATCH
@Path("/{id}/active")
@Transactional
@RequiresAuth
@Operation(
summary = "Activer/Désactiver une promotion",
description = "Change l'état actif d'une promotion. Seul le responsable peut modifier.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "État mis à jour")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé")
@APIResponse(responseCode = "404", description = "Promotion non trouvée")
public Response setPromotionActive(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID promotionId,
@QueryParam("active") @DefaultValue("true") boolean isActive) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Changement d'état de la promotion " + promotionId + " à " + isActive);
try {
// Vérifier les permissions
Promotion existingPromotion = promotionService.getPromotionById(promotionId);
if (!promotionService.canModifyPromotion(existingPromotion, authenticatedUserId)) {
LOG.warn("[WARN] Utilisateur " + authenticatedUserId + " non autorisé");
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Vous n'êtes pas autorisé à modifier cette promotion.\"}")
.build();
}
Promotion promotion = promotionService.setPromotionActive(promotionId, isActive);
PromotionResponseDTO responseDTO = new PromotionResponseDTO(promotion);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Promotion non trouvée.\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la mise à jour.\"}")
.build();
}
}
}

View File

@@ -0,0 +1,343 @@
package com.lions.dev.resource;
import com.lions.dev.core.security.JwtAuthFilter;
import com.lions.dev.core.security.RequiresAuth;
import com.lions.dev.dto.request.review.ReviewCreateRequestDTO;
import com.lions.dev.dto.request.review.ReviewUpdateRequestDTO;
import com.lions.dev.dto.response.review.ReviewResponseDTO;
import com.lions.dev.entity.establishment.Review;
import com.lions.dev.service.ReviewService;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Ressource REST pour la gestion des avis d'établissements.
*
* Cette classe expose des endpoints pour créer, récupérer, mettre à jour
* et supprimer des avis sur les établissements.
*/
@Path("/reviews")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Reviews", description = "Opérations liées aux avis sur les établissements")
public class ReviewResource {
@Inject
ReviewService reviewService;
private static final Logger LOG = Logger.getLogger(ReviewResource.class);
/**
* Extrait l'ID de l'utilisateur authentifié du contexte de la requête.
*/
private UUID getAuthenticatedUserId(ContainerRequestContext requestContext) {
return (UUID) requestContext.getProperty(JwtAuthFilter.AUTHENTICATED_USER_ID);
}
// =====================================================================
// ENDPOINTS PUBLICS (LECTURE)
// =====================================================================
/**
* Récupère un avis par son ID.
*/
@GET
@Path("/{id}")
@Operation(
summary = "Récupérer un avis par ID",
description = "Retourne les détails d'un avis spécifique")
@APIResponse(responseCode = "200", description = "Avis trouvé")
@APIResponse(responseCode = "404", description = "Avis non trouvé")
public Response getReviewById(@PathParam("id") UUID reviewId) {
LOG.info("[LOG] Récupération de l'avis ID : " + reviewId);
try {
Review review = reviewService.getReviewById(reviewId);
ReviewResponseDTO responseDTO = new ReviewResponseDTO(review);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] Avis non trouvé : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Avis non trouvé.\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération de l'avis : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la récupération de l'avis.\"}")
.build();
}
}
/**
* Récupère les avis d'un établissement.
*/
@GET
@Path("/establishment/{establishmentId}")
@Operation(
summary = "Récupérer les avis d'un établissement",
description = "Retourne la liste paginée des avis pour un établissement")
@APIResponse(responseCode = "200", description = "Liste des avis")
public Response getReviewsByEstablishment(
@PathParam("establishmentId") UUID establishmentId,
@QueryParam("verifiedOnly") @DefaultValue("false") boolean verifiedOnly,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération des avis pour l'établissement : " + establishmentId);
try {
List<Review> reviews;
if (verifiedOnly) {
reviews = reviewService.getVerifiedReviewsByEstablishment(establishmentId, page, size);
} else {
reviews = reviewService.getReviewsByEstablishment(establishmentId, page, size);
}
List<ReviewResponseDTO> responseDTOs = reviews.stream()
.map(ReviewResponseDTO::new)
.collect(Collectors.toList());
return Response.ok(responseDTOs).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des avis : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la récupération des avis.\"}")
.build();
}
}
/**
* Récupère les statistiques des avis pour un établissement.
*/
@GET
@Path("/establishment/{establishmentId}/stats")
@Operation(
summary = "Récupérer les statistiques des avis",
description = "Retourne les statistiques (moyenne, distribution, etc.) des avis pour un établissement")
@APIResponse(responseCode = "200", description = "Statistiques des avis")
public Response getReviewStats(@PathParam("establishmentId") UUID establishmentId) {
LOG.info("[LOG] Récupération des statistiques pour l'établissement : " + establishmentId);
try {
Map<String, Object> stats = reviewService.getReviewStats(establishmentId);
return Response.ok(stats).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des statistiques : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la récupération des statistiques.\"}")
.build();
}
}
/**
* Récupère les avis d'un utilisateur.
*/
@GET
@Path("/user/{userId}")
@Operation(
summary = "Récupérer les avis d'un utilisateur",
description = "Retourne la liste paginée des avis écrits par un utilisateur")
@APIResponse(responseCode = "200", description = "Liste des avis")
public Response getReviewsByUser(
@PathParam("userId") UUID userId,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération des avis de l'utilisateur : " + userId);
try {
List<Review> reviews = reviewService.getReviewsByUser(userId, page, size);
List<ReviewResponseDTO> responseDTOs = reviews.stream()
.map(ReviewResponseDTO::new)
.collect(Collectors.toList());
return Response.ok(responseDTOs).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des avis : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la récupération des avis.\"}")
.build();
}
}
/**
* Vérifie si l'utilisateur a déjà écrit un avis pour un établissement.
*/
@GET
@Path("/establishment/{establishmentId}/user/{userId}")
@Operation(
summary = "Récupérer l'avis d'un utilisateur pour un établissement",
description = "Retourne l'avis si l'utilisateur en a écrit un, 404 sinon")
@APIResponse(responseCode = "200", description = "Avis trouvé")
@APIResponse(responseCode = "404", description = "Aucun avis trouvé")
public Response getUserReviewForEstablishment(
@PathParam("establishmentId") UUID establishmentId,
@PathParam("userId") UUID userId) {
LOG.info("[LOG] Recherche de l'avis de l'utilisateur " + userId + " pour l'établissement " + establishmentId);
try {
Optional<Review> reviewOpt = reviewService.getUserReviewForEstablishment(establishmentId, userId);
if (reviewOpt.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Aucun avis trouvé.\"}")
.build();
}
ReviewResponseDTO responseDTO = new ReviewResponseDTO(reviewOpt.get());
return Response.ok(responseDTO).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la recherche de l'avis : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la recherche de l'avis.\"}")
.build();
}
}
// =====================================================================
// ENDPOINTS PROTÉGÉS (CRÉATION, MODIFICATION, SUPPRESSION)
// =====================================================================
/**
* Crée un nouvel avis.
* Requiert une authentification JWT.
*/
@POST
@Transactional
@RequiresAuth
@Operation(
summary = "Créer un avis",
description = "Crée un nouvel avis pour un établissement. Un seul avis par utilisateur et établissement.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "201", description = "Avis créé avec succès")
@APIResponse(responseCode = "400", description = "Données invalides ou avis déjà existant")
@APIResponse(responseCode = "401", description = "Non authentifié")
public Response createReview(
@Context ContainerRequestContext requestContext,
@Valid ReviewCreateRequestDTO requestDTO) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Création d'un avis pour l'établissement : " + requestDTO.getEstablishmentId() +
" par l'utilisateur : " + authenticatedUserId);
try {
Review review = reviewService.createReview(authenticatedUserId, requestDTO);
ReviewResponseDTO responseDTO = new ReviewResponseDTO(review);
return Response.status(Response.Status.CREATED).entity(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] " + e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la création de l'avis : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la création de l'avis.\"}")
.build();
}
}
/**
* Met à jour un avis.
* Requiert une authentification JWT.
* Seul l'auteur peut modifier.
*/
@PUT
@Path("/{id}")
@Transactional
@RequiresAuth
@Operation(
summary = "Mettre à jour un avis",
description = "Met à jour un avis existant. Seul l'auteur peut modifier.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Avis mis à jour")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à modifier cet avis")
@APIResponse(responseCode = "404", description = "Avis non trouvé")
public Response updateReview(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID reviewId,
@Valid ReviewUpdateRequestDTO requestDTO) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Mise à jour de l'avis : " + reviewId + " par l'utilisateur : " + authenticatedUserId);
try {
Review review = reviewService.updateReview(reviewId, authenticatedUserId, requestDTO);
ReviewResponseDTO responseDTO = new ReviewResponseDTO(review);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
} catch (SecurityException e) {
LOG.warn("[WARN] " + e.getMessage());
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la mise à jour de l'avis : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la mise à jour de l'avis.\"}")
.build();
}
}
/**
* Supprime un avis.
* Requiert une authentification JWT.
* Seul l'auteur peut supprimer.
*/
@DELETE
@Path("/{id}")
@Transactional
@RequiresAuth
@Operation(
summary = "Supprimer un avis",
description = "Supprime un avis. Seul l'auteur peut supprimer.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "204", description = "Avis supprimé")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à supprimer cet avis")
@APIResponse(responseCode = "404", description = "Avis non trouvé")
public Response deleteReview(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID reviewId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Suppression de l'avis : " + reviewId + " par l'utilisateur : " + authenticatedUserId);
try {
boolean deleted = reviewService.deleteReview(reviewId, authenticatedUserId);
if (deleted) {
return Response.noContent().build();
} else {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Avis non trouvé.\"}")
.build();
}
} catch (SecurityException e) {
LOG.warn("[WARN] " + e.getMessage());
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la suppression de l'avis : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la suppression de l'avis.\"}")
.build();
}
}
}

View File

@@ -1,8 +1,14 @@
package com.lions.dev.resource;
import com.lions.dev.core.security.JwtAuthFilter;
import com.lions.dev.core.security.JwtValidationService;
import com.lions.dev.core.security.RequiresAuth;
import com.lions.dev.dto.request.social.PostCommentCreateRequestDTO;
import com.lions.dev.dto.request.social.SocialPostCreateRequestDTO;
import com.lions.dev.dto.request.social.SocialPostUpdateRequestDTO;
import com.lions.dev.dto.response.social.PostCommentResponseDTO;
import com.lions.dev.dto.response.social.SocialPostResponseDTO;
import com.lions.dev.entity.social.PostComment;
import com.lions.dev.entity.social.SocialPost;
import com.lions.dev.exception.UserNotFoundException;
import com.lions.dev.service.SocialPostService;
@@ -10,13 +16,19 @@ import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
@@ -37,8 +49,34 @@ public class SocialPostResource {
@Inject
SocialPostService socialPostService;
@Inject
JwtValidationService jwtValidationService;
private static final Logger LOG = Logger.getLogger(SocialPostResource.class);
/**
* Extrait l'ID de l'utilisateur authentifié du contexte de la requête.
* Cette méthode est utilisée pour les endpoints protégés par @RequiresAuth.
*
* @param requestContext Le contexte de la requête
* @return L'ID de l'utilisateur authentifié
*/
private UUID getAuthenticatedUserId(ContainerRequestContext requestContext) {
return (UUID) requestContext.getProperty(JwtAuthFilter.AUTHENTICATED_USER_ID);
}
/**
* Vérifie que l'utilisateur authentifié correspond à l'utilisateur spécifié.
*
* @param requestContext Le contexte de la requête
* @param expectedUserId L'ID attendu de l'utilisateur
* @return true si les IDs correspondent
*/
private boolean verifyUserOwnership(ContainerRequestContext requestContext, UUID expectedUserId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
return authenticatedUserId != null && authenticatedUserId.equals(expectedUserId);
}
/**
* Récupère tous les posts avec pagination.
*
@@ -103,18 +141,37 @@ public class SocialPostResource {
/**
* Crée un nouveau post social.
* Requiert une authentification JWT.
* L'utilisateur authentifié doit correspondre au creatorId.
*
* @param requestContext Le contexte de la requête (injecté)
* @param requestDTO Le DTO contenant les informations du post à créer
* @return Le post créé
*/
@POST
@Transactional
@RequiresAuth
@Operation(
summary = "Créer un nouveau post",
description = "Crée un nouveau post social et retourne ses détails")
public Response createPost(@Valid SocialPostCreateRequestDTO requestDTO) {
description = "Crée un nouveau post social et retourne ses détails. Requiert une authentification JWT.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "201", description = "Post créé avec succès")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "L'utilisateur authentifié ne correspond pas au creatorId")
public Response createPost(
@Context ContainerRequestContext requestContext,
@Valid SocialPostCreateRequestDTO requestDTO) {
LOG.info("[LOG] Création d'un nouveau post par l'utilisateur ID : " + requestDTO.getCreatorId());
// Vérifier que l'utilisateur authentifié correspond au creatorId
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
if (!authenticatedUserId.equals(requestDTO.getCreatorId())) {
LOG.warn("[WARN] Utilisateur " + authenticatedUserId + " tente de créer un post pour " + requestDTO.getCreatorId());
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Vous ne pouvez créer un post que pour votre propre compte.\"}")
.build();
}
try {
SocialPost post = socialPostService.createPost(
requestDTO.getContent(),
@@ -138,7 +195,10 @@ public class SocialPostResource {
/**
* Met à jour un post.
* Requiert une authentification JWT.
* Seul le créateur du post peut le modifier.
*
* @param requestContext Le contexte de la requête (injecté)
* @param postId L'ID du post
* @param requestDTO Body JSON contenant content et/ou imageUrl (compatible client Flutter)
* @return Le post mis à jour
@@ -146,10 +206,17 @@ public class SocialPostResource {
@PUT
@Path("/{id}")
@Transactional
@RequiresAuth
@Operation(
summary = "Mettre à jour un post",
description = "Met à jour le contenu et/ou l'image d'un post existant (body JSON)")
description = "Met à jour le contenu et/ou l'image d'un post existant. Seul le créateur peut modifier.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Post mis à jour")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à modifier ce post")
@APIResponse(responseCode = "404", description = "Post non trouvé")
public Response updatePost(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID postId,
SocialPostUpdateRequestDTO requestDTO) {
LOG.info("[LOG] Mise à jour du post ID : " + postId);
@@ -161,6 +228,18 @@ public class SocialPostResource {
}
try {
// Récupérer le post pour vérifier le propriétaire
SocialPost existingPost = socialPostService.getPostById(postId);
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
// Vérifier que l'utilisateur authentifié est le créateur du post
if (existingPost.getUser() == null || !authenticatedUserId.equals(existingPost.getUser().getId())) {
LOG.warn("[WARN] Utilisateur " + authenticatedUserId + " tente de modifier le post " + postId + " d'un autre utilisateur");
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Vous ne pouvez modifier que vos propres posts.\"}")
.build();
}
SocialPost post = socialPostService.updatePost(
postId,
requestDTO.getContent(),
@@ -183,20 +262,43 @@ public class SocialPostResource {
/**
* Supprime un post.
* Requiert une authentification JWT.
* Seul le créateur du post peut le supprimer.
*
* @param requestContext Le contexte de la requête (injecté)
* @param postId L'ID du post
* @return Réponse de confirmation
*/
@DELETE
@Path("/{id}")
@Transactional
@RequiresAuth
@Operation(
summary = "Supprimer un post",
description = "Supprime un post social spécifique")
public Response deletePost(@PathParam("id") UUID postId) {
description = "Supprime un post social. Seul le créateur peut supprimer.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "204", description = "Post supprimé")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à supprimer ce post")
@APIResponse(responseCode = "404", description = "Post non trouvé")
public Response deletePost(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID postId) {
LOG.info("[LOG] Suppression du post ID : " + postId);
try {
// Récupérer le post pour vérifier le propriétaire
SocialPost existingPost = socialPostService.getPostById(postId);
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
// Vérifier que l'utilisateur authentifié est le créateur du post
if (existingPost.getUser() == null || !authenticatedUserId.equals(existingPost.getUser().getId())) {
LOG.warn("[WARN] Utilisateur " + authenticatedUserId + " tente de supprimer le post " + postId + " d'un autre utilisateur");
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Vous ne pouvez supprimer que vos propres posts.\"}")
.build();
}
boolean deleted = socialPostService.deletePost(postId);
if (deleted) {
return Response.noContent().build();
@@ -205,6 +307,11 @@ public class SocialPostResource {
.entity("{\"message\": \"Post non trouvé.\"}")
.build();
}
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] Post non trouvé : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Post non trouvé.\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la suppression du post : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
@@ -214,18 +321,23 @@ public class SocialPostResource {
}
/**
* Recherche des posts par contenu.
* Recherche des posts par contenu avec pagination.
*
* @param query Le terme de recherche
* @return Liste des posts correspondant à la recherche
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des posts correspondant à la recherche
*/
@GET
@Path("/search")
@Operation(
summary = "Rechercher des posts",
description = "Recherche des posts sociaux par contenu textuel")
public Response searchPosts(@QueryParam("q") String query) {
LOG.info("[LOG] Recherche de posts avec la requête : " + query);
description = "Recherche des posts sociaux par contenu textuel avec pagination")
public Response searchPosts(
@QueryParam("q") String query,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Recherche de posts avec la requête : " + query + " (page: " + page + ", size: " + size + ")");
if (query == null || query.trim().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
@@ -234,7 +346,7 @@ public class SocialPostResource {
}
try {
List<SocialPost> posts = socialPostService.searchPosts(query);
List<SocialPost> posts = socialPostService.searchPosts(query, page, size);
List<SocialPostResponseDTO> responseDTOs = posts.stream()
.map(SocialPostResponseDTO::new)
.collect(Collectors.toList());
@@ -250,29 +362,32 @@ public class SocialPostResource {
/**
* Like un post.
* Requiert une authentification JWT.
* L'utilisateur authentifié est automatiquement utilisé comme likeur.
*
* @param requestContext Le contexte de la requête (injecté)
* @param postId L'ID du post
* @return Le post mis à jour
*/
@POST
@Path("/{id}/like")
@Transactional
@RequiresAuth
@Operation(
summary = "Liker un post",
description = "Incrémente le compteur de likes d'un post")
description = "Incrémente le compteur de likes d'un post. Requiert une authentification JWT.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Post liké")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "404", description = "Post non trouvé")
public Response likePost(
@PathParam("id") UUID postId,
@QueryParam("userId") UUID userId) {
LOG.info("[LOG] Like du post ID : " + postId + " par utilisateur : " + userId);
if (userId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"message\": \"userId est requis.\"}")
.build();
}
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID postId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Like du post ID : " + postId + " par utilisateur : " + authenticatedUserId);
try {
SocialPost post = socialPostService.likePost(postId, userId);
SocialPost post = socialPostService.likePost(postId, authenticatedUserId);
SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
@@ -289,29 +404,35 @@ public class SocialPostResource {
}
/**
* Ajoute un commentaire à un post.
* Ajoute un commentaire à un post (version simplifiée - incrémente le compteur).
* Requiert une authentification JWT.
* L'utilisateur authentifié est automatiquement utilisé comme auteur.
*
* Note: Préférer l'endpoint POST /{id}/comments qui crée un vrai commentaire persisté.
*
* @param requestContext Le contexte de la requête (injecté)
* @param postId L'ID du post
* @param requestBody Le body JSON avec le contenu du commentaire
* @return Le post mis à jour
*/
@POST
@Path("/{id}/comment")
@Transactional
@RequiresAuth
@Consumes(MediaType.APPLICATION_JSON)
@Operation(
summary = "Commenter un post",
description = "Ajoute un commentaire à un post")
summary = "Commenter un post (legacy)",
description = "Ajoute un commentaire à un post. Requiert une authentification JWT.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Commentaire ajouté")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "404", description = "Post non trouvé")
public Response addComment(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID postId,
@QueryParam("userId") UUID userId,
String requestBody) {
LOG.info("[LOG] Ajout de commentaire au post ID : " + postId + " par utilisateur : " + userId);
if (userId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"message\": \"userId est requis.\"}")
.build();
}
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Ajout de commentaire au post ID : " + postId + " par utilisateur : " + authenticatedUserId);
try {
// Parser le body pour obtenir le contenu du commentaire
@@ -320,7 +441,7 @@ public class SocialPostResource {
Map<String, Object> body = mapper.readValue(requestBody, Map.class);
String commentContent = (String) body.getOrDefault("content", "");
SocialPost post = socialPostService.addComment(postId, userId, commentContent);
SocialPost post = socialPostService.addComment(postId, authenticatedUserId, commentContent);
SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
@@ -338,29 +459,32 @@ public class SocialPostResource {
/**
* Partage un post.
* Requiert une authentification JWT.
* L'utilisateur authentifié est automatiquement utilisé comme partageur.
*
* @param requestContext Le contexte de la requête (injecté)
* @param postId L'ID du post
* @return Le post mis à jour
*/
@POST
@Path("/{id}/share")
@Transactional
@RequiresAuth
@Operation(
summary = "Partager un post",
description = "Incrémente le compteur de partages d'un post")
description = "Incrémente le compteur de partages d'un post. Requiert une authentification JWT.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Post partagé")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "404", description = "Post non trouvé")
public Response sharePost(
@PathParam("id") UUID postId,
@QueryParam("userId") UUID userId) {
LOG.info("[LOG] Partage du post ID : " + postId + " par utilisateur : " + userId);
if (userId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"message\": \"userId est requis.\"}")
.build();
}
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID postId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Partage du post ID : " + postId + " par utilisateur : " + authenticatedUserId);
try {
SocialPost post = socialPostService.sharePost(postId, userId);
SocialPost post = socialPostService.sharePost(postId, authenticatedUserId);
SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
@@ -377,21 +501,26 @@ public class SocialPostResource {
}
/**
* Récupère tous les posts d'un utilisateur.
* Récupère tous les posts d'un utilisateur avec pagination.
*
* @param userId L'ID de l'utilisateur
* @return Liste des posts de l'utilisateur
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des posts de l'utilisateur
*/
@GET
@Path("/user/{userId}")
@Operation(
summary = "Récupérer les posts d'un utilisateur",
description = "Retourne tous les posts créés par un utilisateur spécifique")
public Response getPostsByUserId(@PathParam("userId") UUID userId) {
LOG.info("[LOG] Récupération des posts pour l'utilisateur ID : " + userId);
description = "Retourne une liste paginée des posts créés par un utilisateur spécifique")
public Response getPostsByUserId(
@PathParam("userId") UUID userId,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération des posts pour l'utilisateur ID : " + userId + " (page: " + page + ", size: " + size + ")");
try {
List<SocialPost> posts = socialPostService.getPostsByUserId(userId);
List<SocialPost> posts = socialPostService.getPostsByUserId(userId, page, size);
List<SocialPostResponseDTO> responseDTOs = posts.stream()
.map(SocialPostResponseDTO::new)
.collect(Collectors.toList());
@@ -448,5 +577,241 @@ public class SocialPostResource {
.build();
}
}
// =====================================================================
// ENDPOINTS POUR LES COMMENTAIRES
// =====================================================================
/**
* Récupère tous les commentaires d'un post avec pagination.
*
* @param postId L'ID du post
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des commentaires
*/
@GET
@Path("/{id}/comments")
@Operation(
summary = "Récupérer les commentaires d'un post",
description = "Retourne une liste paginée des commentaires d'un post, triés par date de création")
@APIResponse(responseCode = "200", description = "Liste des commentaires")
@APIResponse(responseCode = "404", description = "Post non trouvé")
public Response getComments(
@PathParam("id") UUID postId,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) {
LOG.info("[LOG] Récupération des commentaires pour le post ID : " + postId);
try {
List<PostComment> comments = socialPostService.getCommentsByPostId(postId, page, size);
List<PostCommentResponseDTO> responseDTOs = comments.stream()
.map(PostCommentResponseDTO::new)
.collect(Collectors.toList());
return Response.ok(responseDTOs).build();
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] Post non trouvé : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Post non trouvé.\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des commentaires : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la récupération des commentaires.\"}")
.build();
}
}
/**
* Crée un nouveau commentaire sur un post.
*
* @param postId L'ID du post
* @param requestDTO Le DTO contenant le contenu et l'ID de l'utilisateur
* @return Le commentaire créé
*/
@POST
@Path("/{id}/comments")
@Transactional
@Operation(
summary = "Créer un commentaire sur un post",
description = "Crée un nouveau commentaire sur le post spécifié")
@APIResponse(responseCode = "201", description = "Commentaire créé avec succès")
@APIResponse(responseCode = "400", description = "Données invalides")
@APIResponse(responseCode = "404", description = "Post ou utilisateur non trouvé")
public Response createComment(
@PathParam("id") UUID postId,
@Valid PostCommentCreateRequestDTO requestDTO) {
LOG.info("[LOG] Création d'un commentaire sur le post ID : " + postId + " par l'utilisateur : " + requestDTO.getUserId());
try {
PostComment comment = socialPostService.createComment(
postId,
requestDTO.getUserId(),
requestDTO.getContent()
);
PostCommentResponseDTO responseDTO = new PostCommentResponseDTO(comment);
return Response.status(Response.Status.CREATED).entity(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
} catch (UserNotFoundException e) {
LOG.warn("[WARN] Utilisateur non trouvé : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Utilisateur non trouvé.\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la création du commentaire : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la création du commentaire.\"}")
.build();
}
}
/**
* Récupère un commentaire par son ID.
*
* @param postId L'ID du post (pour cohérence de l'URL)
* @param commentId L'ID du commentaire
* @return Le commentaire
*/
@GET
@Path("/{postId}/comments/{commentId}")
@Operation(
summary = "Récupérer un commentaire par ID",
description = "Retourne les détails d'un commentaire spécifique")
@APIResponse(responseCode = "200", description = "Commentaire trouvé")
@APIResponse(responseCode = "404", description = "Commentaire non trouvé")
public Response getCommentById(
@PathParam("postId") UUID postId,
@PathParam("commentId") UUID commentId) {
LOG.info("[LOG] Récupération du commentaire ID : " + commentId);
try {
PostComment comment = socialPostService.getCommentById(commentId);
PostCommentResponseDTO responseDTO = new PostCommentResponseDTO(comment);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] Commentaire non trouvé : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Commentaire non trouvé.\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération du commentaire : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la récupération du commentaire.\"}")
.build();
}
}
/**
* Met à jour un commentaire.
* Requiert une authentification JWT.
* Seul l'auteur du commentaire peut le modifier.
*
* @param requestContext Le contexte de la requête (injecté)
* @param postId L'ID du post
* @param commentId L'ID du commentaire
* @param requestBody Map contenant "content"
* @return Le commentaire mis à jour
*/
@PUT
@Path("/{postId}/comments/{commentId}")
@Transactional
@RequiresAuth
@Operation(
summary = "Mettre à jour un commentaire",
description = "Met à jour le contenu d'un commentaire. Seul l'auteur peut modifier. Requiert une authentification JWT.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Commentaire mis à jour")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à modifier ce commentaire")
@APIResponse(responseCode = "404", description = "Commentaire non trouvé")
public Response updateComment(
@Context ContainerRequestContext requestContext,
@PathParam("postId") UUID postId,
@PathParam("commentId") UUID commentId,
Map<String, String> requestBody) {
LOG.info("[LOG] Mise à jour du commentaire ID : " + commentId);
String content = requestBody != null ? requestBody.get("content") : null;
// Utiliser l'utilisateur authentifié
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
try {
PostComment comment = socialPostService.updateComment(commentId, authenticatedUserId, content);
PostCommentResponseDTO responseDTO = new PostCommentResponseDTO(comment);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
} catch (SecurityException e) {
LOG.warn("[WARN] " + e.getMessage());
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la mise à jour du commentaire : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la mise à jour du commentaire.\"}")
.build();
}
}
/**
* Supprime un commentaire.
* Requiert une authentification JWT.
* L'auteur du commentaire ou l'auteur du post peuvent supprimer.
*
* @param requestContext Le contexte de la requête (injecté)
* @param postId L'ID du post
* @param commentId L'ID du commentaire
* @return Confirmation de suppression
*/
@DELETE
@Path("/{postId}/comments/{commentId}")
@Transactional
@RequiresAuth
@Operation(
summary = "Supprimer un commentaire",
description = "Supprime un commentaire. L'auteur du commentaire ou l'auteur du post peuvent supprimer. Requiert une authentification JWT.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "204", description = "Commentaire supprimé")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à supprimer ce commentaire")
@APIResponse(responseCode = "404", description = "Commentaire non trouvé")
public Response deleteComment(
@Context ContainerRequestContext requestContext,
@PathParam("postId") UUID postId,
@PathParam("commentId") UUID commentId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Suppression du commentaire ID : " + commentId + " par l'utilisateur : " + authenticatedUserId);
try {
boolean deleted = socialPostService.deleteComment(commentId, authenticatedUserId);
if (deleted) {
return Response.noContent().build();
} else {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Commentaire non trouvé.\"}")
.build();
}
} catch (SecurityException e) {
LOG.warn("[WARN] " + e.getMessage());
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la suppression du commentaire : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"message\": \"Erreur lors de la suppression du commentaire.\"}")
.build();
}
}
}

View File

@@ -1,5 +1,7 @@
package com.lions.dev.resource;
import com.lions.dev.core.security.JwtAuthFilter;
import com.lions.dev.core.security.RequiresAuth;
import com.lions.dev.dto.request.story.StoryCreateRequestDTO;
import com.lions.dev.dto.response.story.StoryResponseDTO;
import com.lions.dev.entity.story.Story;
@@ -8,12 +10,16 @@ import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
@@ -36,6 +42,16 @@ public class StoryResource {
private static final Logger LOG = Logger.getLogger(StoryResource.class);
/**
* Extrait l'ID de l'utilisateur authentifié du contexte de la requête.
*
* @param requestContext Le contexte de la requête
* @return L'ID de l'utilisateur authentifié
*/
private UUID getAuthenticatedUserId(ContainerRequestContext requestContext) {
return (UUID) requestContext.getProperty(JwtAuthFilter.AUTHENTICATED_USER_ID);
}
/**
* Récupère toutes les stories actives (non expirées).
*
@@ -137,18 +153,37 @@ public class StoryResource {
/**
* Crée une nouvelle story.
* Requiert une authentification JWT.
* L'utilisateur authentifié doit correspondre au creatorId.
*
* @param requestContext Le contexte de la requête (injecté)
* @param requestDTO Le DTO contenant les informations de la story à créer
* @return La story créée
*/
@POST
@Transactional
@RequiresAuth
@Operation(
summary = "Créer une nouvelle story",
description = "Crée une nouvelle story et retourne ses détails")
public Response createStory(@Valid StoryCreateRequestDTO requestDTO) {
description = "Crée une nouvelle story et retourne ses détails. Requiert une authentification JWT.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "201", description = "Story créée avec succès")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "L'utilisateur authentifié ne correspond pas au creatorId")
public Response createStory(
@Context ContainerRequestContext requestContext,
@Valid StoryCreateRequestDTO requestDTO) {
LOG.info("[LOG] Création d'une nouvelle story par l'utilisateur ID : " + requestDTO.getCreatorId());
// Vérifier que l'utilisateur authentifié correspond au creatorId
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
if (!authenticatedUserId.equals(requestDTO.getCreatorId())) {
LOG.warn("[WARN] Utilisateur " + authenticatedUserId + " tente de créer une story pour " + requestDTO.getCreatorId());
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Vous ne pouvez créer une story que pour votre propre compte.\"}")
.build();
}
try {
Story story = storyService.createStory(
requestDTO.getCreatorId(),
@@ -169,29 +204,33 @@ public class StoryResource {
/**
* Marque une story comme vue par un utilisateur.
* Requiert une authentification JWT.
* L'utilisateur authentifié est automatiquement utilisé comme viewer.
*
* @param requestContext Le contexte de la requête (injecté)
* @param storyId L'ID de la story
* @param viewerId L'ID de l'utilisateur qui voit la story
* @return La story mise à jour
*/
@POST
@Path("/{id}/view")
@Transactional
@RequiresAuth
@Operation(
summary = "Marquer une story comme vue",
description = "Marque une story comme vue par un utilisateur et incrémente le compteur de vues")
public Response markStoryAsViewed(@PathParam("id") UUID storyId, @QueryParam("userId") UUID viewerId) {
LOG.info("[LOG] Marquage de la story ID : " + storyId + " comme vue par l'utilisateur ID : " + viewerId);
if (viewerId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"message\": \"L'ID de l'utilisateur est obligatoire.\"}")
.build();
}
description = "Marque une story comme vue par l'utilisateur authentifié et incrémente le compteur de vues.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Story marquée comme vue")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "404", description = "Story non trouvée")
public Response markStoryAsViewed(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID storyId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Marquage de la story ID : " + storyId + " comme vue par l'utilisateur ID : " + authenticatedUserId);
try {
Story story = storyService.markStoryAsViewed(storyId, viewerId);
StoryResponseDTO responseDTO = new StoryResponseDTO(story, viewerId);
Story story = storyService.markStoryAsViewed(storyId, authenticatedUserId);
StoryResponseDTO responseDTO = new StoryResponseDTO(story, authenticatedUserId);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] Story non trouvée : " + e.getMessage());
@@ -208,20 +247,43 @@ public class StoryResource {
/**
* Supprime une story.
* Requiert une authentification JWT.
* Seul le créateur de la story peut la supprimer.
*
* @param requestContext Le contexte de la requête (injecté)
* @param storyId L'ID de la story
* @return Confirmation de suppression
*/
@DELETE
@Path("/{id}")
@Transactional
@RequiresAuth
@Operation(
summary = "Supprimer une story",
description = "Supprime définitivement une story")
public Response deleteStory(@PathParam("id") UUID storyId) {
LOG.info("[LOG] Suppression de la story ID : " + storyId);
description = "Supprime définitivement une story. Seul le créateur peut supprimer.")
@SecurityRequirement(name = "bearerAuth")
@APIResponse(responseCode = "200", description = "Story supprimée")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à supprimer cette story")
@APIResponse(responseCode = "404", description = "Story non trouvée")
public Response deleteStory(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID storyId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
LOG.info("[LOG] Suppression de la story ID : " + storyId + " par l'utilisateur : " + authenticatedUserId);
try {
// Récupérer la story pour vérifier le propriétaire
Story existingStory = storyService.getStoryById(storyId);
// Vérifier que l'utilisateur authentifié est le créateur de la story
if (existingStory.getUser() == null || !authenticatedUserId.equals(existingStory.getUser().getId())) {
LOG.warn("[WARN] Utilisateur " + authenticatedUserId + " tente de supprimer la story " + storyId + " d'un autre utilisateur");
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Vous ne pouvez supprimer que vos propres stories.\"}")
.build();
}
boolean deleted = storyService.deleteStory(storyId);
if (deleted) {
return Response.ok("{\"message\": \"Story supprimée avec succès.\"}").build();
@@ -230,6 +292,11 @@ public class StoryResource {
.entity("{\"message\": \"Story non trouvée.\"}")
.build();
}
} catch (IllegalArgumentException e) {
LOG.warn("[WARN] Story non trouvée : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Story non trouvée.\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la suppression de la story : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)

View File

@@ -3,6 +3,7 @@ package com.lions.dev.resource;
import com.lions.dev.dto.PasswordResetRequest;
import com.lions.dev.dto.request.users.AssignRoleRequestDTO;
import com.lions.dev.dto.request.users.SetUserActiveRequestDTO;
import com.lions.dev.dto.request.users.UpdateProfileImageRequestDTO;
import com.lions.dev.dto.request.users.UserAuthenticateRequestDTO;
import com.lions.dev.dto.request.users.UserCreateRequestDTO;
import com.lions.dev.dto.response.users.UserAuthenticateResponseDTO;
@@ -10,6 +11,7 @@ import com.lions.dev.dto.response.users.UserCreateResponseDTO;
import com.lions.dev.dto.response.users.UserDeleteResponseDto;
import com.lions.dev.entity.users.Users;
import com.lions.dev.exception.UserNotFoundException;
import com.lions.dev.service.JwtService;
import com.lions.dev.service.PasswordResetService;
import com.lions.dev.service.UsersService;
import jakarta.inject.Inject;
@@ -20,7 +22,6 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.File;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -31,6 +32,7 @@ import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
/**
* Ressource REST pour la gestion des utilisateurs dans le système AfterWork.
* Cette classe expose des endpoints pour créer, authentifier, récupérer et supprimer des utilisateurs.
@@ -45,6 +47,9 @@ public class UsersResource {
@Inject
UsersService userService;
@Inject
JwtService jwtService;
@Inject
PasswordResetService passwordResetService;
@@ -102,6 +107,7 @@ public class UsersResource {
user.getEmail(),
user.getRole()
);
responseDTO.setToken(jwtService.generateToken(user));
responseDTO.logResponseDetails();
return Response.ok(responseDTO).build();
}
@@ -222,35 +228,43 @@ public class UsersResource {
LOG.info("Réinitialisation du mot de passe pour l'utilisateur avec l'ID : " + id);
userService.resetPassword(id, nouveauMotDePasse);
return Response.ok("{\"message\": \"Mot de passe réinitialisé avec succès.\"}").build();
return Response.ok(Map.of("message", "Mot de passe réinitialisé avec succès.")).build();
}
/**
* Endpoint pour mettre à jour l'image de profil de l'utilisateur.
* Accepte un JSON avec {@code profileImageUrl} (URL retournée par l'upload de médias).
* Réponse toujours en JSON (utilisateur mis à jour ou message d'erreur).
*
* @param id L'identifiant de l'utilisateur.
* @param imageFilePath Le chemin vers l'image de profil.
* @return Un message indiquant si la mise à jour a réussi.
* @param request Corps JSON : { "profileImageUrl": "https://..." }
* @return Réponse JSON : utilisateur mis à jour (200) ou message d'erreur (4xx/5xx).
*/
@PUT
@Path("/{id}/profile-image")
@Operation(summary = "Mettre à jour l'image de profil d'un utilisateur", description = "Met à jour l'image de profil d'un utilisateur.")
public String updateUserProfileImage(@PathParam("id") UUID id, String imageFilePath) {
@Operation(summary = "Mettre à jour l'image de profil d'un utilisateur", description = "Met à jour l'URL de l'image de profil (après upload). Corps JSON : profileImageUrl.")
public Response updateUserProfileImage(@PathParam("id") UUID id, @Valid @NotNull UpdateProfileImageRequestDTO request) {
try {
File file = new File(imageFilePath);
if (!file.exists()) {
LOG.error("[ERROR] Le fichier spécifié n'existe pas : " + imageFilePath);
return "Le fichier spécifié n'existe pas.";
String profileImageUrl = request.getProfileImageUrl();
if (profileImageUrl == null || profileImageUrl.isBlank()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("message", "L'URL de l'image de profil est obligatoire."))
.build();
}
String profileImageUrl = file.getAbsolutePath();
userService.updateUserProfileImage(id, profileImageUrl); // Appel à la méthode correcte
Users updatedUser = userService.updateUserProfileImage(id, profileImageUrl.trim());
LOG.info("[LOG] Image de profil mise à jour pour l'utilisateur avec l'ID : " + id);
return "Image de profil mise à jour avec succès.";
UserCreateResponseDTO responseDTO = new UserCreateResponseDTO(updatedUser);
return Response.ok(responseDTO).build();
} catch (UserNotFoundException e) {
LOG.warn("Utilisateur non trouvé pour mise à jour image de profil : " + id);
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("message", e.getMessage()))
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la mise à jour de l'image de profil : " + e.getMessage());
return "Erreur lors de la mise à jour de l'image de profil.";
LOG.error("[ERROR] Erreur lors de la mise à jour de l'image de profil : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("message", "Erreur lors de la mise à jour de l'image de profil."))
.build();
}
}

View File

@@ -7,7 +7,7 @@ import com.lions.dev.dto.request.events.EventCreateRequestDTO;
import com.lions.dev.entity.events.Events;
import com.lions.dev.entity.friends.Friendship;
import com.lions.dev.entity.users.Users;
import com.lions.dev.exception.EventNotFoundException;
import com.lions.dev.core.errors.exceptions.EventNotFoundException;
import com.lions.dev.exception.UserNotFoundException;
import com.lions.dev.repository.EventsRepository;
import com.lions.dev.repository.FriendshipRepository;
@@ -322,6 +322,23 @@ public class EventService {
return events;
}
/**
* Récupère les événements par catégorie avec pagination.
*
* @param category La catégorie des événements.
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return La liste paginée des événements dans cette catégorie.
*/
public List<Events> findEventsByCategory(String category, int page, int size) {
logger.info("[logger] Récupération des événements dans la catégorie : {} (page: {}, size: {})", category, page, size);
List<Events> events = eventsRepository.find("category", category)
.page(page, size)
.list();
logger.info("[logger] Nombre d'événements trouvés dans la catégorie '{}' : {}", category, events.size());
return events;
}
/**
* Recherche des événements par mot-clé dans le titre ou la description.
*
@@ -335,6 +352,23 @@ public class EventService {
return events;
}
/**
* Recherche des événements par mot-clé avec pagination.
*
* @param keyword Le mot-clé à rechercher.
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return La liste paginée des événements correspondant au mot-clé.
*/
public List<Events> searchEvents(String keyword, int page, int size) {
logger.info("[logger] Recherche d'événements avec le mot-clé : {} (page: {}, size: {})", keyword, page, size);
List<Events> events = eventsRepository.find("title like ?1 or description like ?1", "%" + keyword + "%")
.page(page, size)
.list();
logger.info("[logger] Nombre d'événements trouvés pour le mot-clé '{}' : {}", keyword, events.size());
return events;
}
/**
* Récupère les événements auxquels un utilisateur participe.
*
@@ -410,6 +444,23 @@ public class EventService {
return events;
}
/**
* Récupère les événements futurs avec pagination.
*
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Une liste paginée d'événements à venir.
*/
public List<Events> findUpcomingEvents(int page, int size) {
logger.info("[logger] Récupération des événements futurs (page: {}, size: {})", page, size);
LocalDateTime now = LocalDateTime.now();
List<Events> events = eventsRepository.find("startDate > ?1 ORDER BY startDate ASC", now)
.page(page, size)
.list();
logger.info("[logger] Nombre d'événements futurs trouvés : " + events.size());
return events;
}
/**
* Récupère les événements passés.
*
@@ -423,6 +474,23 @@ public class EventService {
return events;
}
/**
* Récupère les événements passés avec pagination.
*
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Une liste paginée d'événements passés.
*/
public List<Events> findPastEvents(int page, int size) {
logger.info("[logger] Récupération des événements passés (page: {}, size: {})", page, size);
LocalDateTime now = LocalDateTime.now();
List<Events> events = eventsRepository.find("endDate < ?1 ORDER BY endDate DESC", now)
.page(page, size)
.list();
logger.info("[logger] Nombre d'événements passés trouvés : " + events.size());
return events;
}
/**
* Récupère les événements par localisation (ville ou adresse de l'établissement).
* v2.0 : plus de colonne location ; recherche sur establishment.address et establishment.city.
@@ -483,8 +551,8 @@ public class EventService {
Users user = usersRepository.findById(userId);
if (user == null) {
logger.error("[ERROR] Utilisateur non trouvé avec l'ID : " + userId);
throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + userId);
logger.warn("[WARN] Utilisateur non trouvé avec l'ID : " + userId + " — retour liste vide");
return List.of();
}
// Récupérer toutes les relations d'amitié acceptées

View File

@@ -420,8 +420,8 @@ public class FriendshipService {
public List<FriendshipReadStatusResponseDTO> listSentFriendRequests(UUID userId, int page, int size) {
Users user = usersRepository.findById(userId);
if (user == null) {
logger.error("[ERROR] Utilisateur non trouvé.");
throw new UserNotFoundException("Utilisateur introuvable.");
logger.warn("[WARN] Utilisateur non trouvé pour demandes envoyées — retour liste vide.");
return List.of();
}
List<Friendship> friendships = friendshipRepository.findSentRequestsByUser(user, FriendshipStatus.PENDING, page - 1, size);
@@ -441,8 +441,8 @@ public class FriendshipService {
public List<FriendshipReadStatusResponseDTO> listReceivedFriendRequests(UUID userId, int page, int size) {
Users user = usersRepository.findById(userId);
if (user == null) {
logger.error("[ERROR] Utilisateur non trouvé.");
throw new UserNotFoundException("Utilisateur introuvable.");
logger.warn("[WARN] Utilisateur non trouvé pour demandes reçues — retour liste vide.");
return List.of();
}
List<Friendship> friendships = friendshipRepository.findReceivedRequestsByUser(user, FriendshipStatus.PENDING, page - 1, size);

View File

@@ -0,0 +1,60 @@
package com.lions.dev.service;
import com.lions.dev.entity.users.Users;
import io.smallrye.jwt.build.Jwt;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Set;
/**
* Service d'émission de JWT au login.
* Le token contient sub (userId), groups (rôle) et est signé avec la clé secrète configurée.
* La validation des tokens sur les requêtes est assurée par quarkus-smallrye-jwt
* lorsque les endpoints sont protégés avec @RolesAllowed.
*/
@ApplicationScoped
public class JwtService {
private static final Logger LOG = Logger.getLogger(JwtService.class);
private static final String ISSUER = "afterwork";
@ConfigProperty(name = "afterwork.jwt.secret", defaultValue = "afterwork-jwt-secret-min-32-bytes-for-hs256!")
String secret;
/**
* Génère un JWT pour l'utilisateur authentifié.
*
* @param user L'utilisateur après login réussi
* @return Le token JWT signé (à envoyer au client dans la réponse d'authentification)
*/
public String generateToken(Users user) {
if (user == null || user.getId() == null) {
throw new IllegalArgumentException("User et id obligatoires pour générer le JWT");
}
Set<String> groups = Set.of("user", user.getRole() != null ? user.getRole() : "USER");
SecretKey key = secretKeyFromConfig();
String token = Jwt.claims()
.issuer(ISSUER)
.subject(user.getId().toString())
.groups(groups)
.jws()
.sign(key);
LOG.debug("JWT généré pour l'utilisateur " + user.getId());
return token;
}
private SecretKey secretKeyFromConfig() {
byte[] decoded = secret.getBytes(StandardCharsets.UTF_8);
if (decoded.length < 32) {
byte[] padded = new byte[32];
System.arraycopy(decoded, 0, padded, 0, decoded.length);
decoded = padded;
}
return new SecretKeySpec(decoded, "HmacSHA256");
}
}

View File

@@ -1,5 +1,6 @@
package com.lions.dev.service;
import com.lions.dev.core.errors.exceptions.NotFoundException;
import com.lions.dev.entity.chat.Conversation;
import com.lions.dev.entity.chat.Message;
import com.lions.dev.entity.users.Users;
@@ -242,8 +243,9 @@ public class MessageService {
*
* @param user1Id L'ID du premier utilisateur
* @param user2Id L'ID du deuxième utilisateur
* @return La conversation ou null si elle n'existe pas
* @return La conversation
* @throws UserNotFoundException Si l'un des utilisateurs n'existe pas
* @throws NotFoundException Si la conversation n'existe pas
*/
public Conversation getConversationBetweenUsers(UUID user1Id, UUID user2Id) {
logger.info("[MessageService] Recherche de conversation entre " + user1Id + " et " + user2Id);
@@ -255,7 +257,11 @@ public class MessageService {
throw new UserNotFoundException("Un ou plusieurs utilisateurs non trouvés");
}
return conversationRepository.findBetweenUsers(user1, user2);
Conversation conversation = conversationRepository.findBetweenUsers(user1, user2);
if (conversation == null) {
throw new NotFoundException("Conversation non trouvée");
}
return conversation;
}
/**
@@ -270,7 +276,7 @@ public class MessageService {
Message message = messageRepository.findById(messageId);
if (message == null) {
throw new IllegalArgumentException("Message non trouvé avec l'ID : " + messageId);
throw new NotFoundException("Message non trouvé avec l'ID : " + messageId);
}
message.markAsRead();

View File

@@ -88,8 +88,8 @@ public class NotificationService {
Users user = usersRepository.findById(userId);
if (user == null) {
logger.error("[NotificationService] Utilisateur non trouvé avec l'ID : " + userId);
throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + userId);
logger.warn("[NotificationService] Utilisateur non trouvé avec l'ID : " + userId + " — retour liste vide.");
return List.of();
}
List<Notification> notifications = notificationRepository.findByUserId(userId);
@@ -111,8 +111,8 @@ public class NotificationService {
Users user = usersRepository.findById(userId);
if (user == null) {
logger.error("[NotificationService] Utilisateur non trouvé avec l'ID : " + userId);
throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + userId);
logger.warn("[NotificationService] Utilisateur non trouvé avec l'ID : " + userId + " — retour liste vide.");
return List.of();
}
return notificationRepository.findByUserIdWithPagination(userId, page, size);

View File

@@ -0,0 +1,268 @@
package com.lions.dev.service;
import com.lions.dev.dto.request.promotion.PromotionCreateRequestDTO;
import com.lions.dev.dto.request.promotion.PromotionUpdateRequestDTO;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.promotion.Promotion;
import com.lions.dev.repository.EstablishmentRepository;
import com.lions.dev.repository.PromotionRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Service de gestion des promotions.
*
* Ce service contient la logique métier pour la création, récupération,
* mise à jour et suppression des promotions.
*/
@ApplicationScoped
public class PromotionService {
private static final Logger LOG = Logger.getLogger(PromotionService.class);
@Inject
PromotionRepository promotionRepository;
@Inject
EstablishmentRepository establishmentRepository;
/**
* Crée une nouvelle promotion.
*
* @param dto Le DTO de création
* @return La promotion créée
*/
@Transactional
public Promotion createPromotion(PromotionCreateRequestDTO dto) {
LOG.info("[PromotionService] Création d'une promotion pour l'établissement: " + dto.getEstablishmentId());
// Vérifier que l'établissement existe
Establishment establishment = establishmentRepository.findById(dto.getEstablishmentId());
if (establishment == null) {
throw new IllegalArgumentException("Établissement non trouvé avec l'ID: " + dto.getEstablishmentId());
}
// Vérifier l'unicité du code promo s'il est fourni
if (dto.getPromoCode() != null && !dto.getPromoCode().isBlank()) {
if (promotionRepository.promoCodeExists(dto.getPromoCode())) {
throw new IllegalArgumentException("Le code promo '" + dto.getPromoCode() + "' est déjà utilisé");
}
}
// Valider les dates
if (dto.getValidUntil().isBefore(dto.getValidFrom())) {
throw new IllegalArgumentException("La date de fin doit être après la date de début");
}
// Valider le type de réduction
if (!isValidDiscountType(dto.getDiscountType())) {
throw new IllegalArgumentException("Type de réduction invalide. Valeurs acceptées: PERCENTAGE, FIXED_AMOUNT, FREE_ITEM");
}
// Créer la promotion
Promotion promotion = new Promotion(
establishment,
dto.getTitle(),
dto.getDescription(),
dto.getDiscountType().toUpperCase(),
dto.getDiscountValue(),
dto.getValidFrom(),
dto.getValidUntil()
);
promotion.setPromoCode(dto.getPromoCode());
promotionRepository.persist(promotion);
LOG.info("[PromotionService] Promotion créée avec succès: " + promotion.getId());
return promotion;
}
/**
* Récupère une promotion par son ID.
*
* @param promotionId L'ID de la promotion
* @return La promotion
*/
public Promotion getPromotionById(UUID promotionId) {
LOG.debug("[PromotionService] Récupération de la promotion: " + promotionId);
Promotion promotion = promotionRepository.findById(promotionId);
if (promotion == null) {
throw new IllegalArgumentException("Promotion non trouvée avec l'ID: " + promotionId);
}
return promotion;
}
/**
* Récupère toutes les promotions d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @param page Le numéro de la page
* @param size La taille de la page
* @return Liste des promotions
*/
public List<Promotion> getPromotionsByEstablishment(UUID establishmentId, int page, int size) {
LOG.debug("[PromotionService] Récupération des promotions pour l'établissement: " + establishmentId);
return promotionRepository.findByEstablishmentId(establishmentId, page, size);
}
/**
* Récupère les promotions actives et valides d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @return Liste des promotions actives
*/
public List<Promotion> getActivePromotionsByEstablishment(UUID establishmentId) {
LOG.debug("[PromotionService] Récupération des promotions actives pour l'établissement: " + establishmentId);
return promotionRepository.findActiveByEstablishmentId(establishmentId);
}
/**
* Récupère toutes les promotions actives et valides.
*
* @param page Le numéro de la page
* @param size La taille de la page
* @return Liste des promotions actives
*/
public List<Promotion> getAllActivePromotions(int page, int size) {
LOG.debug("[PromotionService] Récupération de toutes les promotions actives");
return promotionRepository.findAllActive(page, size);
}
/**
* Recherche une promotion par son code promo.
*
* @param promoCode Le code promo
* @return La promotion si trouvée
*/
public Optional<Promotion> getPromotionByCode(String promoCode) {
LOG.debug("[PromotionService] Recherche de la promotion avec le code: " + promoCode);
return promotionRepository.findByPromoCode(promoCode);
}
/**
* Met à jour une promotion.
*
* @param promotionId L'ID de la promotion
* @param dto Le DTO de mise à jour
* @return La promotion mise à jour
*/
@Transactional
public Promotion updatePromotion(UUID promotionId, PromotionUpdateRequestDTO dto) {
LOG.info("[PromotionService] Mise à jour de la promotion: " + promotionId);
Promotion promotion = getPromotionById(promotionId);
// Mettre à jour les champs non-nuls
if (dto.getTitle() != null && !dto.getTitle().isBlank()) {
promotion.setTitle(dto.getTitle());
}
if (dto.getDescription() != null) {
promotion.setDescription(dto.getDescription());
}
if (dto.getPromoCode() != null) {
// Vérifier l'unicité du nouveau code promo s'il change
if (!dto.getPromoCode().equals(promotion.getPromoCode())) {
if (!dto.getPromoCode().isBlank() && promotionRepository.promoCodeExists(dto.getPromoCode())) {
throw new IllegalArgumentException("Le code promo '" + dto.getPromoCode() + "' est déjà utilisé");
}
}
promotion.setPromoCode(dto.getPromoCode().isBlank() ? null : dto.getPromoCode());
}
if (dto.getDiscountType() != null && !dto.getDiscountType().isBlank()) {
if (!isValidDiscountType(dto.getDiscountType())) {
throw new IllegalArgumentException("Type de réduction invalide");
}
promotion.setDiscountType(dto.getDiscountType().toUpperCase());
}
if (dto.getDiscountValue() != null) {
promotion.setDiscountValue(dto.getDiscountValue());
}
if (dto.getValidFrom() != null) {
promotion.setValidFrom(dto.getValidFrom());
}
if (dto.getValidUntil() != null) {
promotion.setValidUntil(dto.getValidUntil());
}
if (dto.getIsActive() != null) {
promotion.setIsActive(dto.getIsActive());
}
// Valider les dates après mise à jour
if (promotion.getValidUntil().isBefore(promotion.getValidFrom())) {
throw new IllegalArgumentException("La date de fin doit être après la date de début");
}
promotionRepository.persist(promotion);
LOG.info("[PromotionService] Promotion mise à jour avec succès: " + promotionId);
return promotion;
}
/**
* Supprime une promotion.
*
* @param promotionId L'ID de la promotion
* @return true si supprimée, false sinon
*/
@Transactional
public boolean deletePromotion(UUID promotionId) {
LOG.info("[PromotionService] Suppression de la promotion: " + promotionId);
Promotion promotion = promotionRepository.findById(promotionId);
if (promotion == null) {
return false;
}
promotionRepository.delete(promotion);
LOG.info("[PromotionService] Promotion supprimée avec succès: " + promotionId);
return true;
}
/**
* Active ou désactive une promotion.
*
* @param promotionId L'ID de la promotion
* @param isActive L'état à appliquer
* @return La promotion mise à jour
*/
@Transactional
public Promotion setPromotionActive(UUID promotionId, boolean isActive) {
LOG.info("[PromotionService] Changement d'état de la promotion " + promotionId + " à " + isActive);
Promotion promotion = getPromotionById(promotionId);
promotion.setIsActive(isActive);
promotionRepository.persist(promotion);
return promotion;
}
/**
* Vérifie si le responsable de l'établissement peut modifier cette promotion.
*
* @param promotion La promotion
* @param userId L'ID de l'utilisateur
* @return true si l'utilisateur peut modifier, false sinon
*/
public boolean canModifyPromotion(Promotion promotion, UUID userId) {
if (promotion == null || userId == null) {
return false;
}
Establishment establishment = promotion.getEstablishment();
if (establishment == null || establishment.getManager() == null) {
return false;
}
return userId.equals(establishment.getManager().getId());
}
/**
* Valide le type de réduction.
*/
private boolean isValidDiscountType(String discountType) {
if (discountType == null) return false;
return discountType.equalsIgnoreCase("PERCENTAGE") ||
discountType.equalsIgnoreCase("FIXED_AMOUNT") ||
discountType.equalsIgnoreCase("FREE_ITEM");
}
}

View File

@@ -0,0 +1,276 @@
package com.lions.dev.service;
import com.lions.dev.dto.request.review.ReviewCreateRequestDTO;
import com.lions.dev.dto.request.review.ReviewUpdateRequestDTO;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.establishment.Review;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.EstablishmentRepository;
import com.lions.dev.repository.ReviewRepository;
import com.lions.dev.repository.UsersRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.jboss.logging.Logger;
import java.util.*;
/**
* Service de gestion des avis d'établissements.
*
* Ce service contient la logique métier pour la création, récupération,
* mise à jour et suppression des avis.
*/
@ApplicationScoped
public class ReviewService {
private static final Logger LOG = Logger.getLogger(ReviewService.class);
@Inject
ReviewRepository reviewRepository;
@Inject
EstablishmentRepository establishmentRepository;
@Inject
UsersRepository usersRepository;
/**
* Crée un nouvel avis.
*
* @param userId L'ID de l'utilisateur qui crée l'avis
* @param dto Le DTO de création
* @return L'avis créé
*/
@Transactional
public Review createReview(UUID userId, ReviewCreateRequestDTO dto) {
LOG.info("[ReviewService] Création d'un avis pour l'établissement: " + dto.getEstablishmentId() + " par l'utilisateur: " + userId);
// Vérifier que l'utilisateur existe
Users user = usersRepository.findById(userId);
if (user == null) {
throw new IllegalArgumentException("Utilisateur non trouvé avec l'ID: " + userId);
}
// Vérifier que l'établissement existe
Establishment establishment = establishmentRepository.findById(dto.getEstablishmentId());
if (establishment == null) {
throw new IllegalArgumentException("Établissement non trouvé avec l'ID: " + dto.getEstablishmentId());
}
// Vérifier que l'utilisateur n'a pas déjà écrit un avis
if (reviewRepository.hasUserReviewed(dto.getEstablishmentId(), userId)) {
throw new IllegalArgumentException("Vous avez déjà écrit un avis pour cet établissement");
}
// Créer l'avis
Review review = new Review(user, establishment, dto.getOverallRating(), dto.getComment());
// Ajouter les notes par critères si fournies
if (dto.getCriteriaRatings() != null) {
for (Map.Entry<String, Integer> entry : dto.getCriteriaRatings().entrySet()) {
if (entry.getValue() >= 1 && entry.getValue() <= 5) {
review.addCriteriaRating(entry.getKey(), entry.getValue());
}
}
}
reviewRepository.persist(review);
LOG.info("[ReviewService] Avis créé avec succès: " + review.getId());
return review;
}
/**
* Récupère un avis par son ID.
*
* @param reviewId L'ID de l'avis
* @return L'avis
*/
public Review getReviewById(UUID reviewId) {
LOG.debug("[ReviewService] Récupération de l'avis: " + reviewId);
Review review = reviewRepository.findById(reviewId);
if (review == null) {
throw new IllegalArgumentException("Avis non trouvé avec l'ID: " + reviewId);
}
return review;
}
/**
* Récupère tous les avis d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @param page Le numéro de la page
* @param size La taille de la page
* @return Liste des avis
*/
public List<Review> getReviewsByEstablishment(UUID establishmentId, int page, int size) {
LOG.debug("[ReviewService] Récupération des avis pour l'établissement: " + establishmentId);
return reviewRepository.findByEstablishmentId(establishmentId, page, size);
}
/**
* Récupère les avis vérifiés d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @param page Le numéro de la page
* @param size La taille de la page
* @return Liste des avis vérifiés
*/
public List<Review> getVerifiedReviewsByEstablishment(UUID establishmentId, int page, int size) {
LOG.debug("[ReviewService] Récupération des avis vérifiés pour l'établissement: " + establishmentId);
return reviewRepository.findVerifiedByEstablishmentId(establishmentId, page, size);
}
/**
* Récupère tous les avis d'un utilisateur.
*
* @param userId L'ID de l'utilisateur
* @param page Le numéro de la page
* @param size La taille de la page
* @return Liste des avis
*/
public List<Review> getReviewsByUser(UUID userId, int page, int size) {
LOG.debug("[ReviewService] Récupération des avis de l'utilisateur: " + userId);
return reviewRepository.findByUserId(userId, page, size);
}
/**
* Récupère l'avis d'un utilisateur pour un établissement spécifique.
*
* @param establishmentId L'ID de l'établissement
* @param userId L'ID de l'utilisateur
* @return L'avis si trouvé
*/
public Optional<Review> getUserReviewForEstablishment(UUID establishmentId, UUID userId) {
LOG.debug("[ReviewService] Recherche de l'avis pour établissement " + establishmentId + " et utilisateur " + userId);
return reviewRepository.findByEstablishmentAndUser(establishmentId, userId);
}
/**
* Met à jour un avis.
*
* @param reviewId L'ID de l'avis
* @param userId L'ID de l'utilisateur (pour vérifier les permissions)
* @param dto Le DTO de mise à jour
* @return L'avis mis à jour
*/
@Transactional
public Review updateReview(UUID reviewId, UUID userId, ReviewUpdateRequestDTO dto) {
LOG.info("[ReviewService] Mise à jour de l'avis: " + reviewId + " par l'utilisateur: " + userId);
Review review = getReviewById(reviewId);
// Vérifier que l'utilisateur est l'auteur de l'avis
if (!review.getUser().getId().equals(userId)) {
throw new SecurityException("Vous n'êtes pas autorisé à modifier cet avis");
}
// Mettre à jour les champs non-nuls
if (dto.getOverallRating() != null) {
review.setOverallRating(dto.getOverallRating());
}
if (dto.getComment() != null) {
review.setComment(dto.getComment());
}
if (dto.getCriteriaRatings() != null) {
// Remplacer les notes par critères
review.getCriteriaRatings().clear();
for (Map.Entry<String, Integer> entry : dto.getCriteriaRatings().entrySet()) {
if (entry.getValue() >= 1 && entry.getValue() <= 5) {
review.addCriteriaRating(entry.getKey(), entry.getValue());
}
}
}
reviewRepository.persist(review);
LOG.info("[ReviewService] Avis mis à jour avec succès: " + reviewId);
return review;
}
/**
* Supprime un avis.
*
* @param reviewId L'ID de l'avis
* @param userId L'ID de l'utilisateur (pour vérifier les permissions)
* @return true si supprimé, false sinon
*/
@Transactional
public boolean deleteReview(UUID reviewId, UUID userId) {
LOG.info("[ReviewService] Suppression de l'avis: " + reviewId + " par l'utilisateur: " + userId);
Review review = reviewRepository.findById(reviewId);
if (review == null) {
return false;
}
// Vérifier que l'utilisateur est l'auteur de l'avis
if (!review.getUser().getId().equals(userId)) {
throw new SecurityException("Vous n'êtes pas autorisé à supprimer cet avis");
}
reviewRepository.delete(review);
LOG.info("[ReviewService] Avis supprimé avec succès: " + reviewId);
return true;
}
/**
* Calcule les statistiques des avis pour un établissement.
*
* @param establishmentId L'ID de l'établissement
* @return Map contenant les statistiques
*/
public Map<String, Object> getReviewStats(UUID establishmentId) {
LOG.debug("[ReviewService] Calcul des statistiques pour l'établissement: " + establishmentId);
List<Review> allReviews = reviewRepository.find("establishment.id", establishmentId).list();
Map<String, Object> stats = new HashMap<>();
if (allReviews.isEmpty()) {
stats.put("averageRating", 0.0);
stats.put("totalReviews", 0);
stats.put("verifiedReviews", 0);
stats.put("distribution", new HashMap<Integer, Integer>());
return stats;
}
// Calcul de la moyenne
double average = allReviews.stream()
.mapToInt(Review::getOverallRating)
.average()
.orElse(0.0);
// Distribution par note
Map<Integer, Integer> distribution = new HashMap<>();
for (int i = 1; i <= 5; i++) {
final int rating = i;
distribution.put(i, (int) allReviews.stream()
.filter(r -> r.getOverallRating() == rating)
.count());
}
// Nombre d'avis vérifiés
long verifiedCount = allReviews.stream()
.filter(r -> Boolean.TRUE.equals(r.getIsVerifiedVisit()))
.count();
stats.put("averageRating", Math.round(average * 10.0) / 10.0); // Arrondi à 1 décimale
stats.put("totalReviews", allReviews.size());
stats.put("verifiedReviews", (int) verifiedCount);
stats.put("distribution", distribution);
return stats;
}
/**
* Compte le nombre d'avis pour un établissement.
*
* @param establishmentId L'ID de l'établissement
* @return Le nombre d'avis
*/
public long countReviewsByEstablishment(UUID establishmentId) {
return reviewRepository.countByEstablishmentId(establishmentId);
}
}

View File

@@ -1,10 +1,12 @@
package com.lions.dev.service;
import com.lions.dev.entity.friends.Friendship;
import com.lions.dev.entity.social.PostComment;
import com.lions.dev.entity.social.SocialPost;
import com.lions.dev.entity.users.Users;
import com.lions.dev.exception.UserNotFoundException;
import com.lions.dev.repository.FriendshipRepository;
import com.lions.dev.repository.PostCommentRepository;
import com.lions.dev.repository.SocialPostRepository;
import com.lions.dev.repository.UsersRepository;
import com.lions.dev.dto.events.ReactionEvent;
@@ -34,6 +36,9 @@ public class SocialPostService {
@Inject
SocialPostRepository socialPostRepository;
@Inject
PostCommentRepository postCommentRepository;
@Inject
UsersRepository usersRepository;
@@ -78,6 +83,27 @@ public class SocialPostService {
return socialPostRepository.findByUserId(userId);
}
/**
* Récupère tous les posts d'un utilisateur avec pagination.
*
* @param userId L'ID de l'utilisateur
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des posts de l'utilisateur
* @throws UserNotFoundException Si l'utilisateur n'existe pas
*/
public List<SocialPost> getPostsByUserId(UUID userId, int page, int size) {
logger.info("[SocialPostService] Récupération des posts pour l'utilisateur ID : " + userId + " (page: " + page + ", size: " + size + ")");
Users user = usersRepository.findById(userId);
if (user == null) {
logger.error("[SocialPostService] Utilisateur non trouvé avec l'ID : " + userId);
throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + userId);
}
return socialPostRepository.findByUserIdWithPagination(userId, page, size);
}
/**
* Crée un nouveau post social.
*
@@ -217,6 +243,19 @@ public class SocialPostService {
return socialPostRepository.searchByContent(query);
}
/**
* Recherche des posts par contenu avec pagination.
*
* @param query Le terme de recherche
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des posts correspondant à la recherche
*/
public List<SocialPost> searchPosts(String query, int page, int size) {
logger.info("[SocialPostService] Recherche de posts avec la requête : " + query + " (page: " + page + ", size: " + size + ")");
return socialPostRepository.searchByContentWithPagination(query, page, size);
}
/**
* Like un post (incrémente le compteur de likes).
*
@@ -436,5 +475,245 @@ public class SocialPostService {
// Récupérer les posts de l'utilisateur et de ses amis
return socialPostRepository.findPostsByFriends(userId, friendIds, page, size);
}
// =====================================================================
// GESTION DES COMMENTAIRES
// =====================================================================
/**
* Récupère tous les commentaires d'un post avec pagination.
*
* @param postId L'ID du post
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des commentaires
*/
public List<PostComment> getCommentsByPostId(UUID postId, int page, int size) {
logger.info("[SocialPostService] Récupération des commentaires pour le post ID : " + postId);
// Vérifier que le post existe
SocialPost post = socialPostRepository.findById(postId);
if (post == null) {
logger.error("[SocialPostService] Post non trouvé avec l'ID : " + postId);
throw new IllegalArgumentException("Post non trouvé avec l'ID : " + postId);
}
return postCommentRepository.findByPostId(postId, page, size);
}
/**
* Récupère tous les commentaires d'un post sans pagination.
*
* @param postId L'ID du post
* @return Liste de tous les commentaires
*/
public List<PostComment> getAllCommentsByPostId(UUID postId) {
logger.info("[SocialPostService] Récupération de tous les commentaires pour le post ID : " + postId);
SocialPost post = socialPostRepository.findById(postId);
if (post == null) {
logger.error("[SocialPostService] Post non trouvé avec l'ID : " + postId);
throw new IllegalArgumentException("Post non trouvé avec l'ID : " + postId);
}
return postCommentRepository.findAllByPostId(postId);
}
/**
* Crée un nouveau commentaire sur un post.
*
* @param postId L'ID du post à commenter
* @param userId L'ID de l'utilisateur qui commente
* @param content Le contenu du commentaire
* @return Le commentaire créé
*/
@Transactional
public PostComment createComment(UUID postId, UUID userId, String content) {
logger.info("[SocialPostService] Création d'un commentaire sur le post ID : " + postId + " par l'utilisateur : " + userId);
// Valider le contenu
if (content == null || content.isBlank()) {
throw new IllegalArgumentException("Le contenu du commentaire ne peut pas être vide");
}
if (content.length() > 1000) {
throw new IllegalArgumentException("Le commentaire ne peut pas dépasser 1000 caractères");
}
// Vérifier que le post existe
SocialPost post = socialPostRepository.findById(postId);
if (post == null) {
logger.error("[SocialPostService] Post non trouvé avec l'ID : " + postId);
throw new IllegalArgumentException("Post non trouvé avec l'ID : " + postId);
}
// Vérifier que l'utilisateur existe
Users user = usersRepository.findById(userId);
if (user == null) {
logger.error("[SocialPostService] Utilisateur non trouvé avec l'ID : " + userId);
throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + userId);
}
// Créer et persister le commentaire
PostComment comment = new PostComment(content, post, user);
postCommentRepository.persist(comment);
// Incrémenter le compteur de commentaires du post
post.incrementComments();
socialPostRepository.persist(post);
logger.info("[SocialPostService] Commentaire créé avec succès : " + comment.getId());
// Notification pour l'auteur du post (sauf auto-commentaire)
try {
Users author = post.getUser();
if (author != null && !author.getId().equals(userId)) {
String commenterName = user.getFirstName() + " " + user.getLastName();
String preview = content.length() > 60 ? content.substring(0, 60) + "..." : content;
notificationService.createNotification(
"Nouveau commentaire",
commenterName + " a commenté votre post : " + preview,
"post",
author.getId(),
null
);
}
} catch (Exception e) {
logger.error("[SocialPostService] Erreur création notification commentaire : " + e.getMessage());
}
// Publier dans Kafka pour le temps réel
try {
Map<String, Object> reactionData = new HashMap<>();
reactionData.put("ownerId", post.getUser().getId().toString());
reactionData.put("commentsCount", post.getCommentsCount());
reactionData.put("commentContent", content.length() > 100 ? content.substring(0, 100) + "..." : content);
reactionData.put("commenterName", user.getFirstName() + " " + user.getLastName());
reactionData.put("commentId", comment.getId().toString());
ReactionEvent event = new ReactionEvent(
postId.toString(),
"post",
userId.toString(),
"comment",
reactionData
);
reactionEmitter.send(event);
logger.info("[SocialPostService] Événement commentaire publié dans Kafka pour post: " + postId);
} catch (Exception e) {
logger.error("[SocialPostService] Erreur publication Kafka: " + e.getMessage());
}
return comment;
}
/**
* Récupère un commentaire par son ID.
*
* @param commentId L'ID du commentaire
* @return Le commentaire trouvé
*/
public PostComment getCommentById(UUID commentId) {
logger.info("[SocialPostService] Récupération du commentaire ID : " + commentId);
PostComment comment = postCommentRepository.findById(commentId);
if (comment == null) {
logger.error("[SocialPostService] Commentaire non trouvé avec l'ID : " + commentId);
throw new IllegalArgumentException("Commentaire non trouvé avec l'ID : " + commentId);
}
return comment;
}
/**
* Met à jour le contenu d'un commentaire.
* Seul l'auteur du commentaire peut le modifier.
*
* @param commentId L'ID du commentaire
* @param userId L'ID de l'utilisateur qui modifie
* @param newContent Le nouveau contenu
* @return Le commentaire mis à jour
*/
@Transactional
public PostComment updateComment(UUID commentId, UUID userId, String newContent) {
logger.info("[SocialPostService] Mise à jour du commentaire ID : " + commentId + " par l'utilisateur : " + userId);
PostComment comment = postCommentRepository.findById(commentId);
if (comment == null) {
logger.error("[SocialPostService] Commentaire non trouvé avec l'ID : " + commentId);
throw new IllegalArgumentException("Commentaire non trouvé avec l'ID : " + commentId);
}
// Vérifier que l'utilisateur est l'auteur
if (!comment.isAuthor(userId)) {
logger.error("[SocialPostService] L'utilisateur " + userId + " n'est pas l'auteur du commentaire " + commentId);
throw new SecurityException("Vous n'êtes pas autorisé à modifier ce commentaire");
}
// Valider le nouveau contenu
if (newContent == null || newContent.isBlank()) {
throw new IllegalArgumentException("Le contenu du commentaire ne peut pas être vide");
}
if (newContent.length() > 1000) {
throw new IllegalArgumentException("Le commentaire ne peut pas dépasser 1000 caractères");
}
comment.updateContent(newContent);
postCommentRepository.persist(comment);
logger.info("[SocialPostService] Commentaire mis à jour avec succès");
return comment;
}
/**
* Supprime un commentaire.
* Seul l'auteur du commentaire ou l'auteur du post peut le supprimer.
*
* @param commentId L'ID du commentaire
* @param userId L'ID de l'utilisateur qui supprime
* @return true si le commentaire a été supprimé
*/
@Transactional
public boolean deleteComment(UUID commentId, UUID userId) {
logger.info("[SocialPostService] Suppression du commentaire ID : " + commentId + " par l'utilisateur : " + userId);
PostComment comment = postCommentRepository.findById(commentId);
if (comment == null) {
logger.error("[SocialPostService] Commentaire non trouvé avec l'ID : " + commentId);
return false;
}
// Vérifier que l'utilisateur est l'auteur du commentaire OU l'auteur du post
boolean isCommentAuthor = comment.isAuthor(userId);
boolean isPostAuthor = comment.getPost() != null
&& comment.getPost().getUser() != null
&& comment.getPost().getUser().getId().equals(userId);
if (!isCommentAuthor && !isPostAuthor) {
logger.error("[SocialPostService] L'utilisateur " + userId + " n'est pas autorisé à supprimer ce commentaire");
throw new SecurityException("Vous n'êtes pas autorisé à supprimer ce commentaire");
}
// Décrémenter le compteur de commentaires du post
SocialPost post = comment.getPost();
if (post != null && post.getCommentsCount() > 0) {
post.setCommentsCount(post.getCommentsCount() - 1);
socialPostRepository.persist(post);
}
postCommentRepository.delete(comment);
logger.info("[SocialPostService] Commentaire supprimé avec succès");
return true;
}
/**
* Compte le nombre de commentaires pour un post.
*
* @param postId L'ID du post
* @return Le nombre de commentaires
*/
public long countCommentsByPostId(UUID postId) {
return postCommentRepository.countByPostId(postId);
}
}

View File

@@ -1,16 +0,0 @@
package dev.lions;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/hello")
public class GreetingResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String hello() {
return "Hello RESTEasy";
}
}

View File

@@ -1,32 +0,0 @@
package dev.lions;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
/**
* Example JPA entity.
*
* To use it, get access to a JPA EntityManager via injection.
*
* {@code
* @Inject
* EntityManager em;
*
* public void doSomething() {
* MyEntity entity1 = new MyEntity();
* entity1.field = "field-1";
* em.persist(entity1);
*
* List<MyEntity> entities = em.createQuery("from MyEntity", MyEntity.class).getResultList();
* }
* }
*/
@Entity
public class MyEntity {
@Id
@GeneratedValue
public Long id;
public String field;
}

View File

@@ -3,6 +3,9 @@
# ====================================================================
# Ce fichier est automatiquement chargé avec: mvn quarkus:dev
# Les configurations ici surchargent celles de application.properties
#
# IMPORTANT: En dev, Hibernate gère le schéma (drop-and-create).
# En prod, Flyway gère les migrations (application-prod.properties).
# ====================================================================
# Super administrateur (dev)
@@ -25,31 +28,61 @@ quarkus.datasource.jdbc.driver=org.postgresql.Driver
quarkus.datasource.devservices.enabled=false
# ====================================================================
# Hibernate ORM
# Flyway - DÉSACTIVÉ en développement
# ====================================================================
# En dev, on utilise Hibernate drop-and-create pour un cycle rapide.
# En production (profil prod), Flyway est activé via application-prod.properties.
quarkus.flyway.migrate-at-start=false
quarkus.flyway.clean-at-start=false
# ====================================================================
# Hibernate ORM (développement)
# ====================================================================
# drop-and-create: recrée le schéma à chaque démarrage (pratique en dev).
# ATTENTION: Ne JAMAIS utiliser ce mode en production !
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.log.sql=true
quarkus.hibernate-orm.format_sql=true
quarkus.hibernate-orm.packages=com.lions.dev.entity
# Forcer la création du schéma au démarrage
quarkus.hibernate-orm.schema-generation.scripts.action=drop-and-create
# Script d'import exécuté après création du schéma (données de test)
quarkus.hibernate-orm.sql-load-script=import.sql
# ====================================================================
# Kafka (développement local)
# ====================================================================
# En dev, Kafka doit être joignable sur le port 9092 (conteneur Docker avec -p 9092:9092).
# Si Kafka est ailleurs, définir KAFKA_BOOTSTRAP_SERVERS (ex: host.docker.internal:9092).
# En dev, Kafka doit tourner en local sur le port 9092.
# Docker Compose recommandé: docker-compose up -d kafka
# Si Kafka n'est pas disponible, les fonctionnalités temps réel seront désactivées.
afterwork.kafka.enabled=${KAFKA_ENABLED:true}
kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
# Propager explicitement bootstrap.servers au connecteur SmallRye Kafka (évite les soucis de résolution).
# Propager explicitement bootstrap.servers au connecteur SmallRye Kafka
mp.messaging.connector.smallrye-kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
# SmallRye Reactive Messaging - Les canaux sont définis dans application.properties.
# Voir REALTIME_DEV.md pour faire fonctionner le temps réel en local.
# Tolérance aux pannes Kafka en dev (évite les erreurs si Kafka n'est pas lancé)
mp.messaging.outgoing.notifications.cloud-events=false
mp.messaging.outgoing.chat-messages.cloud-events=false
mp.messaging.outgoing.reactions.cloud-events=false
mp.messaging.outgoing.presence.cloud-events=false
# ====================================================================
# Logging
# CORS (développement - permissif)
# ====================================================================
quarkus.log.level=DEBUG
quarkus.http.cors=true
quarkus.http.cors.origins=/.*/
quarkus.http.cors.methods=GET,POST,PUT,DELETE,OPTIONS,PATCH
quarkus.http.cors.headers=accept,authorization,content-type,x-requested-with,x-super-admin-key
quarkus.http.cors.access-control-allow-credentials=true
# ====================================================================
# Email (développement - mode mock)
# ====================================================================
quarkus.mailer.mock=true
# ====================================================================
# Logging (développement - verbeux)
# ====================================================================
quarkus.log.level=INFO
quarkus.log.category."com.lions.dev".level=DEBUG
quarkus.log.category."org.hibernate.SQL".level=DEBUG
quarkus.log.category."io.smallrye.reactive.messaging".level=INFO

View File

@@ -48,13 +48,15 @@ quarkus.datasource.devservices.enabled=false
# ====================================================================
# Flyway - Migrations SQL automatiques
# ====================================================================
# Flyway exécute automatiquement les migrations au démarrage.
# baseline-on-migrate=true permet de partir d'une base existante (baseline = V1).
# out-of-order=true tolère les migrations ajoutées après coup (ex: V1_1 après V2).
quarkus.flyway.migrate-at-start=true
quarkus.flyway.locations=db/migration
quarkus.flyway.baseline-on-migrate=true
quarkus.flyway.baseline-version=1
quarkus.flyway.validate-on-migrate=true
(ex: V1_1 ajout?e apr?s que la prod ait d?j? V2..V19).
, donc sans risque sur une base d?j? migr?e.
quarkus.flyway.out-of-order=true
# ====================================================================
# Hibernate ORM - Mode validation (Flyway g?re le schema)
# ====================================================================

View File

@@ -28,6 +28,23 @@ afterwork.super-admin.last-name=${SUPER_ADMIN_LAST_NAME:Administrator}
# Clé secrète pour les opérations admin (header X-Super-Admin-Key sur PUT /users/{id}/role, etc.)
afterwork.super-admin.api-key=${SUPER_ADMIN_API_KEY:prod-super-admin-key}
# JWT : clé secrète pour signer les tokens au login (min. 32 octets pour HS256). En prod, définir via JWT_SECRET.
afterwork.jwt.secret=${JWT_SECRET:afterwork-jwt-secret-min-32-bytes-for-hs256!}
# Durée de vie du token (secondes, défaut 24h)
smallrye.jwt.new-token.lifespan=${JWT_LIFESPAN:86400}
smallrye.jwt.new-token.issuer=afterwork
# ====================================================================
# JWT Validation (SmallRye JWT)
# ====================================================================
# Clé secrète pour vérifier les tokens (doit correspondre à afterwork.jwt.secret)
mp.jwt.verify.publickey.algorithm=HS256
smallrye.jwt.verify.algorithm=HS256
smallrye.jwt.sign.key.location=
mp.jwt.verify.issuer=afterwork
# Clé secrète inline (Base64 encodée) - générée dynamiquement via JwtService
smallrye.jwt.verify.key.location=
# ====================================================================
# Wave API (paiement droits d'accès établissements)
# ====================================================================

View File

@@ -0,0 +1,47 @@
-- Migration V20: Création de la table post_comments (Commentaires sur posts sociaux)
-- Date: 2026-02-04
-- Description: Table des commentaires associés aux posts sociaux AfterWork
CREATE TABLE IF NOT EXISTS post_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
content VARCHAR(1000) NOT NULL,
post_id UUID NOT NULL,
user_id UUID NOT NULL,
CONSTRAINT fk_post_comments_post
FOREIGN KEY (post_id)
REFERENCES social_posts(id)
ON DELETE CASCADE,
CONSTRAINT fk_post_comments_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE
);
-- Index pour améliorer les performances des requêtes fréquentes
CREATE INDEX IF NOT EXISTS idx_post_comments_post_id ON post_comments(post_id);
CREATE INDEX IF NOT EXISTS idx_post_comments_user_id ON post_comments(user_id);
CREATE INDEX IF NOT EXISTS idx_post_comments_created_at ON post_comments(created_at DESC);
-- Trigger pour mettre à jour updated_at automatiquement
CREATE OR REPLACE FUNCTION update_post_comments_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_update_post_comments_updated_at ON post_comments;
CREATE TRIGGER trigger_update_post_comments_updated_at
BEFORE UPDATE ON post_comments
FOR EACH ROW
EXECUTE FUNCTION update_post_comments_updated_at();
COMMENT ON TABLE post_comments IS 'Commentaires sur les posts sociaux AfterWork';
COMMENT ON COLUMN post_comments.content IS 'Texte du commentaire (max 1000 caractères)';
COMMENT ON COLUMN post_comments.post_id IS 'Référence vers le post social commenté';
COMMENT ON COLUMN post_comments.user_id IS 'Auteur du commentaire';

View File

@@ -1,8 +0,0 @@
package dev.lions;
import io.quarkus.test.junit.QuarkusIntegrationTest;
@QuarkusIntegrationTest
class GreetingResourceIT extends GreetingResourceTest {
// Execute the same tests but in packaged mode.
}

View File

@@ -1,20 +0,0 @@
package dev.lions;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
@QuarkusTest
class GreetingResourceTest {
@Test
void testHelloEndpoint() {
given()
.when().get("/hello")
.then()
.statusCode(200)
.body(is("Hello RESTEasy"));
}
}