Compare commits

...

24 Commits

Author SHA1 Message Date
dahoud
13d3097b3e Refactoring 2026-02-07 17:04:49 +00:00
dahoud
fc451f025e fix(security): Correction definitive de la verification JWT HS256
PROBLEME RESOLU:

- Les tokens JWT generes au login n'etaient pas verifies correctement

- SmallRye JWT ne pouvait pas charger la cle de verification

- Incompatibilite entre l'issuer du token et celui attendu

CORRECTIONS:

- Creation de jwt-secret.jwk au format JWK standard pour cles symetriques

- Configuration smallrye.jwt.verify.key.location vers le fichier JWK

- Alignement de l'issuer sur 'afterwork' dans .env.example

Ce commit sert de checkpoint stable pour la configuration JWT.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 02:44:35 +00:00
dahoud
e78423dd16 Refactoring 2026-02-05 18:21:13 +00:00
dahoud
4532a25427 Refactoring 2026-02-05 18:16:18 +00:00
dahoud
806efeb074 Refactoring 2026-02-05 18:09:30 +00:00
dahoud
2a794523b6 Refactoring - Bonne version améliorée 2026-02-05 16:30:20 +00:00
dahoud
dd4dbe111e Refactoring - Bonne version améliorée 2026-02-05 14:14:45 +00:00
dahoud
a515963a4a Refactoring 2026-02-04 12:46:56 +00:00
dahoud
c31c6174cc Refactoring 2026-02-04 01:06:17 +00:00
dahoud
40de25315c Refactoring 2026-02-02 19:22:36 +00:00
dahoud
950041719e Refactoring 2026-02-02 19:14:42 +00:00
dahoud
6e89295e6b Refactoring 2026-02-02 19:07:36 +00:00
dahoud
7021b7a7ce Refactoring 2026-02-02 01:37:11 +00:00
dahoud
bcbae7c599 Refactoring 2026-02-02 00:59:52 +00:00
dahoud
8f5267d895 Refactoring 2026-02-02 00:42:02 +00:00
dahoud
675e0925b8 Refactoring 2026-01-31 21:50:43 +00:00
dahoud
0240442671 fix(build): switch from uber-jar to fast-jar for Docker compatibility
- Change quarkus.package.type from uber-jar to fast-jar
- Add EventShare entity and migration for share tracking
- Add establishment capacity field
- Improve event and establishment services
- Add comprehensive tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:27:27 +00:00
dahoud
9dc9ca591c Refactoring 2026-01-31 16:54:46 +00:00
dahoud
ce89face73 feat: v2.0 – réorg docker/scripts, prod, résas, abonnements Wave, Flyway base vierge 2026-01-29 00:44:40 +00:00
dahoud
9d5e388efa Refactoring 2026-01-24 09:33:59 +00:00
dahoud
c5a65bab5b Refactoring 2026-01-21 21:46:21 +00:00
dahoud
cb8b9da12e Refactoring 2026-01-21 21:37:48 +00:00
dahoud
8cb67f1762 Refactoring 2026-01-21 19:16:24 +00:00
dahoud
b9fc1ee05a fix: ajouter application-production.properties et corriger config WebSockets Next 2026-01-21 18:18:20 +00:00
210 changed files with 14218 additions and 2714 deletions

View File

@@ -42,3 +42,7 @@ logs/
*.temp
tmp/
temp/
# Scripts et Docker (hors contexte utile pour le build)
scripts/
docker/

129
.env.example Normal file
View File

@@ -0,0 +1,129 @@
# ============================================
# AfterWork API - Configuration
# ============================================
# Copiez ce fichier vers .env et remplissez les valeurs appropriées
# NE JAMAIS COMMITTER le fichier .env !
#
# ==== INFRASTRUCTURE LIONS (Production) ====
# - API Gateway: https://api.lions.dev/afterwork
# - PostgreSQL: postgresql-service.postgresql.svc.cluster.local:5432
# - Kafka: kafka-service.kafka.svc.cluster.local:9092
# - Prometheus: https://prometheus.lions.dev
# - Grafana: https://grafana.lions.dev
# - Vault: https://vault.lions.dev
# - Keycloak: https://security.lions.dev
# ============================================
# BASE DE DONNÉES
# ============================================
# === Développement local ===
DB_HOST=localhost
DB_PORT=5432
DB_NAME=afterwork_dev
DB_USERNAME=skyfile
DB_PASSWORD=skyfile
# === Production Lions (via Kubernetes Secrets) ===
# DB_HOST=postgresql-service.postgresql.svc.cluster.local
# DB_PORT=5432
# DB_NAME=mic-after-work-server-impl-quarkus-main
# DB_USERNAME=lionsuser
# DB_PASSWORD=<voir-kubernetes-secrets>
# ============================================
# JWT / SÉCURITÉ
# ============================================
# Secret pour signer les tokens JWT (minimum 32 caractères)
# Générez avec: openssl rand -base64 32
JWT_SECRET=afterwork-jwt-secret-min-32-bytes-for-hs256!
JWT_LIFESPAN=86400
# IMPORTANT: L'issuer doit être "afterwork" (correspondant à JwtService.ISSUER)
JWT_ISSUER=afterwork
# ============================================
# SUPER ADMIN
# ============================================
SUPER_ADMIN_EMAIL=superadmin@afterwork.lions.dev
SUPER_ADMIN_PASSWORD=SuperAdmin2025!
SUPER_ADMIN_API_KEY=dev-super-admin-key
# ============================================
# EMAIL (SMTP)
# ============================================
# Mode mock pour le développement (pas d'envoi réel)
MAILER_MOCK=true
MAILER_HOST=smtp.gmail.com
MAILER_PORT=587
MAILER_USERNAME=noreply@afterwork.ci
MAILER_PASSWORD=CHANGEZ_MOI_SMTP_PASSWORD
MAILER_FROM=AfterWork <noreply@afterwork.ci>
# ============================================
# KAFKA
# ============================================
# === Développement local ===
KAFKA_BOOTSTRAP_SERVERS=localhost:9092
# === Production Lions ===
# KAFKA_BOOTSTRAP_SERVERS=kafka-service.kafka.svc.cluster.local:9092
# === Confluent Cloud (optionnel) ===
# KAFKA_BOOTSTRAP_SERVERS=pkc-xxxxx.region.provider.confluent.cloud:9092
# KAFKA_SECURITY_PROTOCOL=SASL_SSL
# KAFKA_SASL_MECHANISM=PLAIN
# KAFKA_SASL_USERNAME=YOUR_API_KEY
# KAFKA_SASL_PASSWORD=YOUR_API_SECRET
# ============================================
# WAVE PAYMENT
# ============================================
WAVE_BASE_URL=https://api.wave.com
WAVE_API_KEY=VOTRE_CLE_API_WAVE
WAVE_SECRET=VOTRE_SECRET_WAVE
WAVE_CURRENCY=XOF
WAVE_CALLBACK_URL=https://api.lions.dev/afterwork/webhooks/wave
# ============================================
# RATE LIMITING
# ============================================
AFTERWORK_RATELIMIT_MAX_REQUESTS=10
AFTERWORK_RATELIMIT_WINDOW_SECONDS=60
# ============================================
# QUARKUS
# ============================================
QUARKUS_PROFILE=dev
QUARKUS_PACKAGE_TYPE=fast-jar
QUARKUS_LOG_LEVEL=INFO
QUARKUS_LOG_CONSOLE_JSON=false
# CORS (développement)
QUARKUS_HTTP_CORS=true
QUARKUS_HTTP_CORS_ORIGINS=http://localhost:3000,http://localhost:4200
# ============================================
# OBSERVABILITÉ
# ============================================
# Métriques Prometheus (auto-découverte via annotations K8s)
QUARKUS_MICROMETER_EXPORT_PROMETHEUS_ENABLED=true
# Health checks
QUARKUS_SMALLRYE_HEALTH_UI_ENABLE=true
# ============================================
# DÉPLOIEMENT LIONS (lionsctl)
# ============================================
# Pour déployer avec lionsctl:
# lionsctl pipeline \
# -u https://git.lions.dev/lionsdev/mic-after-work-server-impl-quarkus-main \
# -b develop \
# -j 17 \
# -e production \
# -c k2 \
# -m dadyo@lions.dev
# Variables d'environnement requises pour lionsctl:
# LIONS_REGISTRY_USERNAME=lionsregistry
# LIONS_REGISTRY_PASSWORD=<votre-mot-de-passe>
# LIONS_GITEA_USERNAME=lionsctl-bot
# LIONS_GITEA_PASSWORD=lionsctl-bot@2025

80
.gitignore vendored
View File

@@ -1,51 +1,105 @@
#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
# JWT secret key (ne pas committer en prod!)
src/main/resources/META-INF/jwt-secret.key
# 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

@@ -1,3 +1,4 @@
-Xmx2048m
-Xms1024m
-XX:MaxMetaspaceSize=512m
-Dfile.encoding=UTF-8

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

@@ -2,7 +2,7 @@
## 📋 Vue d'Ensemble
Ce guide décrit le processus de déploiement de l'API AfterWork sur le VPS via `lionesctl pipeline`.
Ce guide décrit le processus de déploiement de l'API AfterWork sur le VPS via `lionsctl pipeline`.
**URL de l'API** : `https://api.lions.dev/afterwork`
@@ -14,7 +14,7 @@ Ce guide décrit le processus de déploiement de l'API AfterWork sur le VPS via
- Java 17 (JDK)
- Maven 3.9+
- Docker 20.10+
- `lionesctl` CLI installé et configuré
- `lionsctl` CLI installé et configuré
### Environnement Serveur
- PostgreSQL 15+
@@ -38,9 +38,9 @@ DB_USERNAME: afterwork # Utilisateur de la base de données
DB_PASSWORD: <secret> # Mot de passe (à définir dans le secret)
```
### 2. Dockerfile.prod
### 2. docker/Dockerfile.prod
Le fichier `Dockerfile.prod` utilise une approche multi-stage :
Le fichier `docker/Dockerfile.prod` utilise une approche multi-stage :
- **Stage 1** : Build avec Maven dans une image UBI8 OpenJDK 17
- **Stage 2** : Runtime optimisé avec l'uber-jar compilé
@@ -59,8 +59,8 @@ Configuration production avec :
### Build Local (Test)
```bash
# Build de l'image
docker build -f Dockerfile.prod -t afterwork-api:latest .
# Build de l'image (Dockerfiles dans docker/)
docker build -f docker/Dockerfile.prod -t afterwork-api:latest .
# Test local
docker run -p 8080:8080 \
@@ -86,31 +86,66 @@ docker push registry.lions.dev/afterwork-api:latest
---
## 🚢 Déploiement avec lionesctl
## 🚢 Déploiement avec lionsctl pipeline
### Commande de Déploiement
La commande **`lionsctl pipeline`** clone le repo Git, compile (Maven), construit l'image Docker, déploie sur Kubernetes et envoie une notification email. Il n'y a pas de sous-commande `deploy` : tout est inclus dans `lionsctl pipeline`.
### Commande de déploiement
Remplacez `<org>` par votre organisation Git (ex. `lionsdev`, `developer`) et `<email>` par l'adresse de notification.
```bash
# Déploiement via lionesctl pipeline
lionesctl pipeline deploy \
--app afterwork-api \
--image registry.lions.dev/afterwork-api:1.0.0 \
--namespace applications \
--port 8080 \
--replicas 2
# Déploiement en dev (clone + build + image + déploiement K8s)
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>
# Ou avec le fichier de configuration
lionesctl pipeline deploy -f kubernetes/afterwork-deployment.yaml
# Déploiement en production sur le cluster k2
lionsctl pipeline \
-u https://git.lions.dev/<org>/mic-after-work-server-impl-quarkus-main \
-b main \
-j 17 \
-e production \
-c k2 \
-m <email> \
-p prod
# Avec déploiement Helm (charts générés automatiquement)
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> \
--use-helm
```
### Vérification du Déploiement
**Options principales :**
| Option | Description | Exemple |
|--------|-------------|---------|
| `-u`, `--url` | URL du repo Git (obligatoire) | `https://git.lions.dev/.../mic-after-work-server-impl-quarkus-main` |
| `-b`, `--branch` | Branche à déployer | `develop`, `main` |
| `-j`, `--java-version` | Version Java (821) | `17` |
| `-e`, `--environment` | Environnement (dev / staging / production) | `dev`, `production` |
| `-c`, `--cluster` | Cluster Kubernetes (k1 ou k2) (obligatoire) | `k1`, `k2` |
| `-m`, `--mail` | Email(s) pour les notifications | `admin@lions.dev` |
| `-p`, `--profile` | Profil Maven | `prod` pour production |
| `--use-helm` | Déployer via Helm | — |
### Vérification du déploiement
```bash
# Status du déploiement
lionesctl pipeline status --app afterwork-api
# Pods et statut (nom d'app dérivé du repo, ex. mic-after-work-server-impl-quarkus-main)
kubectl get pods -n applications -l app=mic-after-work-server-impl-quarkus-main
# Logs en temps réel
lionesctl pipeline logs --app afterwork-api --follow
kubectl logs -n applications -l app=mic-after-work-server-impl-quarkus-main -f
# Health check
curl https://api.lions.dev/afterwork/q/health/ready
@@ -284,8 +319,8 @@ ls target/*-runner.jar
### Étape 2 : Build Docker
```bash
# Build l'image de production
docker build -f Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 .
# Build l'image de production (Dockerfiles dans docker/)
docker build -f docker/Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 .
# Test local (optionnel)
docker run --rm -p 8080:8080 \
@@ -325,8 +360,8 @@ kubectl apply -f kubernetes/afterwork-deployment.yaml
kubectl apply -f kubernetes/afterwork-service.yaml
kubectl apply -f kubernetes/afterwork-ingress.yaml
# Ou via lionesctl pipeline
lionesctl pipeline deploy -f kubernetes/
# Ou via lionsctl pipeline (clone + build + 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>
```
### Étape 5 : Vérification
@@ -361,7 +396,7 @@ curl https://api.lions.dev/afterwork/api/users/test
```bash
# 1. Build nouvelle version
mvn clean package -DskipTests
docker build -f Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.1 .
docker build -f docker/Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.1 .
docker push registry.lions.dev/afterwork-api:1.0.1
# 2. Mise à jour du déploiement

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 lionesctl (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
lionesctl pipeline deploy -f kubernetes/
```
### 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

@@ -8,15 +8,15 @@
cd C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main
# Déploiement complet (build + push + deploy)
.\deploy.ps1 -Action all -Version 1.0.0
.\scripts\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
.\scripts\deploy.ps1 -Action build # Build Maven + Docker
.\scripts\deploy.ps1 -Action push # Push vers registry
.\scripts\deploy.ps1 -Action deploy # Déploiement K8s
# Vérifier le statut
.\deploy.ps1 -Action status
.\scripts\deploy.ps1 -Action status
```
### Option 2 : Déploiement Manuel
@@ -28,7 +28,7 @@ cd C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main
mvn clean package -DskipTests
# 2. Build Docker
docker build -f Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 -t registry.lions.dev/afterwork-api:latest .
docker build -f docker/Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 -t registry.lions.dev/afterwork-api:latest .
# 3. Push vers Registry
docker login registry.lions.dev
@@ -48,18 +48,15 @@ kubectl get pods -n applications -l app=afterwork-api
kubectl logs -n applications -l app=afterwork-api -f
```
### Option 3 : Déploiement via lionesctl
### Option 3 : Déploiement via lionsctl pipeline
```bash
cd C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main
# Build local
mvn clean package -DskipTests
docker build -f Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 .
docker push registry.lions.dev/afterwork-api:1.0.0
# Le pipeline clone le repo, build Maven, construit limage Docker et déploie sur K8s. Remplacer <org> et <email>.
# Déploiement
lionesctl pipeline deploy -f kubernetes/
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>
```
---

View File

@@ -53,6 +53,36 @@ You can then execute your native executable with: `./target/mic-after-work-serve
If you want to learn more about building native executables, please consult <https://quarkus.io/guides/maven-tooling>.
## Fonctionnalités métier (AfterWork)
### Notifications
- **Service** : `NotificationService` — création, lecture, pagination, marquage lu/suppression des notifications en base.
- **Déclencheurs** : Notifications créées automatiquement pour les demandes damitié (destinataire), les likes/commentaires sur les posts (auteur du post), les nouvelles notes détablissement (manager).
- **API** : `GET/POST /notifications/user/{userId}`, pagination, marquer lu, supprimer. Voir [SECURITY.md](SECURITY.md) pour lusage en production (userId issu de lauth).
### Jobs planifiés (Quarkus Scheduler)
- **Stories** : Désactivation des stories expirées (cron : toutes les heures).
- **Tokens** : Suppression des tokens de réinitialisation de mot de passe expirés (tous les jours à 3h).
- **Abonnements** : Expiration des abonnements établissements et désactivation des établissements non payés (toutes les heures).
- **Rappels événements** : Notifications en base pour les participants (J-1 et H-1), exécution toutes les 15 minutes.
- **Avertissement abonnement** : Envoi demails J-3 avant expiration aux managers (tous les jours à 9h).
Configuration : `quarkus.scheduler.enabled=true` (désactivé en test via `%test.quarkus.scheduler.enabled=false`).
### Emails transactionnels
- **EmailService** : Réinitialisation mot de passe, bienvenue, confirmation de paiement Wave, rappel événement, avertissement expiration abonnement, confirmation de réservation, échec de paiement Wave.
- Configuration SMTP via variables denvironnement (`MAILER_HOST`, `MAILER_USERNAME`, `MAILER_PASSWORD`, etc.) ; en test le mailer peut être en mode mock.
### Paiement Wave (établissements)
- Initiation de paiement (abonnement mensuel/annuel), webhook `POST /webhooks/wave` pour `payment.completed`, `payment.refunded`, `payment.failed`, etc.
- Vérification optionnelle de la signature du webhook (header `X-Wave-Signature`, HMAC-SHA256) si `wave.webhook.secret` est configuré. Voir [SECURITY.md](SECURITY.md).
---
## Related Guides
- Hibernate ORM ([guide](https://quarkus.io/guides/hibernate-orm)): Define your persistent model with Hibernate ORM and Jakarta Persistence
@@ -61,6 +91,11 @@ If you want to learn more about building native executables, please consult <htt
- Logging JSON ([guide](https://quarkus.io/guides/logging#json-logging)): Add JSON formatter for console logging
- JDBC Driver - PostgreSQL ([guide](https://quarkus.io/guides/datasource)): Connect to the PostgreSQL database via JDBC
## Sécurité et déploiement
- **Sécurité** : Voir [SECURITY.md](SECURITY.md) (auth, webhook Wave, secrets, validation).
- **Docker** : Voir [docker/README.md](docker/README.md) pour lancer lapp et les dépendances (PostgreSQL, etc.).
## Provided Code
### Hibernate ORM

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

119
REALTIME_DEV.md Normal file
View File

@@ -0,0 +1,119 @@
# Temps réel en développement (Kafka + WebSocket)
Ce guide permet de faire fonctionner les **notifications / présence / réactions / chat** en temps réel en environnement de développement.
## Architecture
```
Services métier → Kafka (topics) → Bridges → WebSocket → Client Flutter
```
- **Topics Kafka** : `notifications`, `chat.messages`, `reactions`, `presence.updates`
- **WebSocket** : `ws://<backend>/notifications/<userId>` (et `/chat/<userId>` pour le chat)
## 1. Démarrer Kafka en local
Un conteneur Kafka doit être joignable sur le **port 9092** depuis la machine où tourne Quarkus.
### Option A : Conteneur existant
Si vous avez déjà un conteneur Kafka (ex. ID `e100552d0da2...`) :
- Vérifiez que le port **9092** est exposé vers lhôte :
```bash
docker port <container_id_or_name> 9092
```
- Si rien nest mappé, recréez le conteneur avec `-p 9092:9092` ou dans un `docker-compose` :
```yaml
kafka:
image: apache/kafka-native:latest # ou quay.io/strimzi/kafka:latest, etc.
ports:
- "9092:9092"
# ... reste de la config (KAFKA_CFG_..., etc.)
```
### Option B : Lancer Kafka avec Docker (exemple minimal)
```bash
docker run -d --name kafka-dev -p 9092:9092 \
-e KAFKA_NODE_ID=1 \
-e KAFKA_PROCESS_ROLES=broker,controller \
-e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093 \
apache/kafka-native:latest
```
(Adaptez limage et les variables à votre setup si vous en utilisez un autre.)
### Depuis une autre machine / Docker
- **Quarkus sur lhôte, Kafka dans Docker** : `localhost:9092` suffit si le port est mappé (`-p 9092:9092`).
- **Quarkus dans Docker, Kafka sur lhôte** : utilisez `host.docker.internal:9092` (Windows/Mac) ou lIP de lhôte.
- Définir alors :
```bash
export KAFKA_BOOTSTRAP_SERVERS=localhost:9092
```
(ou `host.docker.internal:9092` selon le cas).
## 2. Démarrer le backend Quarkus (profil dev)
```bash
cd mic-after-work-server-impl-quarkus-main
mvn quarkus:dev
```
Le fichier `application-dev.properties` utilise par défaut `localhost:9092`.
En cas derreur de connexion Kafka au démarrage, vérifiez que Kafka écoute bien sur 9092 et que `KAFKA_BOOTSTRAP_SERVERS` pointe vers ce broker.
Logs utiles au démarrage :
- `[KAFKA-BRIDGE] Bridge démarré pour topic: notifications`
- Pas dexception type `ConfigException` / « No resolvable bootstrap urls »
Quand une notification est publiée et consommée :
- `[KAFKA-BRIDGE] Événement reçu: type=... userId=...`
- `[WS-NEXT] Notification envoyée à <userId> (Succès: 1, Échec: 0)`
## 3. Configurer lapp Flutter (URL du backend)
Le client doit pouvoir joindre le **HTTP** et le **WebSocket** du même backend.
- **Émulateur Android** : souvent `http://10.0.2.2:8080` (puis WebSocket `ws://10.0.2.2:8080/notifications/<userId>`).
- **Appareil physique / même réseau** : IP de la machine qui fait tourner Quarkus, ex. `http://192.168.1.103:8080`.
- **Chrome / web** : `http://localhost:8080` si Flutter web et Quarkus sont sur la même machine.
Définir cette URL comme base API (elle est aussi utilisée pour le WebSocket) :
- Au run :
```bash
flutter run --dart-define=API_BASE_URL=http://<VOTRE_IP_OU_HOST>:8080
```
- Ou dans `lib/core/constants/env_config.dart` (valeur par défaut en dev).
Important : **pas de slash final** dans `API_BASE_URL` (ex. `http://192.168.1.103:8080`).
## 4. Vérifier que le temps réel fonctionne
1. **Connexion WebSocket**
- Se connecter dans lapp avec un utilisateur.
- Côté Flutter : log du type « Connecté avec succès au service de notifications ».
- Côté Quarkus : `[WS-NEXT] Connexion ouverte pour l'utilisateur: <userId>`.
2. **Notification (ex. demande dami / post)**
- Déclencher une action qui crée une notification (autre compte ou service).
- Côté Quarkus : `[KAFKA-BRIDGE] Événement reçu` puis `[WS-NEXT] Notification envoyée à ...`.
- Côté Flutter : la notification doit apparaître sans recharger (si lécran écoute le stream temps réel).
3. **Si rien narrive**
- Kafka : le broker est-il bien sur le port 9092 ? `KAFKA_BOOTSTRAP_SERVERS` correct ?
- WebSocket : lURL dans lapp est-elle exactement celle du backend (même hôte/port) ?
- CORS : pour Flutter web, le backend doit autoriser lorigine de lapp (déjà géré dans la config actuelle si vous navez pas changé lorigine).
## 5. Résumé des variables utiles (dev)
| Variable | Rôle | Exemple |
|----------|------|--------|
| `KAFKA_BOOTSTRAP_SERVERS` | Broker Kafka pour Quarkus | `localhost:9092` ou `host.docker.internal:9092` |
| `API_BASE_URL` (Flutter) | Base HTTP + WS du backend | `http://192.168.1.103:8080` |
Aucune régression fonctionnelle nest introduite par ce guide : seules la configuration dev et le format des messages WebSocket (timestamp/type dans `data`) ont été alignés pour le client.

33
SECURITY.md Normal file
View File

@@ -0,0 +1,33 @@
# Sécurité AfterWork Backend
## Authentification et autorisation
- **Super Admin** : Les opérations réservées au super administrateur (stats admin, modification de rôle utilisateur, impersonation) exigent le header `X-Super-Admin-Key` dont la valeur doit correspondre à la propriété `afterwork.super-admin.api-key` (ou `SUPER_ADMIN_API_KEY` en production). À configurer uniquement côté serveur, jamais exposée au client.
- **Utilisateurs / rôles** : À ce jour, lAPI ne repose pas sur JWT/OAuth pour les endpoints métier. En production, il est recommandé dajouter un filtre ou une ressource qui dérive lidentité (userId) du token (JWT/session) et de **ne pas faire confiance au `userId` passé dans lURL** (ex. `GET /notifications/user/{userId}`). L`userId` utilisé doit être celui de lutilisateur authentifié.
## Endpoints sensibles
- **Notifications** (`/notifications/user/{userId}`) : En létat, tout appelant peut demander les notifications dun autre utilisateur en changeant `userId`. En production, remplacer `userId` par lidentifiant issu du contexte dauthentification (JWT/subject).
- **Admin** : `AdminStatsResource` et les endpoints de modification de rôle dans `UsersResource` sont protégés par `X-Super-Admin-Key`.
## Webhook Wave
- **Signature** : Si la propriété `wave.webhook.secret` (ou `WAVE_WEBHOOK_SECRET`) est renseignée, le endpoint `/webhooks/wave` vérifie le header `X-Wave-Signature` (HMAC-SHA256 du body avec ce secret). Sans secret configuré, la vérification est désactivée (acceptable uniquement en dev/test).
- **Production** : Configurer systématiquement `WAVE_WEBHOOK_SECRET` avec le secret fourni par Wave pour éviter les appels forgés.
## Secrets et configuration
- **Base de données** : Utiliser les variables denvironnement (ex. `DB_USERNAME`, `DB_PASSWORD`) ou le profil Quarkus ; ne pas committer de mots de passe en clair.
- **Wave** : `WAVE_API_KEY` et `WAVE_WEBHOOK_SECRET` via variables denvironnement.
- **Email (SMTP)** : `MAILER_USERNAME`, `MAILER_PASSWORD` (et optionnellement `MAILER_FROM`, `MAILER_HOST`, etc.) via variables denvironnement.
- **Super Admin** : `SUPER_ADMIN_EMAIL`, `SUPER_ADMIN_PASSWORD`, `SUPER_ADMIN_API_KEY` pour la production.
## Validation des entrées
- Les DTOs utilisent Bean Validation (`@Valid`, `@NotNull`, `@Size`, `@Email`, `@Pattern`) sur les endpoints principaux (création utilisateur, authentification, établissements, abonnements, etc.). Conserver et étendre ces contraintes sur tout nouvel endpoint.
## Bonnes pratiques
- Répondre par des codes HTTP adaptés (401 si non autorisé, 403 si interdit, 404 si ressource absente).
- Ne pas logger de secrets (tokens, mots de passe, clés API).
- En production, utiliser HTTPS et limiter lexposition des headers sensibles (CORS, sécurisation des headers).

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

@@ -1,52 +0,0 @@
services:
db:
image: postgres:13
container_name: afterwork_db
environment:
POSTGRES_USER: "${DB_USERNAME}"
POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: "${DB_NAME}"
networks:
- afterwork-network
volumes:
- db_data:/var/lib/postgresql/data
restart: unless-stopped
app:
image: dahoudg/afterwork-quarkus:latest
container_name: afterwork-quarkus
environment:
DB_USERNAME: "${DB_USERNAME}"
DB_PASSWORD: "${DB_PASSWORD}"
DB_HOST: "${DB_HOST}"
DB_PORT: "${DB_PORT}"
DB_NAME: "${DB_NAME}"
JAVA_OPTS_APPEND: "-Dquarkus.http.host=0.0.0.0"
ports:
- "8080:8080"
depends_on:
- db
networks:
- afterwork-network
restart: unless-stopped
swagger-ui:
image: swaggerapi/swagger-ui
container_name: afterwork-swagger-ui
environment:
SWAGGER_JSON: http://app:8080/openapi
ports:
- "8081:8080"
depends_on:
- app
networks:
- afterwork-network
restart: unless-stopped
networks:
afterwork-network:
driver: bridge
volumes:
db_data:
driver: local

View File

@@ -1,6 +1,6 @@
##
## AfterWork Server - Development Dockerfile
## Image légère avec JRE Alpine
## Image légère avec JRE Alpine (JAR pré-buildé requis)
##
FROM eclipse-temurin:17-jre-alpine
@@ -25,7 +25,7 @@ RUN mkdir -p /app /tmp/uploads && \
WORKDIR /app
# Copie du JAR
# Copie du JAR (context = racine du projet, build après mvn package)
COPY --chown=appuser:appuser target/*-runner.jar /app/app.jar
# Exposition du port

View File

@@ -13,7 +13,7 @@ USER root
# Installation de Maven
RUN microdnf install -y maven && microdnf clean all
# Copie des fichiers du projet
# Copie des fichiers du projet (context = racine du projet)
WORKDIR /build
COPY pom.xml .
COPY src ./src

54
docker/README.md Normal file
View File

@@ -0,0 +1,54 @@
# Docker AfterWork
Fichiers Docker pour le build et lexécution de lAPI AfterWork.
## Fichiers
| Fichier | Usage |
|---------|--------|
| `Dockerfile` | Image dev (JAR pré-buildé, Alpine) |
| `Dockerfile.prod` | Image prod (multi-stage Maven + UBI8) |
| `docker-compose.yml` | Stack optionnelle (app + PostgreSQL/Kafka sur lhôte) |
## Build (depuis la racine du projet)
```bash
# Image de production
docker build -f docker/Dockerfile.prod -t afterwork-quarkus:latest .
# Image de dev (après mvn package)
docker build -f docker/Dockerfile -t afterwork-quarkus:dev .
```
## Docker Compose
Utilise le PostgreSQL et Kafka déjà en cours dexécution sur lhôte (host.docker.internal).
**Depuis la racine :**
```bash
docker-compose -f docker/docker-compose.yml up -d
```
**Depuis docker/ :**
```bash
cd docker && docker-compose up -d
```
### PostgreSQL (obligatoire)
Lapplication se connecte à PostgreSQL sur lhôte (`host.docker.internal:5432`). Sans identifiants, lerreur **« no password was provided »** apparaît.
- **Par défaut** (si vous ne définissez rien) : `DB_USERNAME=afterwork`, `DB_PASSWORD=changeme`, `DB_NAME=afterwork_db`.
- Créer la base et lutilisateur dans PostgreSQL, par exemple :
```sql
CREATE USER afterwork WITH PASSWORD 'changeme';
CREATE DATABASE afterwork_db OWNER afterwork;
```
- Ou utiliser **vos** identifiants via un fichier **`.env` à la racine du projet** (mic-after-work-server-impl-quarkus-main) — Docker Compose le charge quand vous lancez depuis cette racine :
```bash
# Contenu de .env à la racine du projet
DB_USERNAME=monuser
DB_PASSWORD=monmotdepasse
DB_NAME=afterwork_db
```
Si vous lancez depuis `docker/` (`cd docker && docker-compose up`), placez le `.env` dans le dossier `docker/`.

26
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,26 @@
# Dev: mvn quarkus:dev (H2 in-memory, Swagger /q/swagger-ui, Kafka localhost:9092)
# App utilise le PostgreSQL existant (ex: skyfile sur 5432) - créer la DB: CREATE DATABASE afterwork_db;
# Lancer depuis la racine: docker-compose -f docker/docker-compose.yml up -d
# Ou depuis docker/: docker-compose up -d
#
# PostgreSQL: définir DB_USERNAME/DB_PASSWORD si votre instance utilise d'autres identifiants.
# Exemple .env à la racine docker/ : DB_USERNAME=monuser DB_PASSWORD=monmotdepasse
services:
app:
build:
context: ..
dockerfile: docker/Dockerfile.prod
image: afterwork-quarkus:latest
container_name: afterwork-quarkus
environment:
QUARKUS_PROFILE: prod
DB_HOST: host.docker.internal
DB_PORT: "5432"
DB_NAME: "${DB_NAME:-afterwork_db}"
DB_USERNAME: "${DB_USERNAME:-afterwork}"
DB_PASSWORD: "${DB_PASSWORD:-changeme}"
KAFKA_BOOTSTRAP_SERVERS: host.docker.internal:9092
JAVA_OPTS_APPEND: "-Dquarkus.http.host=0.0.0.0"
ports:
- "8080:8080"
restart: unless-stopped

View File

@@ -1,12 +1,2 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: afterwork-config
namespace: applications
data:
DB_HOST: "postgresql"
DB_PORT: "5432"
DB_NAME: "afterwork_db"
DB_USERNAME: "afterwork"
QUARKUS_PROFILE: "prod"
TZ: "Africa/Douala"
# ConfigMap déplacé dans afterwork-secrets.yaml pour cohérence
# Voir afterwork-secrets.yaml pour la configuration complète

View File

@@ -1,14 +1,20 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: afterwork-api
name: mic-after-work-server-impl-quarkus-main
namespace: applications
labels:
app: afterwork-api
app: mic-after-work-server-impl-quarkus-main
version: "1.0.0"
environment: production
component: application
project: lions-infrastructure-2025
annotations:
description: "AfterWork API - Application sociale déployée via lionsctl"
lionsctl.lions.dev/deployed-by: "lionsctl"
spec:
replicas: 2
replicas: 1
revisionHistoryLimit: 3
strategy:
type: RollingUpdate
rollingUpdate:
@@ -16,37 +22,86 @@ spec:
maxUnavailable: 0
selector:
matchLabels:
app: afterwork-api
app: mic-after-work-server-impl-quarkus-main
template:
metadata:
labels:
app: afterwork-api
app: mic-after-work-server-impl-quarkus-main
version: "1.0.0"
component: application
project: lions-infrastructure-2025
annotations:
# Prometheus scraping - Lions Prometheus auto-découvre via ces annotations
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/afterwork/q/metrics"
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
seccompProfile:
type: RuntimeDefault
terminationGracePeriodSeconds: 30
containers:
- name: afterwork-api
image: registry.lions.dev/lionsdev/mic-after-work-server-impl-quarkus-main:d659416
- name: mic-after-work-server-impl-quarkus-main
image: registry.lions.dev/lionsdev/mic-after-work-server-impl-quarkus-main:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
name: http
protocol: TCP
# Variables d'environnement depuis ConfigMap et Secrets
envFrom:
- configMapRef:
name: afterwork-config
- secretRef:
name: afterwork-secrets
env:
# Override explicites pour Quarkus
- name: QUARKUS_DATASOURCE_DB_KIND
value: "postgresql"
- name: QUARKUS_DATASOURCE_USERNAME
valueFrom:
configMapKeyRef:
name: afterwork-config
key: DB_USERNAME
- name: QUARKUS_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef:
name: afterwork-secrets
key: DB_PASSWORD
- name: QUARKUS_DATASOURCE_JDBC_URL
value: "jdbc:postgresql://$(DB_HOST):$(DB_PORT)/$(DB_NAME)"
# Kafka - Lions Kafka cluster
- name: KAFKA_BOOTSTRAP_SERVERS
valueFrom:
configMapKeyRef:
name: afterwork-config
key: KAFKA_BOOTSTRAP_SERVERS
# JWT
- name: SMALLRYE_JWT_SIGN_KEY
valueFrom:
secretKeyRef:
name: afterwork-secrets
key: JWT_SECRET
- name: MP_JWT_VERIFY_ISSUER
valueFrom:
configMapKeyRef:
name: afterwork-config
key: JWT_ISSUER
# Java options
- name: JAVA_OPTS
value: "-Xms256m -Xmx512m -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
resources:
requests:
memory: "512Mi"
cpu: "250m"
cpu: "200m"
limits:
memory: "1Gi"
cpu: "1000m"
# Health checks HTTP (utilisent les endpoints SmallRye Health)
livenessProbe:
httpGet:
path: /afterwork/q/health/live
@@ -67,13 +122,35 @@ spec:
timeoutSeconds: 5
successThreshold: 1
failureThreshold: 3
# Startup probe pour éviter les kills pendant le démarrage
startupProbe:
httpGet:
path: /afterwork/q/health/started
port: 8080
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 30
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
capabilities:
drop:
- ALL
volumeMounts:
- name: temp-uploads
mountPath: /tmp/uploads
- name: tmp-volume
mountPath: /tmp
- name: logs-volume
mountPath: /app/logs
volumes:
- name: temp-uploads
emptyDir:
sizeLimit: 1Gi
- name: tmp-volume
emptyDir: {}
- name: logs-volume
emptyDir: {}
imagePullSecrets:
- name: lionsregistry-secret
restartPolicy: Always

View File

@@ -46,9 +46,8 @@ metadata:
nginx.ingress.kubernetes.io/rate-limit: "1000"
nginx.ingress.kubernetes.io/rate-limit-window: "1m"
# Rewrite (important pour /afterwork)
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/rewrite-target: /$2
# PAS de rewrite-target : le backend sert sous quarkus.http.root-path=/afterwork,
# l'Ingress doit transmettre le chemin complet (/afterwork/...) au service.
spec:
ingressClassName: nginx
@@ -60,8 +59,8 @@ spec:
- host: api.lions.dev
http:
paths:
- path: /afterwork(/|$)(.*)
pathType: ImplementationSpecific
- path: /afterwork
pathType: Prefix
backend:
service:
name: mic-after-work-server-impl-quarkus-main-service

View File

@@ -0,0 +1,408 @@
# ==============================================================================
# AfterWork API - Configuration Monitoring pour Lions Infrastructure
# ==============================================================================
# Cette configuration intègre l'application avec:
# - Prometheus (https://prometheus.lions.dev) - scraping auto via annotations
# - Grafana (https://grafana.lions.dev) - dashboard dédié
# ==============================================================================
---
# ==============================================================================
# ServiceMonitor pour Prometheus Operator (si installé)
# ==============================================================================
# Note: L'infrastructure Lions utilise le scraping via annotations pod, mais
# ce ServiceMonitor peut être utilisé si Prometheus Operator est déployé.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: afterwork-api-monitor
namespace: monitoring
labels:
app: mic-after-work-server-impl-quarkus-main
release: prometheus
project: lions-infrastructure-2025
spec:
selector:
matchLabels:
app: mic-after-work-server-impl-quarkus-main
namespaceSelector:
matchNames:
- applications
endpoints:
- port: http-direct
path: /afterwork/q/metrics
interval: 30s
scrapeTimeout: 10s
scheme: http
---
# ==============================================================================
# PrometheusRule - Alertes pour AfterWork API
# ==============================================================================
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: afterwork-api-alerts
namespace: monitoring
labels:
app: mic-after-work-server-impl-quarkus-main
release: prometheus
project: lions-infrastructure-2025
spec:
groups:
- name: afterwork-api.rules
rules:
# Alerte si l'application est down
- alert: AfterWorkAPIDown
expr: up{job=~".*afterwork.*"} == 0
for: 2m
labels:
severity: critical
application: afterwork-api
annotations:
summary: "AfterWork API is down"
description: "L'API AfterWork n'est pas accessible depuis plus de 2 minutes"
# Alerte si le taux d'erreur HTTP 5xx est élevé
- alert: AfterWorkHighErrorRate
expr: |
sum(rate(http_server_requests_seconds_count{
kubernetes_namespace="applications",
app="mic-after-work-server-impl-quarkus-main",
status=~"5.."
}[5m])) /
sum(rate(http_server_requests_seconds_count{
kubernetes_namespace="applications",
app="mic-after-work-server-impl-quarkus-main"
}[5m])) > 0.05
for: 5m
labels:
severity: warning
application: afterwork-api
annotations:
summary: "High error rate on AfterWork API"
description: "Le taux d'erreur 5xx est supérieur à 5% depuis 5 minutes"
# Alerte si la latence p95 est élevée
- alert: AfterWorkHighLatency
expr: |
histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{
kubernetes_namespace="applications",
app="mic-after-work-server-impl-quarkus-main"
}[5m])) by (le)) > 2
for: 5m
labels:
severity: warning
application: afterwork-api
annotations:
summary: "High latency on AfterWork API"
description: "La latence p95 dépasse 2 secondes depuis 5 minutes"
# Alerte si la mémoire est proche de la limite
- alert: AfterWorkHighMemoryUsage
expr: |
sum(container_memory_working_set_bytes{
namespace="applications",
pod=~"mic-after-work-server-impl-quarkus-main.*"
}) /
sum(container_spec_memory_limit_bytes{
namespace="applications",
pod=~"mic-after-work-server-impl-quarkus-main.*"
}) > 0.85
for: 5m
labels:
severity: warning
application: afterwork-api
annotations:
summary: "High memory usage on AfterWork API"
description: "L'utilisation mémoire dépasse 85% de la limite"
# Alerte si le pod redémarre fréquemment
- alert: AfterWorkPodRestarts
expr: |
increase(kube_pod_container_status_restarts_total{
namespace="applications",
pod=~"mic-after-work-server-impl-quarkus-main.*"
}[1h]) > 3
for: 5m
labels:
severity: warning
application: afterwork-api
annotations:
summary: "AfterWork API pod restarting frequently"
description: "Le pod a redémarré plus de 3 fois dans la dernière heure"
---
# ==============================================================================
# Grafana Dashboard ConfigMap (pour import automatique)
# ==============================================================================
apiVersion: v1
kind: ConfigMap
metadata:
name: afterwork-grafana-dashboard
namespace: monitoring
labels:
grafana_dashboard: "1"
app: mic-after-work-server-impl-quarkus-main
project: lions-infrastructure-2025
data:
afterwork-api-dashboard.json: |
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "yellow", "value": 100},
{"color": "red", "value": 500}
]
},
"unit": "reqps"
}
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
"id": 1,
"options": {},
"targets": [
{
"expr": "sum(rate(http_server_requests_seconds_count{kubernetes_namespace=\"applications\",app=\"mic-after-work-server-impl-quarkus-main\"}[5m]))",
"legendFormat": "Requests/s",
"refId": "A"
}
],
"title": "Request Rate",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "ms"
}
},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
"id": 2,
"options": {},
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{kubernetes_namespace=\"applications\",app=\"mic-after-work-server-impl-quarkus-main\"}[5m])) by (le)) * 1000",
"legendFormat": "p95 Latency",
"refId": "A"
},
{
"expr": "histogram_quantile(0.50, sum(rate(http_server_requests_seconds_bucket{kubernetes_namespace=\"applications\",app=\"mic-after-work-server-impl-quarkus-main\"}[5m])) by (le)) * 1000",
"legendFormat": "p50 Latency",
"refId": "B"
}
],
"title": "Response Time",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "percent"
}
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
"id": 3,
"options": {},
"targets": [
{
"expr": "sum(rate(http_server_requests_seconds_count{kubernetes_namespace=\"applications\",app=\"mic-after-work-server-impl-quarkus-main\",status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count{kubernetes_namespace=\"applications\",app=\"mic-after-work-server-impl-quarkus-main\"}[5m])) * 100",
"legendFormat": "Error Rate %",
"refId": "A"
}
],
"title": "Error Rate",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "bytes"
}
},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
"id": 4,
"options": {},
"targets": [
{
"expr": "sum(container_memory_working_set_bytes{namespace=\"applications\",pod=~\"mic-after-work-server-impl-quarkus-main.*\"})",
"legendFormat": "Memory Used",
"refId": "A"
},
{
"expr": "sum(container_spec_memory_limit_bytes{namespace=\"applications\",pod=~\"mic-after-work-server-impl-quarkus-main.*\"})",
"legendFormat": "Memory Limit",
"refId": "B"
}
],
"title": "Memory Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
}
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
"id": 5,
"options": {},
"targets": [
{
"expr": "sum(rate(container_cpu_usage_seconds_total{namespace=\"applications\",pod=~\"mic-after-work-server-impl-quarkus-main.*\"}[5m])) * 1000",
"legendFormat": "CPU Usage (millicores)",
"refId": "A"
}
],
"title": "CPU Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "green", "value": 1}
]
}
}
},
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 16},
"id": 6,
"options": {
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"showThresholdLabels": false,
"showThresholdMarkers": true
},
"targets": [
{
"expr": "up{job=~\".*afterwork.*\"}",
"legendFormat": "Status",
"refId": "A"
}
],
"title": "API Status",
"type": "gauge"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "yellow", "value": 1},
{"color": "red", "value": 3}
]
}
}
},
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 16},
"id": 7,
"options": {
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
}
},
"targets": [
{
"expr": "increase(kube_pod_container_status_restarts_total{namespace=\"applications\",pod=~\"mic-after-work-server-impl-quarkus-main.*\"}[1h])",
"legendFormat": "Restarts (1h)",
"refId": "A"
}
],
"title": "Pod Restarts (1h)",
"type": "stat"
}
],
"refresh": "30s",
"schemaVersion": 38,
"style": "dark",
"tags": ["lions", "afterwork", "quarkus", "api"],
"templating": {
"list": []
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "AfterWork API Dashboard",
"uid": "afterwork-api",
"version": 1,
"weekStart": ""
}

View File

@@ -6,8 +6,175 @@ metadata:
labels:
app: afterwork-api
component: secrets
environment: production
project: lions-infrastructure-2025
type: Opaque
stringData:
# Base de données PostgreSQL
# Pattern cohérent avec unionflow et btpxpress
# ==============================================================================
# BASE DE DONNÉES PostgreSQL
# ==============================================================================
# Utilise le PostgreSQL de l'infrastructure Lions
# postgresql-service.postgresql.svc.cluster.local:5432
DB_PASSWORD: "AfterWork2025!"
# ==============================================================================
# JWT / SÉCURITÉ
# ==============================================================================
# Clé secrète JWT (minimum 32 caractères, aléatoire)
# Générer avec: openssl rand -base64 32
JWT_SECRET: "AfterWorkJWTSecret2025LionsInfrastructureKey"
# ==============================================================================
# COMPTE ADMINISTRATEUR INITIAL
# ==============================================================================
ADMIN_EMAIL: "admin@afterwork.ci"
ADMIN_PASSWORD: "AdminAfterWork2025!"
# ==============================================================================
# SERVICE EMAIL (SMTP)
# ==============================================================================
# Configuration Gmail ou autre SMTP
MAILER_USERNAME: "noreply@afterwork.ci"
MAILER_PASSWORD: "CHANGEZ_MOI_SMTP_PASSWORD"
# ==============================================================================
# WAVE PAYMENT (Intégration paiement)
# ==============================================================================
WAVE_API_KEY: "CHANGEZ_MOI_WAVE_API_KEY"
WAVE_SECRET: "CHANGEZ_MOI_WAVE_SECRET"
---
# ==============================================================================
# CONFIGMAP POUR CONFIGURATION NON-SENSIBLE
# ==============================================================================
apiVersion: v1
kind: ConfigMap
metadata:
name: afterwork-config
namespace: applications
labels:
app: afterwork-api
component: configuration
environment: production
project: lions-infrastructure-2025
data:
# ==============================================================================
# BASE DE DONNÉES - Lions PostgreSQL
# ==============================================================================
DB_HOST: "postgresql-service.postgresql.svc.cluster.local"
DB_PORT: "5432"
DB_NAME: "mic-after-work-server-impl-quarkus-main"
DB_USERNAME: "lionsuser"
# ==============================================================================
# QUARKUS
# ==============================================================================
QUARKUS_PROFILE: "prod"
QUARKUS_LOG_LEVEL: "INFO"
QUARKUS_LOG_CONSOLE_JSON: "true"
# ==============================================================================
# JWT
# ==============================================================================
JWT_LIFESPAN: "86400"
JWT_ISSUER: "afterwork-api"
# ==============================================================================
# KAFKA - Lions Infrastructure
# ==============================================================================
# Utilise le Kafka déployé dans le namespace kafka
KAFKA_BOOTSTRAP_SERVERS: "kafka-service.kafka.svc.cluster.local:9092"
# ==============================================================================
# EMAIL (SMTP)
# ==============================================================================
MAILER_HOST: "smtp.gmail.com"
MAILER_PORT: "587"
MAILER_FROM: "AfterWork <noreply@afterwork.ci>"
MAILER_START_TLS: "REQUIRED"
# En production, mettre false. true = mock (pas d'envoi réel)
MAILER_MOCK: "true"
# ==============================================================================
# RATE LIMITING
# ==============================================================================
AFTERWORK_RATELIMIT_MAX_REQUESTS: "10"
AFTERWORK_RATELIMIT_WINDOW_SECONDS: "60"
# ==============================================================================
# WAVE PAYMENT
# ==============================================================================
WAVE_BASE_URL: "https://api.wave.com"
WAVE_CURRENCY: "XOF"
WAVE_CALLBACK_URL: "https://api.lions.dev/afterwork/webhooks/wave"
# ==============================================================================
# OBSERVABILITY - Lions Prometheus/Grafana
# ==============================================================================
# Prometheus scrape via annotations sur le pod
# Grafana disponible sur https://grafana.lions.dev
# ==============================================================================
# KEYCLOAK / SSO (optionnel)
# ==============================================================================
# OIDC_AUTH_SERVER_URL: "https://security.lions.dev/realms/lions"
# OIDC_CLIENT_ID: "afterwork-api"
---
# ==============================================================================
# EXTERNAL SECRET - Intégration Vault (ACTIF)
# ==============================================================================
# Vault est déverrouillé sur https://vault.lions.dev
# Les secrets sont synchronisés depuis Vault vers Kubernetes automatiquement
#
# PRÉREQUIS: Créer les secrets dans Vault avec:
# vault kv put lions/afterwork \
# db_password="AfterWork2025!" \
# jwt_secret="AfterWorkJWTSecret2025LionsInfrastructureKey" \
# admin_password="AdminAfterWork2025!" \
# mailer_password="SMTP_PASSWORD" \
# wave_api_key="WAVE_KEY" \
# wave_secret="WAVE_SECRET"
#
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: afterwork-vault-secrets
namespace: applications
labels:
app: afterwork-api
component: external-secrets
project: lions-infrastructure-2025
spec:
refreshInterval: "1h"
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: afterwork-secrets-vault
creationPolicy: Owner
data:
- secretKey: DB_PASSWORD
remoteRef:
key: lions/data/afterwork
property: db_password
- secretKey: JWT_SECRET
remoteRef:
key: lions/data/afterwork
property: jwt_secret
- secretKey: ADMIN_PASSWORD
remoteRef:
key: lions/data/afterwork
property: admin_password
- secretKey: MAILER_PASSWORD
remoteRef:
key: lions/data/afterwork
property: mailer_password
- secretKey: WAVE_API_KEY
remoteRef:
key: lions/data/afterwork
property: wave_api_key
- secretKey: WAVE_SECRET
remoteRef:
key: lions/data/afterwork
property: wave_secret

View File

@@ -1,10 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: afterwork-api
name: mic-after-work-server-impl-quarkus-main-service
namespace: applications
labels:
app: afterwork-api
app: mic-after-work-server-impl-quarkus-main
component: application
project: lions-infrastructure-2025
annotations:
description: "Service for AfterWork API"
spec:
type: ClusterIP
sessionAffinity: ClientIP
@@ -12,9 +16,15 @@ spec:
clientIP:
timeoutSeconds: 10800
ports:
- port: 8080
# Port 80 exposé, route vers 8080 du container
- port: 80
targetPort: 8080
protocol: TCP
name: http
# Port 8080 pour compatibilité directe
- port: 8080
targetPort: 8080
protocol: TCP
name: http-direct
selector:
app: afterwork-api
app: mic-after-work-server-impl-quarkus-main

55
pom.xml
View File

@@ -13,7 +13,7 @@
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<quarkus.platform.version>3.16.3</quarkus.platform.version>
<quarkus.package.type>uber-jar</quarkus.package.type>
<quarkus.package.type>fast-jar</quarkus.package.type>
<skipITs>true</skipITs>
<surefire-plugin.version>3.5.0</surefire-plugin.version>
</properties>
@@ -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>
@@ -91,6 +100,16 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jsonb</artifactId>
</dependency>
<!-- Flyway pour les migrations SQL automatiques -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-flyway</artifactId>
</dependency>
<!-- Scheduler pour jobs planifiés (nettoyage stories, tokens, rappels événements) -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-scheduler</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@@ -102,11 +121,45 @@
<artifactId>bcrypt</artifactId>
<version>0.10.2</version>
</dependency>
<!-- Email Service pour réinitialisation de mot de passe -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-mailer</artifactId>
</dependency>
<!-- ============================================== -->
<!-- HEALTH CHECKS & OBSERVABILITY -->
<!-- ============================================== -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-micrometer</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-micrometer-registry-prometheus</artifactId>
</dependency>
<!-- ============================================== -->
<!-- TEST DEPENDENCIES -->
<!-- ============================================== -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5-mockito</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-security</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>

20
scripts/README.md Normal file
View File

@@ -0,0 +1,20 @@
# Scripts AfterWork
Scripts de déploiement et doutillage.
## deploy.ps1
Script PowerShell de déploiement production (build Maven, build Docker, push registry, déploiement Kubernetes).
**Exécution** (depuis la racine du projet) :
```powershell
.\scripts\deploy.ps1 -Action all -Version 1.0.0
.\scripts\deploy.ps1 -Action build # Build Maven + Docker
.\scripts\deploy.ps1 -Action push # Push vers registry
.\scripts\deploy.ps1 -Action deploy # Déploiement K8s
.\scripts\deploy.ps1 -Action status # Statut du déploiement
.\scripts\deploy.ps1 -Action rollback # Rollback
```
Le script détecte automatiquement la racine du projet (parent de `scripts/`).

View File

@@ -3,6 +3,7 @@
# ====================================================================
# Ce script automatise le processus de build et déploiement
# de l'API AfterWork sur le VPS via Kubernetes.
# Exécuter depuis la racine du projet ou depuis scripts/
# ====================================================================
param(
@@ -20,6 +21,10 @@ param(
$ErrorActionPreference = "Stop"
# Racine du projet (parent du dossier scripts)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = (Resolve-Path (Join-Path $ScriptDir "..")).Path
# Couleurs
function Write-Info { param($msg) Write-Host $msg -ForegroundColor Cyan }
function Write-Success { param($msg) Write-Host $msg -ForegroundColor Green }
@@ -42,6 +47,7 @@ Write-Host " - Version: $Version"
Write-Host " - Registry: $Registry"
Write-Host " - Image: $ImageName"
Write-Host " - Namespace: $Namespace"
Write-Host " - Racine projet: $ProjectRoot"
Write-Host ""
# ======================================================================
@@ -50,27 +56,31 @@ Write-Host ""
function Build-Application {
Write-Info "[1/5] Build Maven..."
$mavenArgs = "clean", "package", "-Dquarkus.package.type=uber-jar"
if ($SkipTests) {
$mavenArgs += "-DskipTests"
} else {
$mavenArgs += "-DtestFailureIgnore=true"
}
Push-Location $ProjectRoot
try {
$mavenArgs = "clean", "package", "-Dquarkus.package.type=uber-jar"
if ($SkipTests) {
$mavenArgs += "-DskipTests"
} else {
$mavenArgs += "-DtestFailureIgnore=true"
}
& mvn $mavenArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Erreur lors du build Maven"
exit 1
}
& mvn $mavenArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Erreur lors du build Maven"
exit 1
}
# Vérifier que le JAR existe
$jar = Get-ChildItem -Path "target" -Filter "*-runner.jar" | Select-Object -First 1
if (-not $jar) {
Write-Error "JAR runner non trouvé dans target/"
exit 1
}
$jar = Get-ChildItem -Path (Join-Path $ProjectRoot "target") -Filter "*-runner.jar" | Select-Object -First 1
if (-not $jar) {
Write-Error "JAR runner non trouvé dans target/"
exit 1
}
Write-Success "Build Maven réussi : $($jar.Name)"
Write-Success "Build Maven réussi : $($jar.Name)"
} finally {
Pop-Location
}
}
# ======================================================================
@@ -79,13 +89,19 @@ function Build-Application {
function Build-DockerImage {
Write-Info "[2/5] Build Docker Image..."
docker build -f Dockerfile.prod -t $ImageName -t $ImageLatest .
if ($LASTEXITCODE -ne 0) {
Write-Error "Erreur lors du build Docker"
exit 1
}
Push-Location $ProjectRoot
try {
$dockerDir = Join-Path $ProjectRoot "docker"
docker build -f (Join-Path $dockerDir "Dockerfile.prod") -t $ImageName -t $ImageLatest .
if ($LASTEXITCODE -ne 0) {
Write-Error "Erreur lors du build Docker"
exit 1
}
Write-Success "Image Docker créée : $ImageName"
Write-Success "Image Docker créée : $ImageName"
} finally {
Pop-Location
}
}
# ======================================================================
@@ -94,7 +110,6 @@ function Build-DockerImage {
function Push-ToRegistry {
Write-Info "[3/5] Push vers Registry..."
# Vérifier si on est connecté au registry
$loginTest = docker login $Registry 2>&1
if ($LASTEXITCODE -ne 0 -and -not $loginTest.ToString().Contains("Succeeded")) {
Write-Warning "Connexion au registry nécessaire..."
@@ -105,7 +120,6 @@ function Push-ToRegistry {
}
}
# Push des images
docker push $ImageName
if ($LASTEXITCODE -ne 0) {
Write-Error "Erreur lors du push de $ImageName"
@@ -127,20 +141,18 @@ function Push-ToRegistry {
function Deploy-ToKubernetes {
Write-Info "[4/5] Déploiement Kubernetes..."
# Vérifier que kubectl est disponible
$kubectlCheck = kubectl version --client 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Error "kubectl n'est pas installé ou configuré"
exit 1
}
# Créer le namespace si nécessaire
$k8sDir = Join-Path $ProjectRoot "kubernetes"
Write-Info "Création du namespace $Namespace..."
kubectl create namespace $Namespace --dry-run=client -o yaml | kubectl apply -f -
# Appliquer les manifests
Write-Info "Application des ConfigMaps et Secrets..."
kubectl apply -f kubernetes/afterwork-configmap.yaml
kubectl apply -f (Join-Path $k8sDir "afterwork-configmap.yaml")
if ($LASTEXITCODE -ne 0) {
Write-Warning "ConfigMap déjà existante ou erreur"
}
@@ -155,26 +167,26 @@ function Deploy-ToKubernetes {
}
}
kubectl apply -f kubernetes/afterwork-secrets.yaml
kubectl apply -f (Join-Path $k8sDir "afterwork-secrets.yaml")
if ($LASTEXITCODE -ne 0) {
Write-Error "Erreur lors de l'application des secrets"
exit 1
}
Write-Info "Déploiement de l'application..."
kubectl apply -f kubernetes/afterwork-deployment.yaml
kubectl apply -f (Join-Path $k8sDir "afterwork-deployment.yaml")
if ($LASTEXITCODE -ne 0) {
Write-Error "Erreur lors du déploiement"
exit 1
}
kubectl apply -f kubernetes/afterwork-service.yaml
kubectl apply -f (Join-Path $k8sDir "afterwork-service.yaml")
if ($LASTEXITCODE -ne 0) {
Write-Error "Erreur lors de la création du service"
exit 1
}
kubectl apply -f kubernetes/afterwork-ingress.yaml
kubectl apply -f (Join-Path $k8sDir "afterwork-ingress.yaml")
if ($LASTEXITCODE -ne 0) {
Write-Error "Erreur lors de la création de l'ingress"
exit 1
@@ -182,7 +194,6 @@ function Deploy-ToKubernetes {
Write-Success "Déploiement Kubernetes réussi"
# Attendre que le déploiement soit prêt
Write-Info "Attente du rollout..."
kubectl rollout status deployment/$AppName -n $Namespace --timeout=5m
if ($LASTEXITCODE -ne 0) {
@@ -196,19 +207,15 @@ function Deploy-ToKubernetes {
function Verify-Deployment {
Write-Info "[5/5] Vérification du déploiement..."
# Status des pods
Write-Info "Pods:"
kubectl get pods -n $Namespace -l app=$AppName
# Status du service
Write-Info "`nService:"
kubectl get svc -n $Namespace $AppName
# Status de l'ingress
Write-Info "`nIngress:"
kubectl get ingress -n $Namespace $AppName
# Test health check
Write-Info "`nTest Health Check..."
Start-Sleep -Seconds 5

View File

@@ -0,0 +1,44 @@
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;
/**
* Configuration OpenAPI pour l'API AfterWork.
*
* 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(
title = "AfterWork API",
version = "1.0.0",
description = "API REST pour l'application AfterWork - Gestion d'événements, réseaux sociaux et messagerie"
),
servers = {
@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

@@ -0,0 +1,171 @@
package com.lions.dev.config;
import com.lions.dev.entity.events.Events;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.establishment.EstablishmentSubscription;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.EstablishmentRepository;
import com.lions.dev.repository.EstablishmentSubscriptionRepository;
import com.lions.dev.repository.EventsRepository;
import com.lions.dev.repository.PasswordResetTokenRepository;
import com.lions.dev.repository.StoryRepository;
import com.lions.dev.service.EmailService;
import com.lions.dev.service.NotificationService;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.jboss.logging.Logger;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Set;
/**
* Jobs planifiés (Quarkus Scheduler) pour :
* - Nettoyage des stories expirées (24h)
* - Nettoyage des tokens de reset password expirés
* - Expiration des abonnements établissements
* - Désactivation des établissements non payés
* - Rappels d'événements (J-1, H-1)
*/
@ApplicationScoped
public class ScheduledJobs {
private static final Logger LOG = Logger.getLogger(ScheduledJobs.class);
@Inject
StoryRepository storyRepository;
@Inject
PasswordResetTokenRepository passwordResetTokenRepository;
@Inject
EstablishmentSubscriptionRepository subscriptionRepository;
@Inject
EstablishmentRepository establishmentRepository;
@Inject
EventsRepository eventsRepository;
@Inject
NotificationService notificationService;
@Inject
EmailService emailService;
/** Nettoyage des stories expirées : toutes les heures. */
@Scheduled(cron = "0 0 * * * ?")
@Transactional
public void deactivateExpiredStories() {
int count = storyRepository.deactivateExpiredStories();
if (count > 0) {
LOG.info("[ScheduledJobs] Stories expirées désactivées : " + count);
}
}
/** Nettoyage des tokens de reset password expirés : tous les jours à 3h. */
@Scheduled(cron = "0 0 3 * * ?")
@Transactional
public void deleteExpiredPasswordResetTokens() {
long count = passwordResetTokenRepository.deleteExpiredTokens();
if (count > 0) {
LOG.info("[ScheduledJobs] Tokens de reset password supprimés : " + count);
}
}
/** Expiration des abonnements et désactivation des établissements non payés : toutes les heures. */
@Scheduled(cron = "0 5 * * * ?")
@Transactional
public void expireSubscriptionsAndDisableEstablishments() {
List<EstablishmentSubscription> expired = subscriptionRepository.findExpiredActiveSubscriptions();
for (EstablishmentSubscription sub : expired) {
sub.setStatus(EstablishmentSubscription.STATUS_EXPIRED);
subscriptionRepository.persist(sub);
Establishment est = establishmentRepository.findById(sub.getEstablishmentId());
if (est != null && Boolean.TRUE.equals(est.getIsActive())) {
est.setIsActive(false);
establishmentRepository.persist(est);
LOG.info("[ScheduledJobs] Établissement désactivé (abonnement expiré) : " + est.getId());
}
}
if (!expired.isEmpty()) {
LOG.info("[ScheduledJobs] Abonnements expirés traités : " + expired.size());
}
}
/** Rappels d'événements J-1 (dans ~24h) et H-1 (dans ~1h) : toutes les 15 minutes. */
@Scheduled(cron = "0 */15 * * * ?")
@Transactional
public void sendEventReminders() {
LocalDateTime now = LocalDateTime.now();
// Fenêtre J-1 : début entre 23h30 et 24h30
LocalDateTime j1From = now.plus(23, ChronoUnit.HOURS).plus(30, ChronoUnit.MINUTES);
LocalDateTime j1To = now.plus(24, ChronoUnit.HOURS).plus(30, ChronoUnit.MINUTES);
List<Events> eventsJ1 = eventsRepository.findEventsStartingBetween(j1From, j1To);
for (Events event : eventsJ1) {
sendReminderToParticipants(event, "J-1", "demain");
}
// Fenêtre H-1 : début entre 50 min et 1h10
LocalDateTime h1From = now.plus(50, ChronoUnit.MINUTES);
LocalDateTime h1To = now.plus(70, ChronoUnit.MINUTES);
List<Events> eventsH1 = eventsRepository.findEventsStartingBetween(h1From, h1To);
for (Events event : eventsH1) {
sendReminderToParticipants(event, "H-1", "dans 1 heure");
}
}
/** Avertissement expiration abonnement (J-3) : email au manager. */
@Scheduled(cron = "0 0 9 * * ?")
@Transactional
public void sendSubscriptionExpirationWarningEmails() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime in3DaysStart = now.plusDays(3);
LocalDateTime in3DaysEnd = now.plusDays(3).plusHours(23).plusMinutes(59);
List<EstablishmentSubscription> expiring = subscriptionRepository.findActiveSubscriptionsExpiringBetween(in3DaysStart, in3DaysEnd);
for (EstablishmentSubscription sub : expiring) {
Establishment est = establishmentRepository.findById(sub.getEstablishmentId());
if (est == null) continue;
Users manager = est.getManager();
if (manager == null || manager.getEmail() == null) continue;
try {
emailService.sendSubscriptionExpirationWarningEmail(
manager.getEmail(),
manager.getFirstName(),
est.getName(),
sub.getExpiresAt()
);
} catch (Exception e) {
LOG.warn("[ScheduledJobs] Email expiration abonnement échoué pour " + est.getId() + ": " + e.getMessage());
}
}
if (!expiring.isEmpty()) {
LOG.info("[ScheduledJobs] Emails avertissement expiration envoyés : " + expiring.size());
}
}
private void sendReminderToParticipants(Events event, String reminderType, String whenText) {
Set<Users> participants = event.getParticipants();
if (participants == null) return;
Users creator = event.getCreator();
String title = "Rappel événement " + reminderType + " : " + event.getTitle();
String message = "L'événement « " + event.getTitle() + " » commence " + whenText + ".";
for (Users participant : participants) {
if (participant == null || participant.getId() == null) continue;
try {
notificationService.createNotification(title, message, "reminder", participant.getId(), event.getId());
} catch (Exception e) {
LOG.warn("[ScheduledJobs] Impossible de créer rappel pour participant " + participant.getId() + ": " + e.getMessage());
}
}
if (creator != null && creator.getId() != null && (participants.isEmpty() || !participants.stream().anyMatch(p -> p.getId().equals(creator.getId())))) {
try {
notificationService.createNotification(title, message, "reminder", creator.getId(), event.getId());
} catch (Exception e) {
LOG.warn("[ScheduledJobs] Impossible de créer rappel pour créateur: " + e.getMessage());
}
}
}
}

View File

@@ -0,0 +1,59 @@
package com.lions.dev.config;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.UsersRepository;
import com.lions.dev.util.UserRoles;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
/**
* Crée le super administrateur au démarrage de l'application si aucun n'existe.
* Email et mot de passe configurables (variables d'environnement en production).
*/
@ApplicationScoped
public class SuperAdminStartup {
private static final Logger LOG = Logger.getLogger(SuperAdminStartup.class);
@Inject
UsersRepository usersRepository;
@ConfigProperty(name = "afterwork.super-admin.email", defaultValue = "superadmin@afterwork.lions.dev")
String superAdminEmail;
@ConfigProperty(name = "afterwork.super-admin.password", defaultValue = "SuperAdmin2025!")
String superAdminPassword;
@ConfigProperty(name = "afterwork.super-admin.first-name", defaultValue = "Super")
String superAdminFirstName;
@ConfigProperty(name = "afterwork.super-admin.last-name", defaultValue = "Administrator")
String superAdminLastName;
@Transactional
void onStart(@Observes StartupEvent event) {
if (usersRepository.findByEmail(superAdminEmail).isPresent()) {
LOG.info("Super administrateur déjà présent (email: " + superAdminEmail + "). Aucune création.");
return;
}
Users superAdmin = new Users();
superAdmin.setFirstName(superAdminFirstName);
superAdmin.setLastName(superAdminLastName);
superAdmin.setEmail(superAdminEmail);
superAdmin.setPassword(superAdminPassword);
superAdmin.setRole(UserRoles.SUPER_ADMIN);
superAdmin.setProfileImageUrl("https://placehold.co/150x150.png");
superAdmin.setBio("Super administrateur AfterWork");
superAdmin.setLoyaltyPoints(0);
superAdmin.setVerified(true);
usersRepository.persist(superAdmin);
LOG.info("Super administrateur créé au démarrage : " + superAdminEmail + " (role: " + UserRoles.SUPER_ADMIN + ")");
}
}

View File

@@ -1,18 +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);
System.out.println("[ERROR] Exception déclenchée : " + message);
}
}

View File

@@ -15,7 +15,6 @@ public class Failures {
*/
public Failures(String failureMessage) {
this.failureMessage = failureMessage;
System.out.println("[FAILURE] Échec détecté : " + failureMessage);
}
/**

View File

@@ -1,35 +1,49 @@
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) {
logger.warn("BadRequestException intercepted: " + exception.getMessage());
return buildResponse(Response.Status.BAD_REQUEST, exception.getMessage());
} 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());
@@ -50,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

@@ -16,7 +16,6 @@ public class BadRequestException extends WebApplicationException {
*/
public BadRequestException(String message) {
super(message, Response.Status.BAD_REQUEST);
System.out.println("[ERROR] Requête invalide : " + message);
}
}

View File

@@ -16,6 +16,5 @@ public class NotFoundException extends WebApplicationException {
*/
public NotFoundException(String message) {
super(message, Response.Status.NOT_FOUND);
System.out.println("[ERROR] Ressource non trouvée : " + message);
}
}

View File

@@ -13,6 +13,5 @@ public class ServerException extends RuntimeException {
*/
public ServerException(String message) {
super(message);
System.out.println("[ERROR] Erreur serveur : " + message);
}
}

View File

@@ -16,6 +16,5 @@ public class UnauthorizedException extends WebApplicationException {
*/
public UnauthorizedException(String message) {
super(message, Response.Status.UNAUTHORIZED);
System.out.println("[ERROR] Accès non autorisé : " + message);
}
}

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

@@ -0,0 +1,19 @@
package com.lions.dev.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public class PasswordResetRequest {
@NotBlank(message = "L'email est obligatoire")
@Email(message = "Format d'email invalide")
private String email;
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}

View File

@@ -0,0 +1,66 @@
package com.lions.dev.dto.request.booking;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
/**
* DTO de création de réservation (aligné frontend).
*/
public class ReservationCreateRequestDTO {
@NotNull(message = "userId est obligatoire")
private UUID userId;
@NotNull(message = "establishmentId est obligatoire")
private UUID establishmentId;
/** Date/heure de réservation (ISO-8601 ou timestamp). */
private String reservationDate;
@Min(1)
private int numberOfPeople = 1;
private String notes;
public UUID getUserId() {
return userId;
}
public void setUserId(UUID userId) {
this.userId = userId;
}
public UUID getEstablishmentId() {
return establishmentId;
}
public void setEstablishmentId(UUID establishmentId) {
this.establishmentId = establishmentId;
}
public String getReservationDate() {
return reservationDate;
}
public void setReservationDate(String reservationDate) {
this.reservationDate = reservationDate;
}
public int getNumberOfPeople() {
return numberOfPeople;
}
public void setNumberOfPeople(int numberOfPeople) {
this.numberOfPeople = numberOfPeople;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
}

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,27 @@
package com.lions.dev.dto.request.establishment;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Requête pour initier un paiement Wave (droits d'accès établissement).
*/
@Getter
@Setter
@NoArgsConstructor
public class InitiateSubscriptionRequestDTO {
/** Plan : MONTHLY, YEARLY */
@NotBlank(message = "Le plan est obligatoire")
@Pattern(regexp = "MONTHLY|YEARLY", message = "Plan invalide. Valeurs : MONTHLY, YEARLY")
private String plan;
/** Numéro de téléphone client au format international (ex. 221771234567). */
@NotBlank(message = "Le numéro de téléphone client est obligatoire pour Wave")
@Size(max = 25, message = "Le numéro de téléphone ne peut pas dépasser 25 caractères")
private String clientPhone;
}

View File

@@ -6,6 +6,7 @@ import lombok.Setter;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import org.jboss.logging.Logger;
/**
* DTO pour la création d'un événement.
@@ -61,7 +62,6 @@ public class EventCreateRequestDTO {
private String location;
public EventCreateRequestDTO() {
System.out.println("[LOG] DTO de requête de création d'événement initialisé.");
}
/**

View File

@@ -2,6 +2,7 @@ package com.lions.dev.dto.request.events;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
import org.jboss.logging.Logger;
/**
* DTO pour la suppression d'un événement.
@@ -15,6 +16,5 @@ public class EventDeleteRequestDTO {
private UUID eventId; // ID de l'événement à supprimer
public EventDeleteRequestDTO() {
System.out.println("[LOG] DTO de requête de suppression d'événement initialisé.");
}
}

View File

@@ -2,6 +2,7 @@ package com.lions.dev.dto.request.events;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
import org.jboss.logging.Logger;
/**
* DTO pour lire un événement par son ID.
@@ -15,6 +16,5 @@ public class EventReadOneByIdRequestDTO {
private UUID eventId; // ID de l'événement à lire
public EventReadOneByIdRequestDTO() {
System.out.println("[LOG] DTO de requête de lecture d'événement initialisé.");
}
}

View File

@@ -1,5 +1,6 @@
package com.lions.dev.dto.request.friends;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
@@ -15,7 +16,10 @@ import java.util.UUID;
@NoArgsConstructor
public class FriendshipCreateOneRequestDTO {
@NotNull(message = "L'identifiant de l'utilisateur est requis")
private UUID userId; // ID de l'utilisateur qui envoie la demande
@NotNull(message = "L'identifiant de l'ami est requis")
private UUID friendId; // ID de l'utilisateur qui reçoit la demande
/**

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

@@ -6,6 +6,7 @@ import jakarta.validation.constraints.Size;
import java.util.UUID;
import lombok.Getter;
import lombok.Setter;
import org.jboss.logging.Logger;
/**
* DTO pour la création d'un post social.
@@ -29,7 +30,6 @@ public class SocialPostCreateRequestDTO {
private String imageUrl; // URL de l'image (optionnel)
public SocialPostCreateRequestDTO() {
System.out.println("[LOG] DTO de requête de création de post social initialisé.");
}
}

View File

@@ -0,0 +1,25 @@
package com.lions.dev.dto.request.social;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* DTO pour la mise à jour d'un post social.
*
* Utilisé dans les requêtes PUT /posts/{id} avec un body JSON
* (content, imageUrl) envoyé par le client Flutter.
*/
@Getter
@Setter
public class SocialPostUpdateRequestDTO {
@Size(max = 2000, message = "Le contenu ne peut pas dépasser 2000 caractères.")
private String content;
@Size(max = 500, message = "L'URL de l'image ne peut pas dépasser 500 caractères.")
private String imageUrl;
public SocialPostUpdateRequestDTO() {
}
}

View File

@@ -7,6 +7,7 @@ import jakarta.validation.constraints.Size;
import java.util.UUID;
import lombok.Getter;
import lombok.Setter;
import org.jboss.logging.Logger;
/**
* DTO pour la création d'une story.
@@ -34,6 +35,5 @@ public class StoryCreateRequestDTO {
private Integer durationSeconds; // Durée en secondes (optionnel, pour les vidéos)
public StoryCreateRequestDTO() {
System.out.println("[LOG] DTO de requête de création de story initialisé.");
}
}

View File

@@ -0,0 +1,26 @@
package com.lions.dev.dto.request.users;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* DTO pour l'attribution d'un rôle à un utilisateur (opération réservée au super admin).
*/
@Getter
@Setter
@NoArgsConstructor
public class AssignRoleRequestDTO {
private static final String ROLE_PATTERN = "SUPER_ADMIN|ADMIN|MANAGER|USER";
@NotBlank(message = "Le rôle est obligatoire")
@Pattern(regexp = ROLE_PATTERN, message = "Rôle invalide. Valeurs autorisées : SUPER_ADMIN, ADMIN, MANAGER, USER")
private String role;
public AssignRoleRequestDTO(String role) {
this.role = role;
}
}

View File

@@ -0,0 +1,22 @@
package com.lions.dev.dto.request.users;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* DTO pour forcer l'activation ou la suspension d'un utilisateur (opération réservée au super admin).
*/
@Getter
@Setter
@NoArgsConstructor
public class SetUserActiveRequestDTO {
@NotNull(message = "Le champ active est obligatoire")
private Boolean active;
public SetUserActiveRequestDTO(Boolean active) {
this.active = active;
}
}

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,20 @@
package com.lions.dev.dto.response.admin;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AdminRevenueResponseDTO {
/** Revenus totaux (abonnements actifs * prix). */
private BigDecimal totalRevenueXof;
/** Nombre d'abonnements actifs. */
private long activeSubscriptionsCount;
}

View File

@@ -0,0 +1,26 @@
package com.lions.dev.dto.response.admin;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ManagerStatsResponseDTO {
private UUID userId;
private String email;
private String firstName;
private String lastName;
/** ACTIVE ou SUSPENDED (isActive). */
private String status;
private LocalDateTime subscriptionExpiresAt;
private UUID establishmentId;
private String establishmentName;
}

View File

@@ -0,0 +1,45 @@
package com.lions.dev.dto.response.booking;
import com.lions.dev.entity.booking.Booking;
import lombok.Getter;
/**
* DTO de réponse pour une réservation (aligné sur le frontend Flutter ReservationModel).
* eventId/eventTitle : pour les réservations d'établissement, eventTitle = nom de l'établissement, eventId = null.
*/
@Getter
public class ReservationResponseDTO {
private final String id;
private final String userId;
private final String userFullName;
private final String eventId; // null pour résa établissement
private final String eventTitle; // nom événement ou établissement
private final String reservationDate; // ISO-8601
private final int numberOfPeople;
private final String status; // PENDING, CONFIRMED, CANCELLED, COMPLETED
private final String establishmentId;
private final String establishmentName;
private final String notes;
private final String createdAt; // ISO-8601
public ReservationResponseDTO(Booking booking) {
this.id = booking.getId() != null ? booking.getId().toString() : null;
this.userId = booking.getUser() != null && booking.getUser().getId() != null
? booking.getUser().getId().toString() : null;
this.userFullName = booking.getUser() != null
? (booking.getUser().getFirstName() + " " + booking.getUser().getLastName()).trim()
: "";
this.eventId = null; // Réservation établissement sans événement
this.eventTitle = booking.getEstablishment() != null ? booking.getEstablishment().getName() : "";
this.reservationDate = booking.getReservationTime() != null
? booking.getReservationTime().toString() : null;
this.numberOfPeople = booking.getGuestCount() != null ? booking.getGuestCount() : 1;
this.status = booking.getStatus() != null ? booking.getStatus().toLowerCase() : "pending";
this.establishmentId = booking.getEstablishment() != null && booking.getEstablishment().getId() != null
? booking.getEstablishment().getId().toString() : null;
this.establishmentName = booking.getEstablishment() != null ? booking.getEstablishment().getName() : null;
this.notes = booking.getSpecialRequests();
this.createdAt = booking.getCreatedAt() != null ? booking.getCreatedAt().toString() : null;
}
}

View File

@@ -26,6 +26,8 @@ public class ConversationResponseDTO {
private LocalDateTime lastMessageTimestamp;
private int unreadCount;
private boolean isTyping;
/** Indique si le participant (l'autre utilisateur) est actuellement en ligne (WebSocket notifications). */
private boolean participantIsOnline;
/**
* Constructeur depuis une entité Conversation.
@@ -44,6 +46,7 @@ public class ConversationResponseDTO {
this.participantFirstName = otherUser.getFirstName();
this.participantLastName = otherUser.getLastName();
this.participantProfileImageUrl = otherUser.getProfileImageUrl();
this.participantIsOnline = otherUser.isOnline();
}
this.lastMessage = conversation.getLastMessageContent();

View File

@@ -0,0 +1,46 @@
package com.lions.dev.dto.response.establishment;
import com.lions.dev.entity.establishment.BusinessHours;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO pour renvoyer les horaires d'ouverture d'un établissement.
* Conforme à l'architecture AfterWork v2.0.
*/
@Getter
public class BusinessHoursResponseDTO {
private String id;
private String establishmentId;
private String dayOfWeek;
private String openTime;
private String closeTime;
private Boolean isClosed;
private Boolean isException;
private LocalDateTime exceptionDate;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* Constructeur qui transforme une entité BusinessHours en DTO.
* Utilise establishmentId fourni pour éviter LazyInitializationException.
*
* @param businessHours L'entité à convertir.
* @param establishmentId ID de l'établissement (déjà connu par l'appelant).
*/
public BusinessHoursResponseDTO(BusinessHours businessHours, UUID establishmentId) {
this.id = businessHours.getId() != null ? businessHours.getId().toString() : null;
this.establishmentId = establishmentId != null ? establishmentId.toString() : null;
this.dayOfWeek = businessHours.getDayOfWeek();
this.openTime = businessHours.getOpenTime();
this.closeTime = businessHours.getCloseTime();
this.isClosed = businessHours.getIsClosed();
this.isException = businessHours.getIsException();
this.exceptionDate = businessHours.getExceptionDate();
this.createdAt = businessHours.getCreatedAt();
this.updatedAt = businessHours.getUpdatedAt();
}
}

View File

@@ -0,0 +1,44 @@
package com.lions.dev.dto.response.establishment;
import com.lions.dev.entity.establishment.EstablishmentAmenity;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO pour renvoyer un équipement d'établissement (avec nom du type).
* Conforme à l'architecture AfterWork v2.0.
* Utilise des paramètres explicites pour éviter LazyInitializationException.
*/
@Getter
public class EstablishmentAmenityResponseDTO {
private String establishmentId;
private String amenityId;
private String amenityName;
private String category;
private String icon;
private String details;
private LocalDateTime createdAt;
/**
* Constructeur qui transforme une entité EstablishmentAmenity en DTO.
* Les champs du type (name, category, icon) sont passés en paramètres car ils peuvent
* provenir d'un JOIN FETCH déjà résolu ou être null si le type n'est pas chargé.
*
* @param ea L'entité à convertir.
* @param amenityName Nom du type d'équipement (ex: "WiFi", "Parking").
* @param category Catégorie du type (ex: "Comfort", "Accessibility").
* @param icon Nom de l'icône (ex: "wifi", "parking").
*/
public EstablishmentAmenityResponseDTO(EstablishmentAmenity ea, String amenityName, String category, String icon) {
this.establishmentId = ea.getEstablishmentId() != null ? ea.getEstablishmentId().toString() : null;
this.amenityId = ea.getAmenityId() != null ? ea.getAmenityId().toString() : null;
this.amenityName = amenityName;
this.category = category;
this.icon = icon;
this.details = ea.getDetails();
this.createdAt = ea.getCreatedAt();
}
}

View File

@@ -41,6 +41,11 @@ public class EstablishmentResponseDTO {
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** Nombre maximum de places dans l'établissement (optionnel). */
private Integer capacity;
/** Places restantes (capacity - participants des événements ouverts/à venir). Null si capacity non défini. */
private Integer remainingPlaces;
// Champs dépréciés (v1.0) - conservés pour compatibilité
/**
* @deprecated Utiliser {@link #averageRating} à la place.
@@ -60,12 +65,6 @@ public class EstablishmentResponseDTO {
@Deprecated
private String imageUrl;
/**
* @deprecated Supprimé en v2.0.
*/
@Deprecated
private Integer capacity;
/**
* @deprecated Supprimé en v2.0 (utiliser establishment_amenities à la place).
*/
@@ -130,14 +129,26 @@ public class EstablishmentResponseDTO {
this.createdAt = establishment.getCreatedAt();
this.updatedAt = establishment.getUpdatedAt();
this.capacity = establishment.getCapacity();
this.remainingPlaces = null; // Sera renseigné via le constructeur avec occupiedPlaces si besoin
// Compatibilité v1.0 - valeurs null pour les champs dépréciés
this.rating = null;
this.email = null;
this.imageUrl = null;
this.capacity = null;
this.amenities = null;
this.openingHours = null;
this.totalRatingsCount = this.totalReviewsCount; // Alias pour compatibilité
}
/**
* Constructeur avec calcul des places restantes (capacity - participants des événements ouverts/à venir).
*/
public EstablishmentResponseDTO(Establishment establishment, Integer occupiedPlaces) {
this(establishment);
if (this.capacity != null && occupiedPlaces != null) {
this.remainingPlaces = Math.max(0, this.capacity - occupiedPlaces);
}
}
}

View File

@@ -0,0 +1,22 @@
package com.lions.dev.dto.response.establishment;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Réponse après initiation d'un paiement Wave (URL de redirection).
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class InitiateSubscriptionResponseDTO {
private String paymentUrl;
private String waveSessionId;
private Integer amountXof;
private String plan;
private String status;
}

View File

@@ -36,6 +36,9 @@ public class EventCreateResponseDTO {
private Boolean waitlistEnabled; // v2.0 - Indique si la liste d'attente est activée
private Integer maxParticipants; // Nombre maximum de participants autorisés
private Integer participationFee; // Frais de participation en centimes
private Integer participantsCount; // ✅ Nombre actuel de participants (event.getParticipants().size())
private Integer commentsCount; // ✅ Nombre de commentaires (event.getComments().size())
private Integer sharesCount; // ✅ Nombre de partages (event.getShares().size())
private Long reactionsCount; // ✅ Nombre de réactions (utilisateurs qui ont cet événement en favori)
private Boolean isFavorite; // ✅ Indique si l'utilisateur actuel a cet événement en favori (optionnel, dépend du contexte)
@@ -68,7 +71,10 @@ public class EventCreateResponseDTO {
this.waitlistEnabled = event.getWaitlistEnabled(); // v2.0
this.maxParticipants = event.getMaxParticipants();
this.participationFee = event.getParticipationFee();
this.participantsCount = event.getParticipants() != null ? event.getParticipants().size() : 0;
this.commentsCount = event.getComments() != null ? event.getComments().size() : 0;
this.sharesCount = event.getShares() != null ? event.getShares().size() : 0;
// ✅ Calculer reactionsCount si usersRepository est fourni
if (usersRepository != null) {
this.reactionsCount = usersRepository.countUsersWithFavoriteEvent(event.getId());

View File

@@ -27,8 +27,24 @@ public class FriendshipCreateOneResponseDTO {
/**
* Constructeur pour mapper l'entité `Friendship` à ce DTO.
* Utilise les IDs fournis pour éviter LazyInitializationException sur user/friend.
*
* @param friendship L'entité `Friendship` à convertir en DTO.
* @param userId ID de l'utilisateur qui envoie la demande (déjà chargé).
* @param friendId ID de l'utilisateur qui reçoit la demande (déjà chargé).
*/
public FriendshipCreateOneResponseDTO(Friendship friendship, UUID userId, UUID friendId) {
this.id = friendship.getId();
this.userId = userId;
this.friendId = friendId;
this.status = friendship.getStatus();
this.createdAt = friendship.getCreatedAt();
this.updatedAt = friendship.getUpdatedAt();
}
/**
* Constructeur pour mapper l'entité `Friendship` à ce DTO (charge les associations lazy).
* Préférer {@link #FriendshipCreateOneResponseDTO(Friendship, UUID, UUID)} en fin de transaction.
*/
public FriendshipCreateOneResponseDTO(Friendship friendship) {
this.id = friendship.getId();

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

@@ -31,6 +31,8 @@ public class SocialPostResponseDTO {
private int likesCount;
private int commentsCount;
private int sharesCount;
/** Indique si l'utilisateur courant a liké ce post (attendu par le client Flutter). */
private boolean isLikedByCurrentUser = false;
/**
* Constructeur à partir d'une entité SocialPost (v2.0).
@@ -51,6 +53,7 @@ public class SocialPostResponseDTO {
this.likesCount = post.getLikesCount();
this.commentsCount = post.getCommentsCount();
this.sharesCount = post.getSharesCount();
this.isLikedByCurrentUser = false; // À enrichir si on passe le userId courant (table post_likes)
}
}
}

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

@@ -3,6 +3,7 @@ package com.lions.dev.dto.response.users;
import com.lions.dev.entity.users.Users;
import java.util.UUID;
import lombok.Getter;
import org.jboss.logging.Logger;
/**
* DTO pour renvoyer les informations d'un utilisateur.
@@ -21,6 +22,7 @@ public class UserCreateResponseDTO {
private String lastName; // v2.0 - Nom de famille de l'utilisateur
private String email; // Email de l'utilisateur
private String role; // Rôle de l'utilisateur
private Boolean isActive = true; // false = manager suspendu (abonnement expiré)
private String profileImageUrl; // URL de l'image de profil de l'utilisateur
private String bio; // v2.0 - Biographie courte
private Integer loyaltyPoints; // v2.0 - Points de fidélité
@@ -56,6 +58,7 @@ public class UserCreateResponseDTO {
this.lastName = user.getLastName(); // v2.0
this.email = user.getEmail();
this.role = user.getRole();
this.isActive = user.isActive();
this.profileImageUrl = user.getProfileImageUrl();
this.bio = user.getBio(); // v2.0
this.loyaltyPoints = user.getLoyaltyPoints(); // v2.0
@@ -66,6 +69,5 @@ public class UserCreateResponseDTO {
this.nom = this.lastName;
this.prenoms = this.firstName;
System.out.println("[LOG] DTO créé pour l'utilisateur : " + this.email);
}
}

View File

@@ -1,4 +1,4 @@
package com.lions.dev.dto;
package com.lions.dev.dto.response.users;
import com.lions.dev.entity.users.Users; // Import de l'entité Users
import java.util.UUID;

View File

@@ -37,7 +37,6 @@ public abstract class BaseEntity {
protected void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
System.out.println("[LOG] Nouvelle entité créée avec ID : " + this.id + " à " + this.createdAt);
}
/**
@@ -47,6 +46,5 @@ public abstract class BaseEntity {
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
System.out.println("[LOG] Entité mise à jour avec ID : " + this.id + " à " + this.updatedAt);
}
}

View File

@@ -0,0 +1,79 @@
package com.lions.dev.entity.auth;
import com.lions.dev.entity.users.Users;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Entité représentant un token de réinitialisation de mot de passe.
*
* Le token est valide pendant 1 heure après sa création.
*/
@Entity
@Table(name = "password_reset_tokens")
@Getter
@Setter
@NoArgsConstructor
public class PasswordResetToken {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@Column(name = "token", nullable = false, unique = true, length = 64)
private String token;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private Users user;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Column(name = "used", nullable = false)
private boolean used = false;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* Crée un nouveau token de réinitialisation pour un utilisateur.
* Le token expire après 1 heure.
*
* @param user L'utilisateur pour lequel créer le token
*/
public PasswordResetToken(Users user) {
this.user = user;
this.token = generateToken();
this.createdAt = LocalDateTime.now();
this.expiresAt = this.createdAt.plusHours(1);
this.used = false;
}
/**
* Génère un token aléatoire sécurisé.
*/
private String generateToken() {
return UUID.randomUUID().toString().replace("-", "") +
UUID.randomUUID().toString().replace("-", "").substring(0, 32);
}
/**
* Vérifie si le token est valide (non expiré et non utilisé).
*/
public boolean isValid() {
return !used && LocalDateTime.now().isBefore(expiresAt);
}
/**
* Marque le token comme utilisé.
*/
public void markAsUsed() {
this.used = true;
}
}

View File

@@ -62,7 +62,6 @@ public class Conversation extends BaseEntity {
this.user1 = user1;
this.user2 = user2;
this.lastMessageTimestamp = LocalDateTime.now();
System.out.println("[LOG] Conversation créée entre " + user1.getEmail() + " et " + user2.getEmail());
}
/**
@@ -80,7 +79,6 @@ public class Conversation extends BaseEntity {
unreadCountUser1++;
}
System.out.println("[LOG] Dernier message mis à jour pour la conversation " + this.getId());
}
/**
@@ -89,10 +87,8 @@ public class Conversation extends BaseEntity {
public void markAllAsReadForUser(Users user) {
if (user != null && user1 != null && user.getId().equals(user1.getId())) {
unreadCountUser1 = 0;
System.out.println("[LOG] Messages marqués comme lus pour user1 dans la conversation " + this.getId());
} else if (user != null && user2 != null && user.getId().equals(user2.getId())) {
unreadCountUser2 = 0;
System.out.println("[LOG] Messages marqués comme lus pour user2 dans la conversation " + this.getId());
}
}

View File

@@ -57,7 +57,6 @@ public class Message extends BaseEntity {
this.messageType = "text";
this.isRead = false;
this.isDelivered = false;
System.out.println("[LOG] Message créé par " + sender.getEmail() + " dans la conversation " + conversation.getId());
}
/**
@@ -66,7 +65,6 @@ public class Message extends BaseEntity {
public void markAsRead() {
if (!this.isRead) {
this.isRead = true;
System.out.println("[LOG] Message " + this.getId() + " marqué comme lu");
}
}
@@ -76,7 +74,6 @@ public class Message extends BaseEntity {
public void markAsDelivered() {
if (!this.isDelivered) {
this.isDelivered = true;
System.out.println("[LOG] Message " + this.getId() + " marqué comme délivré");
}
}

View File

@@ -53,15 +53,7 @@ public class Comment extends BaseEntity {
this.user = user;
this.event = event;
this.text = text;
this.commentDate =
LocalDateTime.now(); // La date est définie automatiquement lors de la création
System.out.println(
"[LOG] Nouveau commentaire ajouté par "
+ user.getEmail()
+ " sur l'événement : "
+ event.getTitle()
+ " - Texte : "
+ text);
this.commentDate = LocalDateTime.now();
}
/**
@@ -73,14 +65,7 @@ public class Comment extends BaseEntity {
* @param newText Le nouveau texte du commentaire.
*/
public void updateComment(String newText) {
System.out.println(
"[LOG] Modification du commentaire de "
+ user.getEmail()
+ " sur l'événement : "
+ event.getTitle()
+ " - Nouveau texte : "
+ newText);
this.text = newText;
this.commentDate = LocalDateTime.now(); // Mise à jour de la date de modification
this.commentDate = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,37 @@
package com.lions.dev.entity.establishment;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Entité représentant un type d'équipement (référence amenity_types).
* Utilisée pour afficher le nom/catégorie des équipements d'un établissement.
*/
@Entity
@Table(name = "amenity_types")
@Getter
@Setter
@NoArgsConstructor
public class AmenityType {
@Id
@Column(name = "id", nullable = false)
private UUID id;
@Column(name = "name", nullable = false, unique = true, length = 100)
private String name;
@Column(name = "category", length = 50)
private String category;
@Column(name = "icon", length = 50)
private String icon;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt = LocalDateTime.now();
}

View File

@@ -62,12 +62,20 @@ public class Establishment extends BaseEntity {
@Column(name = "verification_status", nullable = false)
private String verificationStatus = "PENDING"; // Statut de vérification: PENDING, VERIFIED, REJECTED (v2.0)
/** true = visible dans l'app ; false = masqué (abonnement inactif / suspension Wave). Par défaut true. */
@Column(name = "is_active", nullable = false)
private Boolean isActive = true;
@Column(name = "latitude")
private Double latitude; // Latitude pour la géolocalisation
@Column(name = "longitude")
private Double longitude; // Longitude pour la géolocalisation
/** Nombre maximum de places dans l'établissement (optionnel). Utilisé pour le calcul des places restantes. */
@Column(name = "capacity")
private Integer capacity;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "manager_id", nullable = false)
private Users manager; // Le responsable de l'établissement

View File

@@ -45,6 +45,10 @@ public class EstablishmentAmenity {
@JoinColumn(name = "establishment_id", insertable = false, updatable = false)
private Establishment establishment; // L'établissement concerné
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "amenity_id", insertable = false, updatable = false)
private AmenityType amenityType; // Type d'équipement (nom, catégorie, icône)
/**
* Constructeur pour créer une liaison établissement-équipement.
*
@@ -67,32 +71,3 @@ public class EstablishmentAmenity {
}
}
/**
* Classe composite pour la clé primaire de EstablishmentAmenity.
*/
@Getter
@Setter
@NoArgsConstructor
class EstablishmentAmenityId implements java.io.Serializable {
private UUID establishmentId;
private UUID amenityId;
public EstablishmentAmenityId(UUID establishmentId, UUID amenityId) {
this.establishmentId = establishmentId;
this.amenityId = amenityId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
EstablishmentAmenityId that = (EstablishmentAmenityId) o;
return establishmentId.equals(that.establishmentId) && amenityId.equals(that.amenityId);
}
@Override
public int hashCode() {
return establishmentId.hashCode() + amenityId.hashCode();
}
}

View File

@@ -0,0 +1,38 @@
package com.lions.dev.entity.establishment;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
import java.util.UUID;
/**
* Classe composite pour la clé primaire de EstablishmentAmenity.
*/
@Getter
@Setter
@NoArgsConstructor
public class EstablishmentAmenityId implements Serializable {
private UUID establishmentId;
private UUID amenityId;
public EstablishmentAmenityId(UUID establishmentId, UUID amenityId) {
this.establishmentId = establishmentId;
this.amenityId = amenityId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
EstablishmentAmenityId that = (EstablishmentAmenityId) o;
return establishmentId.equals(that.establishmentId) && amenityId.equals(that.amenityId);
}
@Override
public int hashCode() {
return establishmentId.hashCode() + amenityId.hashCode();
}
}

View File

@@ -0,0 +1,48 @@
package com.lions.dev.entity.establishment;
import com.lions.dev.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.UUID;
/**
* Enregistrement d'un paiement Wave pour un établissement (droits d'accès).
*/
@Entity
@Table(name = "establishment_payments")
@Getter
@Setter
@NoArgsConstructor
public class EstablishmentPayment extends BaseEntity {
@Column(name = "establishment_id", nullable = false)
private UUID establishmentId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "establishment_id", insertable = false, updatable = false)
private Establishment establishment;
@Column(name = "amount_xof", nullable = false)
private Integer amountXof;
@Column(name = "wave_session_id", length = 255)
private String waveSessionId;
/** Statut : PENDING, COMPLETED, FAILED, CANCELLED */
@Column(name = "status", nullable = false, length = 20)
private String status = "PENDING";
@Column(name = "client_phone", length = 30)
private String clientPhone;
@Column(name = "plan", length = 20)
private String plan;
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_COMPLETED = "COMPLETED";
public static final String STATUS_FAILED = "FAILED";
public static final String STATUS_CANCELLED = "CANCELLED";
}

View File

@@ -0,0 +1,56 @@
package com.lions.dev.entity.establishment;
import com.lions.dev.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Abonnement / droits d'accès d'un établissement (paiement via Wave).
* Un établissement doit avoir un abonnement actif pour accéder aux fonctionnalités payantes.
*/
@Entity
@Table(name = "establishment_subscriptions")
@Getter
@Setter
@NoArgsConstructor
public class EstablishmentSubscription extends BaseEntity {
@Column(name = "establishment_id", nullable = false)
private UUID establishmentId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "establishment_id", insertable = false, updatable = false)
private Establishment establishment;
/** Plan : MONTHLY, YEARLY */
@Column(name = "plan", nullable = false, length = 20)
private String plan;
/** Statut : PENDING, ACTIVE, EXPIRED, CANCELLED */
@Column(name = "status", nullable = false, length = 20)
private String status = "PENDING";
@Column(name = "wave_session_id", length = 255)
private String waveSessionId;
@Column(name = "amount_xof")
private Integer amountXof;
@Column(name = "paid_at")
private LocalDateTime paidAt;
@Column(name = "expires_at")
private LocalDateTime expiresAt;
public static final String PLAN_MONTHLY = "MONTHLY";
public static final String PLAN_YEARLY = "YEARLY";
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_EXPIRED = "EXPIRED";
public static final String STATUS_CANCELLED = "CANCELLED";
}

View File

@@ -0,0 +1,41 @@
package com.lions.dev.entity.events;
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;
import java.time.LocalDateTime;
/**
* Enregistrement d'un partage d'événement par un utilisateur.
* Chaque partage est lié à un utilisateur et à un événement.
*/
@Entity
@Table(name = "event_shares")
@Getter
@Setter
@NoArgsConstructor
@ToString
public class EventShare extends BaseEntity {
@Column(name = "shared_at", nullable = false)
private LocalDateTime sharedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private Users user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", nullable = false)
private Events event;
public EventShare(Users user, Events event) {
this.user = user;
this.event = event;
this.sharedAt = LocalDateTime.now();
}
}

View File

@@ -117,7 +117,6 @@ public class Events extends BaseEntity {
*/
public void addParticipant(Users user) {
participants.add(user);
System.out.println("[LOG] Participant ajouté : " + user.getEmail() + " à l'événement : " + this.title);
}
/**
@@ -127,7 +126,6 @@ public class Events extends BaseEntity {
*/
public void removeParticipant(Users user) {
participants.remove(user);
System.out.println("[LOG] Participant supprimé : " + user.getEmail() + " de l'événement : " + this.title);
}
/**
@@ -137,7 +135,6 @@ public class Events extends BaseEntity {
*/
public int getNumberOfParticipants() {
int count = participants.size();
System.out.println("[LOG] Nombre de participants à l'événement : " + this.title + " - " + count);
return count;
}
@@ -146,7 +143,6 @@ public class Events extends BaseEntity {
*/
public void setClosed(boolean closed) {
this.status = closed ? "CLOSED" : "OPEN";
System.out.println("[LOG] Statut de l'événement mis à jour : " + this.title + " - " + this.status);
}
/**
@@ -183,14 +179,23 @@ public class Events extends BaseEntity {
@OneToMany(fetch = FetchType.LAZY, mappedBy = "event")
private List<Comment> comments; // Liste des commentaires associés à l'événement
@OneToMany(fetch = FetchType.LAZY, mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true)
private List<EventShare> shares = new java.util.ArrayList<>(); // Partages de l'événement
/**
* Retourne la liste des commentaires associés à cet événement.
*
* @return Une liste de commentaires.
*/
public List<Comment> getComments() {
System.out.println("[LOG] Récupération des commentaires pour l'événement : " + this.title);
return comments;
}
/**
* Retourne la liste des partages de cet événement.
*/
public List<EventShare> getShares() {
return shares != null ? shares : new java.util.ArrayList<>();
}
}

View File

@@ -38,12 +38,5 @@ public class Friendship extends BaseEntity {
*/
public void setStatus(FriendshipStatus newStatus) {
this.status = newStatus;
System.out.println(
"[LOG] Statut changé pour l'amitié entre "
+ this.user.getEmail()
+ " et "
+ this.friend.getEmail()
+ " - Nouveau statut : "
+ this.status);
}
}

View File

@@ -60,7 +60,6 @@ public class Notification extends BaseEntity {
this.type = type;
this.user = user;
this.isRead = false;
System.out.println("[LOG] Notification créée : " + title + " pour l'utilisateur " + user.getEmail());
}
/**
@@ -69,7 +68,6 @@ public class Notification extends BaseEntity {
public void markAsRead() {
if (!this.isRead) {
this.isRead = true;
System.out.println("[LOG] Notification marquée comme lue : " + this.title);
}
}

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

@@ -27,9 +27,9 @@ public class SocialPost extends BaseEntity {
@Column(name = "content", nullable = false, length = 2000)
private String content; // Le contenu textuel du post
@ManyToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private Users user; // L'utilisateur créateur du post
private Users user; // L'utilisateur créateur du post (EAGER pour éviter 500 sur mapping DTO)
@Column(name = "image_url", length = 500)
private String imageUrl; // URL de l'image associée (optionnel)
@@ -52,7 +52,6 @@ public class SocialPost extends BaseEntity {
this.likesCount = 0;
this.commentsCount = 0;
this.sharesCount = 0;
System.out.println("[LOG] Post social créé par l'utilisateur : " + user.getEmail());
}
/**
@@ -60,7 +59,6 @@ public class SocialPost extends BaseEntity {
*/
public void incrementLikes() {
this.likesCount++;
System.out.println("[LOG] Like ajouté au post ID : " + this.getId() + " (total: " + this.likesCount + ")");
}
/**
@@ -69,7 +67,6 @@ public class SocialPost extends BaseEntity {
public void decrementLikes() {
if (this.likesCount > 0) {
this.likesCount--;
System.out.println("[LOG] Like retiré du post ID : " + this.getId() + " (total: " + this.likesCount + ")");
}
}
@@ -78,7 +75,6 @@ public class SocialPost extends BaseEntity {
*/
public void incrementComments() {
this.commentsCount++;
System.out.println("[LOG] Commentaire ajouté au post ID : " + this.getId() + " (total: " + this.commentsCount + ")");
}
/**
@@ -86,7 +82,6 @@ public class SocialPost extends BaseEntity {
*/
public void incrementShares() {
this.sharesCount++;
System.out.println("[LOG] Partage ajouté au post ID : " + this.getId() + " (total: " + this.sharesCount + ")");
}
}

View File

@@ -70,7 +70,6 @@ public class Story extends BaseEntity {
this.expiresAt = LocalDateTime.now().plusHours(24); // Expire après 24h
this.isActive = true;
this.viewsCount = 0;
System.out.println("[LOG] Story créée par l'utilisateur : " + user.getEmail());
}
/**
@@ -82,7 +81,6 @@ public class Story extends BaseEntity {
public boolean markAsViewed(UUID viewerId) {
if (viewerIds.add(viewerId)) {
this.viewsCount++;
System.out.println("[LOG] Story ID : " + this.getId() + " vue par l'utilisateur ID : " + viewerId + " (total: " + this.viewsCount + ")");
return true;
}
return false;
@@ -102,7 +100,6 @@ public class Story extends BaseEntity {
*/
public void deactivate() {
this.isActive = false;
System.out.println("[LOG] Story ID : " + this.getId() + " désactivée");
}
/**

View File

@@ -69,6 +69,10 @@ public class Users extends BaseEntity {
@Column(name = "last_seen")
private java.time.LocalDateTime lastSeen; // Dernière fois que l'utilisateur était en ligne
/** true = compte actif ; false = manager suspendu (abonnement expiré / paiement échoué). Par défaut true. */
@Column(name = "is_active", nullable = false)
private boolean isActive = true;
// Utilisation de BCrypt pour hacher les mots de passe de manière sécurisée
// private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
@@ -80,7 +84,6 @@ public class Users extends BaseEntity {
*/
public void setPassword(String password) {
this.passwordHash = BCrypt.withDefaults().hashToString(12, password.toCharArray());
System.out.println("[LOG] Mot de passe haché pour l'utilisateur : " + this.email);
}
/**
@@ -101,9 +104,7 @@ public class Users extends BaseEntity {
*/
public boolean verifyPassword(String password) {
BCrypt.Result result = BCrypt.verifyer().verify(password.toCharArray(), this.passwordHash);
boolean isValid = result.verified;
System.out.println("[LOG] Vérification du mot de passe pour l'utilisateur : " + this.email + " - Résultat : " + isValid);
return isValid;
return result.verified;
}
/**
@@ -130,9 +131,7 @@ public class Users extends BaseEntity {
* @return true si l'utilisateur est un administrateur, false sinon.
*/
public boolean isAdmin() {
boolean isAdmin = "ADMIN".equalsIgnoreCase(this.role);
System.out.println("[LOG] Vérification du rôle ADMIN pour l'utilisateur : " + this.email + " - Résultat : " + isAdmin);
return isAdmin;
return "ADMIN".equalsIgnoreCase(this.role);
}
@ManyToMany(fetch = FetchType.LAZY)
@@ -150,7 +149,6 @@ public class Users extends BaseEntity {
*/
public void addFavoriteEvent(Events event) {
favoriteEvents.add(event);
System.out.println("[LOG] Événement ajouté aux favoris pour l'utilisateur : " + this.email);
}
/**
@@ -160,7 +158,6 @@ public class Users extends BaseEntity {
*/
public void removeFavoriteEvent(Events event) {
favoriteEvents.remove(event);
System.out.println("[LOG] Événement retiré des favoris pour l'utilisateur : " + this.email);
}
/**
@@ -179,7 +176,6 @@ public class Users extends BaseEntity {
* @return Une liste d'événements favoris.
*/
public Set<Events> getFavoriteEvents() {
System.out.println("[LOG] Récupération des événements favoris pour l'utilisateur : " + this.email);
return favoriteEvents;
}
@@ -210,7 +206,6 @@ public class Users extends BaseEntity {
} else {
preferences.remove("preferred_category");
}
System.out.println("[LOG] Catégorie préférée définie pour l'utilisateur : " + this.email + " - Catégorie : " + category);
}
/**
@@ -228,7 +223,6 @@ public class Users extends BaseEntity {
public void updatePresence() {
this.isOnline = true;
this.lastSeen = java.time.LocalDateTime.now();
System.out.println("[LOG] Présence mise à jour pour l'utilisateur : " + this.email + " - Online: true");
}
/**
@@ -237,7 +231,6 @@ public class Users extends BaseEntity {
public void setOffline() {
this.isOnline = false;
this.lastSeen = java.time.LocalDateTime.now();
System.out.println("[LOG] Utilisateur marqué hors ligne : " + this.email);
}
}

View File

@@ -0,0 +1,15 @@
package com.lions.dev.exception;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
/**
* Exception levée lorsqu'on tente de supprimer un établissement qui a encore
* des dépendances (événements, réservations). Renvoie une réponse HTTP 409 (Conflict).
*/
public class EstablishmentHasDependenciesException extends WebApplicationException {
public EstablishmentHasDependenciesException(String message) {
super(message, Response.Status.CONFLICT);
}
}

View File

@@ -1,22 +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);
System.out.println("[ERROR] Événement non trouvé avec l'ID : " + eventId.toString());
}
}

View File

@@ -0,0 +1,110 @@
package com.lions.dev.exception;
import jakarta.persistence.EntityNotFoundException;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.WebApplicationException;
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.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Mapper d'exceptions global pour standardiser les réponses d'erreur de l'API.
*
* Format de réponse d'erreur standard:
* {
* "timestamp": "2026-02-05T12:00:00Z",
* "status": 400,
* "error": "Bad Request",
* "message": "Description de l'erreur",
* "path": "/api/endpoint"
* }
*
* @since 2.0 - Production-ready
*/
@Provider
public class GlobalExceptionMapper implements ExceptionMapper<Throwable> {
private static final Logger LOG = Logger.getLogger(GlobalExceptionMapper.class);
@Override
public Response toResponse(Throwable exception) {
// Déterminer le statut et le message en fonction du type d'exception
int status;
String error;
String message;
if (exception instanceof ConstraintViolationException cve) {
status = Response.Status.BAD_REQUEST.getStatusCode();
error = "Validation Error";
message = cve.getConstraintViolations().stream()
.map(cv -> cv.getPropertyPath() + ": " + cv.getMessage())
.collect(Collectors.joining("; "));
LOG.warnf("Validation error: %s", message);
} else if (exception instanceof IllegalArgumentException) {
status = Response.Status.BAD_REQUEST.getStatusCode();
error = "Bad Request";
message = exception.getMessage();
LOG.warnf("Bad request: %s", message);
} else if (exception instanceof EntityNotFoundException || exception instanceof NotFoundException) {
status = Response.Status.NOT_FOUND.getStatusCode();
error = "Not Found";
message = exception.getMessage() != null ? exception.getMessage() : "Resource not found";
LOG.warnf("Not found: %s", message);
} else if (exception instanceof UserNotFoundException) {
status = Response.Status.NOT_FOUND.getStatusCode();
error = "User Not Found";
message = exception.getMessage();
LOG.warnf("User not found: %s", message);
} else if (exception instanceof NotAuthorizedException) {
status = Response.Status.UNAUTHORIZED.getStatusCode();
error = "Unauthorized";
message = "Authentication required";
LOG.warnf("Unauthorized access attempt");
} else if (exception instanceof ForbiddenException || exception instanceof UnauthorizedException) {
status = Response.Status.FORBIDDEN.getStatusCode();
error = "Forbidden";
message = exception.getMessage() != null ? exception.getMessage() : "Access denied";
LOG.warnf("Forbidden: %s", message);
} else if (exception instanceof SecurityException) {
status = Response.Status.FORBIDDEN.getStatusCode();
error = "Security Error";
message = exception.getMessage();
LOG.warnf("Security error: %s", message);
} else if (exception instanceof WebApplicationException wae) {
Response response = wae.getResponse();
status = response.getStatus();
error = Response.Status.fromStatusCode(status).getReasonPhrase();
message = exception.getMessage();
LOG.warnf("Web application exception (%d): %s", status, message);
} else {
// Erreur interne non gérée
status = Response.Status.INTERNAL_SERVER_ERROR.getStatusCode();
error = "Internal Server Error";
message = "An unexpected error occurred. Please try again later.";
LOG.errorf(exception, "Unhandled exception: %s", exception.getMessage());
}
// Construire la réponse d'erreur standardisée
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", Instant.now().toString());
errorResponse.put("status", status);
errorResponse.put("error", error);
errorResponse.put("message", message);
return Response.status(status)
.type(MediaType.APPLICATION_JSON)
.entity(errorResponse)
.build();
}
}

Some files were not shown because too many files have changed in this diff Show More