feat: v2.0 – réorg docker/scripts, prod, résas, abonnements Wave, Flyway base vierge

This commit is contained in:
dahoud
2026-01-29 00:44:40 +00:00
parent 9d5e388efa
commit ce89face73
66 changed files with 2333 additions and 227 deletions

View File

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

View File

@@ -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) 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 1** : Build avec Maven dans une image UBI8 OpenJDK 17
- **Stage 2** : Runtime optimisé avec l'uber-jar compilé - **Stage 2** : Runtime optimisé avec l'uber-jar compilé
@@ -59,8 +59,8 @@ Configuration production avec :
### Build Local (Test) ### Build Local (Test)
```bash ```bash
# Build de l'image # Build de l'image (Dockerfiles dans docker/)
docker build -f Dockerfile.prod -t afterwork-api:latest . docker build -f docker/Dockerfile.prod -t afterwork-api:latest .
# Test local # Test local
docker run -p 8080:8080 \ docker run -p 8080:8080 \
@@ -284,8 +284,8 @@ ls target/*-runner.jar
### Étape 2 : Build Docker ### Étape 2 : Build Docker
```bash ```bash
# Build l'image de production # Build l'image de production (Dockerfiles dans docker/)
docker build -f Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 . docker build -f docker/Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 .
# Test local (optionnel) # Test local (optionnel)
docker run --rm -p 8080:8080 \ docker run --rm -p 8080:8080 \
@@ -361,7 +361,7 @@ curl https://api.lions.dev/afterwork/api/users/test
```bash ```bash
# 1. Build nouvelle version # 1. Build nouvelle version
mvn clean package -DskipTests 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 docker push registry.lions.dev/afterwork-api:1.0.1
# 2. Mise à jour du déploiement # 2. Mise à jour du déploiement

View File

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

View File

@@ -8,15 +8,15 @@
cd C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main cd C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main
# Déploiement complet (build + push + deploy) # 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 # Ou étape par étape
.\deploy.ps1 -Action build # Build Maven + Docker .\scripts\deploy.ps1 -Action build # Build Maven + Docker
.\deploy.ps1 -Action push # Push vers registry .\scripts\deploy.ps1 -Action push # Push vers registry
.\deploy.ps1 -Action deploy # Déploiement K8s .\scripts\deploy.ps1 -Action deploy # Déploiement K8s
# Vérifier le statut # Vérifier le statut
.\deploy.ps1 -Action status .\scripts\deploy.ps1 -Action status
``` ```
### Option 2 : Déploiement Manuel ### 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 mvn clean package -DskipTests
# 2. Build Docker # 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 # 3. Push vers Registry
docker login registry.lions.dev docker login registry.lions.dev
@@ -55,7 +55,7 @@ cd C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main
# Build local # Build local
mvn clean package -DskipTests mvn clean package -DskipTests
docker build -f Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 . docker build -f docker/Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 .
docker push registry.lions.dev/afterwork-api:1.0.0 docker push registry.lions.dev/afterwork-api:1.0.0
# Déploiement # Déploiement

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 ## 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 FROM eclipse-temurin:17-jre-alpine
@@ -25,7 +25,7 @@ RUN mkdir -p /app /tmp/uploads && \
WORKDIR /app 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 COPY --chown=appuser:appuser target/*-runner.jar /app/app.jar
# Exposition du port # Exposition du port

View File

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

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

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

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

@@ -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

@@ -0,0 +1,25 @@
package com.lions.dev.dto.request.establishment;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
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")
private String clientPhone;
}

View File

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

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,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

@@ -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

@@ -21,6 +21,7 @@ public class UserCreateResponseDTO {
private String lastName; // v2.0 - Nom de famille de l'utilisateur private String lastName; // v2.0 - Nom de famille de l'utilisateur
private String email; // Email de l'utilisateur private String email; // Email de l'utilisateur
private String role; // Rôle 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 profileImageUrl; // URL de l'image de profil de l'utilisateur
private String bio; // v2.0 - Biographie courte private String bio; // v2.0 - Biographie courte
private Integer loyaltyPoints; // v2.0 - Points de fidélité private Integer loyaltyPoints; // v2.0 - Points de fidélité
@@ -56,6 +57,7 @@ public class UserCreateResponseDTO {
this.lastName = user.getLastName(); // v2.0 this.lastName = user.getLastName(); // v2.0
this.email = user.getEmail(); this.email = user.getEmail();
this.role = user.getRole(); this.role = user.getRole();
this.isActive = user.isActive();
this.profileImageUrl = user.getProfileImageUrl(); this.profileImageUrl = user.getProfileImageUrl();
this.bio = user.getBio(); // v2.0 this.bio = user.getBio(); // v2.0
this.loyaltyPoints = user.getLoyaltyPoints(); // v2.0 this.loyaltyPoints = user.getLoyaltyPoints(); // v2.0

View File

@@ -62,6 +62,10 @@ public class Establishment extends BaseEntity {
@Column(name = "verification_status", nullable = false) @Column(name = "verification_status", nullable = false)
private String verificationStatus = "PENDING"; // Statut de vérification: PENDING, VERIFIED, REJECTED (v2.0) 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") @Column(name = "latitude")
private Double latitude; // Latitude pour la géolocalisation private Double latitude; // Latitude pour la géolocalisation

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

@@ -27,9 +27,9 @@ public class SocialPost extends BaseEntity {
@Column(name = "content", nullable = false, length = 2000) @Column(name = "content", nullable = false, length = 2000)
private String content; // Le contenu textuel du post private String content; // Le contenu textuel du post
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false) @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) @Column(name = "image_url", length = 500)
private String imageUrl; // URL de l'image associée (optionnel) private String imageUrl; // URL de l'image associée (optionnel)

View File

@@ -69,6 +69,10 @@ public class Users extends BaseEntity {
@Column(name = "last_seen") @Column(name = "last_seen")
private java.time.LocalDateTime lastSeen; // Dernière fois que l'utilisateur était en ligne 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 // Utilisation de BCrypt pour hacher les mots de passe de manière sécurisée
// private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); // private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();

View File

@@ -0,0 +1,16 @@
package com.lions.dev.repository;
import com.lions.dev.entity.booking.Booking;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
@ApplicationScoped
public class BookingRepository implements PanacheRepositoryBase<Booking, UUID> {
public List<Booking> findByUserId(UUID userId) {
return list("user.id", userId);
}
}

View File

@@ -0,0 +1,21 @@
package com.lions.dev.repository;
import com.lions.dev.entity.establishment.EstablishmentPayment;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class EstablishmentPaymentRepository implements PanacheRepositoryBase<EstablishmentPayment, UUID> {
public Optional<EstablishmentPayment> findByWaveSessionId(String waveSessionId) {
return find("waveSessionId", waveSessionId).firstResultOptional();
}
public List<EstablishmentPayment> findByEstablishmentId(UUID establishmentId) {
return list("establishmentId", establishmentId);
}
}

View File

@@ -84,5 +84,16 @@ public class EstablishmentRepository implements PanacheRepositoryBase<Establishm
LOG.info("[LOG] Nombre d'établissements trouvés : " + establishments.size()); LOG.info("[LOG] Nombre d'établissements trouvés : " + establishments.size());
return establishments; return establishments;
} }
/**
* Compte le nombre d'établissements gérés par un responsable.
* Contrainte métier : un Manager ne peut gérer qu'un seul établissement.
*
* @param managerId L'ID du responsable.
* @return Le nombre d'établissements (0 ou 1).
*/
public long countByManagerId(UUID managerId) {
return count("manager.id = ?1", managerId);
}
} }

View File

@@ -0,0 +1,26 @@
package com.lions.dev.repository;
import com.lions.dev.entity.establishment.EstablishmentSubscription;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class EstablishmentSubscriptionRepository implements PanacheRepositoryBase<EstablishmentSubscription, UUID> {
public Optional<EstablishmentSubscription> findByWaveSessionId(String waveSessionId) {
return find("waveSessionId", waveSessionId).firstResultOptional();
}
public List<EstablishmentSubscription> findByEstablishmentId(UUID establishmentId) {
return list("establishmentId", establishmentId);
}
public Optional<EstablishmentSubscription> findActiveByEstablishmentId(UUID establishmentId) {
return find("establishmentId = ?1 and status = ?2", establishmentId, EstablishmentSubscription.STATUS_ACTIVE)
.firstResultOptional();
}
}

View File

@@ -80,4 +80,24 @@ public class EventsRepository implements PanacheRepositoryBase<Events, UUID> {
LOG.info("[LOG] " + events.size() + " événement(s) récupéré(s) des amis"); LOG.info("[LOG] " + events.size() + " événement(s) récupéré(s) des amis");
return events; return events;
} }
/**
* Récupère les événements dont l'établissement correspond à la localisation (ville ou adresse).
* v2.0 : la colonne location a été supprimée ; on filtre par establishment.address ou establishment.city.
*
* @param location Fragment de ville ou d'adresse (recherche partielle, insensible à la casse).
* @return Liste des événements dont l'établissement matche la localisation.
*/
public List<Events> findEventsByEstablishmentLocation(String location) {
if (location == null || location.isBlank()) {
return List.of();
}
String pattern = "%" + location.trim().toLowerCase() + "%";
return getEntityManager()
.createQuery(
"SELECT e FROM Events e JOIN e.establishment est WHERE est IS NOT NULL AND (LOWER(est.address) LIKE :loc OR LOWER(est.city) LIKE :loc)",
Events.class)
.setParameter("loc", pattern)
.getResultList();
}
} }

View File

@@ -26,8 +26,9 @@ public class FriendshipRepository implements PanacheRepositoryBase<Friendship, U
/** /**
* Trouver une relation d'amitié entre deux utilisateurs spécifiés. * Trouver une relation d'amitié entre deux utilisateurs spécifiés.
* Cette méthode recherche une relation d'amitié entre deux utilisateurs donnés. * Cette méthode recherche une relation d'amitié entre deux utilisateurs donnés,
* Elle peut être utilisée pour vérifier si une demande d'amitié existe déjà. * dans les deux sens (user→friend ou friend→user) car une demande peut exister
* dans l'une ou l'autre direction.
* *
* @param user L'utilisateur qui envoie la demande d'amitié. * @param user L'utilisateur qui envoie la demande d'amitié.
* @param friend L'ami qui reçoit la demande. * @param friend L'ami qui reçoit la demande.
@@ -36,8 +37,8 @@ public class FriendshipRepository implements PanacheRepositoryBase<Friendship, U
public Optional<Friendship> findByUsers(Users user, Users friend) { public Optional<Friendship> findByUsers(Users user, Users friend) {
logger.infof("Recherche de la relation d'amitié entre les utilisateurs : %s et %s", user.getId(), friend.getId()); logger.infof("Recherche de la relation d'amitié entre les utilisateurs : %s et %s", user.getId(), friend.getId());
// Requête qui cherche une relation d'amitié entre deux utilisateurs spécifiques // Vérifier dans les deux sens : (user, friend) ET (friend, user)
Optional<Friendship> friendship = find("user = ?1 and friend = ?2", user, friend).firstResultOptional(); Optional<Friendship> friendship = find("(user = ?1 AND friend = ?2) OR (user = ?2 AND friend = ?1)", user, friend).firstResultOptional();
if (friendship.isPresent()) { if (friendship.isPresent()) {
logger.infof("Relation d'amitié trouvée entre %s et %s", user.getId(), friend.getId()); logger.infof("Relation d'amitié trouvée entre %s et %s", user.getId(), friend.getId());

View File

@@ -0,0 +1,69 @@
package com.lions.dev.resource;
import com.lions.dev.dto.response.admin.AdminRevenueResponseDTO;
import com.lions.dev.dto.response.admin.ManagerStatsResponseDTO;
import com.lions.dev.service.AdminStatsService;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.Optional;
/**
* Ressource REST pour le tableau de bord Super Admin.
* Requiert le header X-Super-Admin-Key pour toutes les opérations.
*/
@Path("/admin/stats")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Admin Stats", description = "Statistiques et KPIs (Super Admin)")
public class AdminStatsResource {
private static final Logger LOG = Logger.getLogger(AdminStatsResource.class);
private static final String SUPER_ADMIN_KEY_HEADER = "X-Super-Admin-Key";
@Inject
AdminStatsService adminStatsService;
@ConfigProperty(name = "afterwork.super-admin.api-key", defaultValue = "")
Optional<String> superAdminApiKey;
private boolean isAuthorized(String apiKeyHeader) {
if (superAdminApiKey == null || !superAdminApiKey.isPresent() || superAdminApiKey.get().isBlank()) {
return false;
}
return apiKeyHeader != null && apiKeyHeader.equals(superAdminApiKey.get());
}
@GET
@Path("/revenue")
@Operation(summary = "Revenus des abonnements", description = "Total des abonnements actifs * prix. Réservé Super Admin.")
public Response getRevenue(@HeaderParam(SUPER_ADMIN_KEY_HEADER) String apiKeyHeader) {
if (!isAuthorized(apiKeyHeader)) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Clé Super Admin invalide ou absente.\"}")
.build();
}
AdminRevenueResponseDTO dto = adminStatsService.getRevenue();
return Response.ok(dto).build();
}
@GET
@Path("/managers")
@Operation(summary = "Liste des managers", description = "Managers avec statut (Actif/Suspendu) et date d'expiration. Réservé Super Admin.")
public Response getManagers(@HeaderParam(SUPER_ADMIN_KEY_HEADER) String apiKeyHeader) {
if (!isAuthorized(apiKeyHeader)) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Clé Super Admin invalide ou absente.\"}")
.build();
}
List<ManagerStatsResponseDTO> list = adminStatsService.getManagers();
return Response.ok(list).build();
}
}

View File

@@ -0,0 +1,100 @@
package com.lions.dev.resource;
import com.lions.dev.dto.request.booking.ReservationCreateRequestDTO;
import com.lions.dev.dto.response.booking.ReservationResponseDTO;
import com.lions.dev.service.BookingService;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.UUID;
/**
* Ressource REST pour les réservations (bookings).
* Path /reservations pour alignement avec le frontend Flutter (ReservationsScreen).
*/
@Path("/reservations")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Reservations", description = "Réservations d'établissements")
public class BookingResource {
private static final Logger LOG = Logger.getLogger(BookingResource.class);
@Inject
BookingService bookingService;
@GET
@Path("/user/{userId}")
@Operation(summary = "Liste des réservations d'un utilisateur")
public Response getUserReservations(@PathParam("userId") UUID userId) {
List<ReservationResponseDTO> list = bookingService.getReservationsByUserId(userId);
return Response.ok(list).build();
}
@GET
@Path("/{id}")
@Operation(summary = "Détail d'une réservation")
public Response getReservation(@PathParam("id") UUID id) {
ReservationResponseDTO dto = bookingService.getReservationById(id);
if (dto == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(dto).build();
}
@POST
@Transactional
@Operation(summary = "Créer une réservation")
public Response createReservation(@Valid ReservationCreateRequestDTO dto) {
try {
ReservationResponseDTO created = bookingService.createReservation(dto);
return Response.status(Response.Status.CREATED).entity(created).build();
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.BAD_REQUEST).entity(e.getMessage()).build();
}
}
@PUT
@Path("/{id}")
@Transactional
@Operation(summary = "Mettre à jour une réservation")
public Response updateReservation(@PathParam("id") UUID id, @Valid ReservationCreateRequestDTO dto) {
ReservationResponseDTO updated = bookingService.updateReservation(id, dto);
if (updated == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(updated).build();
}
@PUT
@Path("/{id}/cancel")
@Transactional
@Operation(summary = "Annuler une réservation")
public Response cancelReservation(@PathParam("id") UUID id) {
ReservationResponseDTO dto = bookingService.cancelReservation(id);
if (dto == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(dto).build();
}
@DELETE
@Path("/{id}")
@Transactional
@Operation(summary = "Supprimer une réservation")
public Response deleteReservation(@PathParam("id") UUID id) {
if (bookingService.getReservationById(id) == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
bookingService.deleteReservation(id);
return Response.noContent().build();
}
}

View File

@@ -5,8 +5,10 @@ import com.lions.dev.dto.request.establishment.EstablishmentUpdateRequestDTO;
import com.lions.dev.dto.response.establishment.EstablishmentResponseDTO; import com.lions.dev.dto.response.establishment.EstablishmentResponseDTO;
import com.lions.dev.entity.establishment.Establishment; import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.users.Users; import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.EstablishmentRepository;
import com.lions.dev.repository.UsersRepository; import com.lions.dev.repository.UsersRepository;
import com.lions.dev.service.EstablishmentService; import com.lions.dev.service.EstablishmentService;
import com.lions.dev.util.UserRoles;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -38,6 +40,9 @@ public class EstablishmentResource {
@Inject @Inject
UsersRepository usersRepository; UsersRepository usersRepository;
@Inject
EstablishmentRepository establishmentRepository;
private static final Logger LOG = Logger.getLogger(EstablishmentResource.class); private static final Logger LOG = Logger.getLogger(EstablishmentResource.class);
// *********** Création d'un établissement *********** // *********** Création d'un établissement ***********
@@ -67,6 +72,26 @@ public class EstablishmentResource {
.build(); .build();
} }
// Vérification rôle : seuls les Managers (ou ADMIN) peuvent créer un établissement
String role = manager.getRole() != null ? manager.getRole().toUpperCase() : "";
if (!UserRoles.MANAGER.equals(role) && !UserRoles.ADMIN.equals(role) && !UserRoles.SUPER_ADMIN.equals(role)) {
LOG.error("[ERROR] L'utilisateur " + requestDTO.getManagerId() + " n'a pas le rôle Manager. Rôle : " + role);
return Response.status(Response.Status.FORBIDDEN)
.entity("Seuls les Managers peuvent créer des établissements")
.build();
}
// Contrainte : un Manager ne peut gérer qu'un seul établissement (ADMIN/SUPER_ADMIN exclus)
if (UserRoles.MANAGER.equals(role)) {
long count = establishmentRepository.countByManagerId(requestDTO.getManagerId());
if (count >= 1) {
LOG.error("[ERROR] Le Manager " + requestDTO.getManagerId() + " possède déjà un établissement");
return Response.status(Response.Status.FORBIDDEN)
.entity("Un Manager ne peut gérer qu'un seul établissement")
.build();
}
}
// Créer l'établissement // Créer l'établissement
Establishment establishment = new Establishment(); Establishment establishment = new Establishment();
establishment.setName(requestDTO.getName()); establishment.setName(requestDTO.getName());

View File

@@ -0,0 +1,66 @@
package com.lions.dev.resource;
import com.lions.dev.dto.request.establishment.InitiateSubscriptionRequestDTO;
import com.lions.dev.dto.response.establishment.InitiateSubscriptionResponseDTO;
import com.lions.dev.service.WavePaymentService;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import java.util.Map;
import java.util.UUID;
/**
* Ressource pour les abonnements / droits d'accès des établissements (paiement Wave).
*/
@Path("/establishments/{establishmentId}/subscriptions")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Establishment Subscriptions", description = "Paiement des droits d'accès via Wave")
public class EstablishmentSubscriptionResource {
private static final Logger LOG = Logger.getLogger(EstablishmentSubscriptionResource.class);
@Inject
WavePaymentService wavePaymentService;
/**
* Initie un paiement Wave pour les droits d'accès d'un établissement.
* Retourne l'URL de redirection vers la page de paiement Wave.
*/
@POST
@Path("/initiate")
@Operation(summary = "Initier un paiement Wave (droits d'accès)",
description = "Crée une session Wave et retourne l'URL de paiement. Le client redirige l'utilisateur vers payment_url.")
public Response initiatePayment(
@PathParam("establishmentId") UUID establishmentId,
@Valid InitiateSubscriptionRequestDTO request) {
try {
InitiateSubscriptionResponseDTO response = wavePaymentService.initiatePayment(
establishmentId,
request.getPlan(),
request.getClientPhone()
);
return Response.ok(response).build();
} catch (IllegalArgumentException e) {
LOG.warn(e.getMessage());
return Response.status(Response.Status.NOT_FOUND).entity("{\"message\": \"" + e.getMessage() + "\"}").build();
}
}
/**
* Vérifie si l'établissement a un abonnement actif.
*/
@GET
@Path("/status")
@Operation(summary = "Statut d'abonnement", description = "Indique si l'établissement a des droits d'accès actifs.")
public Response getSubscriptionStatus(@PathParam("establishmentId") UUID establishmentId) {
boolean active = wavePaymentService.hasActiveSubscription(establishmentId);
return Response.ok(Map.of("hasActiveSubscription", active)).build();
}
}

View File

@@ -9,6 +9,7 @@ import com.lions.dev.dto.response.friends.FriendshipReadStatusResponseDTO;
import com.lions.dev.entity.friends.FriendshipStatus; import com.lions.dev.entity.friends.FriendshipStatus;
import com.lions.dev.exception.UserNotFoundException; import com.lions.dev.exception.UserNotFoundException;
import com.lions.dev.service.FriendshipService; import com.lions.dev.service.FriendshipService;
import jakarta.ws.rs.NotFoundException;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
@@ -82,6 +83,16 @@ public class FriendshipResource {
+ " et " + " et "
+ friendshipResponse.getFriendId()); + friendshipResponse.getFriendId());
return Response.ok(friendshipResponse).build(); return Response.ok(friendshipResponse).build();
} catch (UserNotFoundException e) {
logger.warn("[WARN] Utilisateur non trouvé : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Utilisateur non trouvé.\"}")
.build();
} catch (IllegalArgumentException e) {
logger.warn("[WARN] Requête invalide : " + e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
} catch (Exception e) { } catch (Exception e) {
logger.error("[ERROR] Erreur lors de l'envoi de la demande d'amitié : " + e.getMessage(), e); logger.error("[ERROR] Erreur lors de l'envoi de la demande d'amitié : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR) return Response.status(Response.Status.INTERNAL_SERVER_ERROR)

View File

@@ -3,6 +3,7 @@ package com.lions.dev.resource;
import com.lions.dev.dto.request.social.SocialPostCreateRequestDTO; import com.lions.dev.dto.request.social.SocialPostCreateRequestDTO;
import com.lions.dev.dto.response.social.SocialPostResponseDTO; import com.lions.dev.dto.response.social.SocialPostResponseDTO;
import com.lions.dev.entity.social.SocialPost; import com.lions.dev.entity.social.SocialPost;
import com.lions.dev.exception.UserNotFoundException;
import com.lions.dev.service.SocialPostService; import com.lions.dev.service.SocialPostService;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
@@ -121,6 +122,11 @@ public class SocialPostResource {
); );
SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post); SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post);
return Response.status(Response.Status.CREATED).entity(responseDTO).build(); return Response.status(Response.Status.CREATED).entity(responseDTO).build();
} catch (UserNotFoundException e) {
LOG.warn("[WARN] Utilisateur non trouvé : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Utilisateur non trouvé.\"}")
.build();
} catch (Exception e) { } catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la création du post : " + e.getMessage(), e); LOG.error("[ERROR] Erreur lors de la création du post : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR) return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
@@ -382,6 +388,11 @@ public class SocialPostResource {
.collect(Collectors.toList()); .collect(Collectors.toList());
return Response.ok(responseDTOs).build(); return Response.ok(responseDTOs).build();
} catch (UserNotFoundException e) {
LOG.warn("[WARN] Utilisateur non trouvé : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Utilisateur non trouvé.\"}")
.build();
} catch (Exception e) { } catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des posts : " + e.getMessage(), e); LOG.error("[ERROR] Erreur lors de la récupération des posts : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR) return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
@@ -416,6 +427,11 @@ public class SocialPostResource {
.collect(Collectors.toList()); .collect(Collectors.toList());
return Response.ok(responseDTOs).build(); return Response.ok(responseDTOs).build();
} catch (UserNotFoundException e) {
LOG.warn("[WARN] Utilisateur non trouvé : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Utilisateur non trouvé.\"}")
.build();
} catch (Exception e) { } catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des posts des amis : " + e.getMessage(), e); LOG.error("[ERROR] Erreur lors de la récupération des posts des amis : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR) return Response.status(Response.Status.INTERNAL_SERVER_ERROR)

View File

@@ -1,6 +1,7 @@
package com.lions.dev.resource; package com.lions.dev.resource;
import com.lions.dev.dto.PasswordResetRequest; import com.lions.dev.dto.PasswordResetRequest;
import com.lions.dev.dto.request.users.AssignRoleRequestDTO;
import com.lions.dev.dto.request.users.UserAuthenticateRequestDTO; import com.lions.dev.dto.request.users.UserAuthenticateRequestDTO;
import com.lions.dev.dto.request.users.UserCreateRequestDTO; import com.lions.dev.dto.request.users.UserCreateRequestDTO;
import com.lions.dev.dto.response.users.UserAuthenticateResponseDTO; import com.lions.dev.dto.response.users.UserAuthenticateResponseDTO;
@@ -16,9 +17,11 @@ import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*; import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.File; import java.io.File;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
@@ -39,8 +42,13 @@ public class UsersResource {
@Inject @Inject
UsersService userService; UsersService userService;
@ConfigProperty(name = "afterwork.super-admin.api-key", defaultValue = "")
Optional<String> superAdminApiKey;
private static final Logger LOG = Logger.getLogger(UsersResource.class); private static final Logger LOG = Logger.getLogger(UsersResource.class);
private static final String SUPER_ADMIN_KEY_HEADER = "X-Super-Admin-Key";
/** /**
* Endpoint pour créer un nouvel utilisateur. * Endpoint pour créer un nouvel utilisateur.
* *
@@ -296,11 +304,11 @@ public class UsersResource {
Users user = userService.findByEmail(request.getEmail()); Users user = userService.findByEmail(request.getEmail());
if (user != null) { if (user != null) {
// TODO: Generer un token de reset et l'envoyer par email // En standby : pas encore de service d'envoi de mail. Quand disponible :
// Pour l'instant, on retourne success pour ne pas reveler si l'email existe // - generer un token de reset (table dédiée ou champ user avec expiration)
// String resetToken = generateResetToken(); // - appeler emailService.sendPasswordResetEmail(user.getEmail(), resetToken)
// emailService.sendPasswordResetEmail(user.getEmail(), resetToken); // Pour l'instant, on retourne success pour ne pas reveler si l'email existe.
LOG.info("Utilisateur trouve, email de reinitialisation devrait etre envoye : " + request.getEmail()); LOG.info("Utilisateur trouve, email de reinitialisation (en standby - pas de mail service) : " + request.getEmail());
} else { } else {
LOG.info("Aucun utilisateur trouve avec cet email (ne pas reveler) : " + request.getEmail()); LOG.info("Aucun utilisateur trouve avec cet email (ne pas reveler) : " + request.getEmail());
} }
@@ -318,4 +326,98 @@ public class UsersResource {
} }
} }
/**
* Attribue un rôle à un utilisateur (réservé au super administrateur).
* Requiert le header X-Super-Admin-Key correspondant à afterwork.super-admin.api-key.
*
* @param id L'ID de l'utilisateur.
* @param request Le DTO contenant le nouveau rôle.
* @param apiKeyHeader Valeur du header X-Super-Admin-Key (injecté via @HeaderParam).
* @return L'utilisateur mis à jour.
*/
@PUT
@Path("/{id}/role")
@Transactional
@Operation(summary = "Attribuer un rôle à un utilisateur (super admin)",
description = "Modifie le rôle d'un utilisateur. Réservé au super administrateur (header X-Super-Admin-Key).")
@APIResponse(responseCode = "200", description = "Rôle mis à jour")
@APIResponse(responseCode = "403", description = "Clé super admin invalide ou absente")
@APIResponse(responseCode = "404", description = "Utilisateur non trouvé")
public Response assignRole(
@PathParam("id") UUID id,
@Valid AssignRoleRequestDTO request,
@HeaderParam(SUPER_ADMIN_KEY_HEADER) String apiKeyHeader) {
String key = superAdminApiKey.orElse("");
if (key.isBlank()) {
LOG.warn("Opération assignRole refusée : afterwork.super-admin.api-key non configurée");
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Opération non autorisée : clé super admin non configurée.\"}")
.build();
}
if (apiKeyHeader == null || !apiKeyHeader.equals(key)) {
LOG.warn("Opération assignRole refusée : clé super admin invalide ou absente");
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Clé super administrateur invalide ou absente.\"}")
.build();
}
try {
Users user = userService.assignRole(id, request.getRole());
UserCreateResponseDTO responseDTO = new UserCreateResponseDTO(user);
return Response.ok(responseDTO).build();
} catch (UserNotFoundException e) {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
}
}
/**
* Génère un token temporaire d'impersonation (Super Admin se connecte en tant qu'un autre utilisateur).
* Le client envoie ce token (ex: Authorization: Bearer &lt;token&gt;) pour les requêtes suivantes.
* La validation du token côté backend (filter) est à implémenter si nécessaire.
*
* @param id L'ID de l'utilisateur à impersonner.
* @param apiKeyHeader X-Super-Admin-Key.
* @return JSON avec impersonationToken, expiresInSeconds, userId.
*/
@POST
@Path("/{id}/impersonate")
@Operation(summary = "Impersonation (Super Admin)",
description = "Génère un token temporaire pour se connecter en tant que cet utilisateur. Header X-Super-Admin-Key requis.")
public Response impersonate(
@PathParam("id") UUID id,
@HeaderParam(SUPER_ADMIN_KEY_HEADER) String apiKeyHeader) {
String key = superAdminApiKey.orElse("");
if (key.isBlank()) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Opération non autorisée : clé super admin non configurée.\"}")
.build();
}
if (apiKeyHeader == null || !apiKeyHeader.equals(key)) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Clé super administrateur invalide ou absente.\"}")
.build();
}
try {
Users user = userService.getUserById(id);
// Token temporaire (UUID) — à valider côté backend via un filter si besoin
String token = UUID.randomUUID().toString();
int expiresInSeconds = 900; // 15 min
return Response.ok(Map.of(
"impersonationToken", token,
"expiresInSeconds", expiresInSeconds,
"userId", user.getId().toString(),
"email", user.getEmail()
)).build();
} catch (UserNotFoundException e) {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Utilisateur non trouvé.\"}")
.build();
}
}
} }

View File

@@ -0,0 +1,38 @@
package com.lions.dev.resource;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lions.dev.service.WavePaymentService;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
/**
* Webhook Wave pour les notifications de paiement (payment.completed, payment.cancelled, etc.).
*/
@Path("/webhooks/wave")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class WaveWebhookResource {
private static final Logger LOG = Logger.getLogger(WaveWebhookResource.class);
@Inject
WavePaymentService wavePaymentService;
private final ObjectMapper objectMapper = new ObjectMapper();
@POST
public Response handleWebhook(String payload) {
try {
JsonNode node = objectMapper.readTree(payload);
wavePaymentService.handleWebhook(node);
return Response.ok().build();
} catch (Exception e) {
LOG.error("Erreur traitement webhook Wave", e);
return Response.status(Response.Status.BAD_REQUEST).build();
}
}
}

View File

@@ -0,0 +1,78 @@
package com.lions.dev.service;
import com.lions.dev.dto.response.admin.AdminRevenueResponseDTO;
import com.lions.dev.dto.response.admin.ManagerStatsResponseDTO;
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.UsersRepository;
import com.lions.dev.util.UserRoles;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class AdminStatsService {
@Inject
EstablishmentSubscriptionRepository subscriptionRepository;
@Inject
EstablishmentRepository establishmentRepository;
@Inject
UsersRepository usersRepository;
/**
* Revenus totaux : somme des montants des abonnements actifs.
*/
public AdminRevenueResponseDTO getRevenue() {
List<EstablishmentSubscription> active = subscriptionRepository.list(
"status", EstablishmentSubscription.STATUS_ACTIVE);
long count = active.size();
BigDecimal total = active.stream()
.map(s -> s.getAmountXof() != null ? BigDecimal.valueOf(s.getAmountXof()) : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return new AdminRevenueResponseDTO(total, count);
}
/**
* Liste des managers avec statut (Actif/Suspendu) et date d'expiration d'abonnement.
*/
public List<ManagerStatsResponseDTO> getManagers() {
List<Users> managers = usersRepository.list("role", UserRoles.MANAGER);
List<ManagerStatsResponseDTO> result = new ArrayList<>();
for (Users manager : managers) {
List<Establishment> establishments = establishmentRepository.findByManagerId(manager.getId());
UUID establishmentId = null;
String establishmentName = null;
java.time.LocalDateTime expiresAt = null;
String status = manager.isActive() ? "ACTIVE" : "SUSPENDED";
if (!establishments.isEmpty()) {
Establishment est = establishments.get(0);
establishmentId = est.getId();
establishmentName = est.getName();
Optional<EstablishmentSubscription> sub = subscriptionRepository.findActiveByEstablishmentId(est.getId());
if (sub.isPresent()) {
expiresAt = sub.get().getExpiresAt();
}
}
result.add(new ManagerStatsResponseDTO(
manager.getId(),
manager.getEmail(),
manager.getFirstName(),
manager.getLastName(),
status,
expiresAt,
establishmentId,
establishmentName
));
}
return result;
}
}

View File

@@ -0,0 +1,108 @@
package com.lions.dev.service;
import com.lions.dev.dto.request.booking.ReservationCreateRequestDTO;
import com.lions.dev.dto.response.booking.ReservationResponseDTO;
import com.lions.dev.entity.booking.Booking;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.BookingRepository;
import com.lions.dev.repository.EstablishmentRepository;
import com.lions.dev.repository.UsersRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@ApplicationScoped
public class BookingService {
@Inject
BookingRepository bookingRepository;
@Inject
EstablishmentRepository establishmentRepository;
@Inject
UsersRepository usersRepository;
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_DATE_TIME;
public List<ReservationResponseDTO> getReservationsByUserId(UUID userId) {
return bookingRepository.findByUserId(userId).stream()
.map(ReservationResponseDTO::new)
.collect(Collectors.toList());
}
public ReservationResponseDTO getReservationById(UUID id) {
Booking booking = bookingRepository.findById(id);
if (booking == null) return null;
return new ReservationResponseDTO(booking);
}
@Transactional
public ReservationResponseDTO createReservation(ReservationCreateRequestDTO dto) {
Establishment establishment = establishmentRepository.findById(dto.getEstablishmentId());
Users user = usersRepository.findById(dto.getUserId());
if (establishment == null || user == null) {
throw new IllegalArgumentException("Établissement ou utilisateur non trouvé");
}
LocalDateTime reservationTime = parseReservationDate(dto.getReservationDate());
Booking booking = new Booking();
booking.setEstablishment(establishment);
booking.setUser(user);
booking.setReservationTime(reservationTime);
booking.setGuestCount(dto.getNumberOfPeople() > 0 ? dto.getNumberOfPeople() : 1);
booking.setStatus("PENDING");
booking.setSpecialRequests(dto.getNotes());
bookingRepository.persist(booking);
return new ReservationResponseDTO(booking);
}
@Transactional
public ReservationResponseDTO cancelReservation(UUID id) {
Booking booking = bookingRepository.findById(id);
if (booking == null) return null;
booking.setStatus("CANCELLED");
bookingRepository.persist(booking);
return new ReservationResponseDTO(booking);
}
@Transactional
public void deleteReservation(UUID id) {
bookingRepository.deleteById(id);
}
@Transactional
public ReservationResponseDTO updateReservation(UUID id, ReservationCreateRequestDTO dto) {
Booking booking = bookingRepository.findById(id);
if (booking == null) return null;
if (dto.getReservationDate() != null) {
booking.setReservationTime(parseReservationDate(dto.getReservationDate()));
}
if (dto.getNumberOfPeople() > 0) {
booking.setGuestCount(dto.getNumberOfPeople());
}
if (dto.getNotes() != null) {
booking.setSpecialRequests(dto.getNotes());
}
bookingRepository.persist(booking);
return new ReservationResponseDTO(booking);
}
private static LocalDateTime parseReservationDate(String value) {
if (value == null || value.isBlank()) return LocalDateTime.now();
try {
return LocalDateTime.parse(value, ISO);
} catch (Exception e) {
try {
int len = Math.min(19, value.length());
return LocalDateTime.parse(value.substring(0, len));
} catch (Exception e2) {
return LocalDateTime.now();
}
}
}
}

View File

@@ -75,6 +75,7 @@ public class EstablishmentService {
"SELECT DISTINCT e FROM Establishment e " + "SELECT DISTINCT e FROM Establishment e " +
"LEFT JOIN FETCH e.medias m " + "LEFT JOIN FETCH e.medias m " +
"LEFT JOIN FETCH e.manager " + "LEFT JOIN FETCH e.manager " +
"WHERE (e.isActive IS NULL OR e.isActive = true) " +
"ORDER BY e.name ASC", "ORDER BY e.name ASC",
Establishment.class Establishment.class
) )

View File

@@ -152,7 +152,7 @@ public class EventService {
notificationEmitter.send(kafkaEvent); notificationEmitter.send(kafkaEvent);
logger.debug("[logger] Événement event_created publié dans Kafka pour: {}", friend.getId()); logger.debug("[logger] Événement event_created publié dans Kafka pour: {}", friend.getId());
} catch (Exception kafkaEx) { } catch (Exception kafkaEx) {
logger.error("[ERROR] Erreur publication Kafka: {}", kafkaEx.getMessage()); logger.error("[ERROR] Erreur publication Kafka pour événement {}", event.getId(), kafkaEx);
// Ne pas bloquer si Kafka échoue // Ne pas bloquer si Kafka échoue
} }
} }
@@ -424,14 +424,15 @@ public class EventService {
} }
/** /**
* Récupère les événements par localisation. * Récupère les événements par localisation (ville ou adresse de l'établissement).
* v2.0 : plus de colonne location ; recherche sur establishment.address et establishment.city.
* *
* @param location La localisation des événements. * @param location Fragment de localisation (ville ou adresse).
* @return La liste des événements situés à cette localisation. * @return La liste des événements dont l'établissement matche.
*/ */
public List<Events> findEventsByLocation(String location) { public List<Events> findEventsByLocation(String location) {
logger.info("[logger] Récupération des événements pour la localisation : " + location); logger.info("[logger] Récupération des événements pour la localisation : " + location);
List<Events> events = eventsRepository.find("location", location).list(); List<Events> events = eventsRepository.findEventsByEstablishmentLocation(location);
logger.info("[logger] Nombre d'événements trouvés pour la localisation '" + location + "' : " + events.size()); logger.info("[logger] Nombre d'événements trouvés pour la localisation '" + location + "' : " + events.size());
return events; return events;
} }

View File

@@ -56,6 +56,11 @@ public class FriendshipService {
*/ */
@Transactional @Transactional
public FriendshipCreateOneResponseDTO sendFriendRequest(FriendshipCreateOneRequestDTO request) { public FriendshipCreateOneResponseDTO sendFriendRequest(FriendshipCreateOneRequestDTO request) {
if (request == null || request.getUserId() == null || request.getFriendId() == null) {
logger.error("[ERROR] Requête invalide : userId ou friendId manquant");
throw new IllegalArgumentException("L'identifiant de l'utilisateur et de l'ami sont requis.");
}
logger.info("[LOG] Envoi d'une demande d'amitié de l'utilisateur " + request.getUserId() + " à l'utilisateur " + request.getFriendId()); logger.info("[LOG] Envoi d'une demande d'amitié de l'utilisateur " + request.getUserId() + " à l'utilisateur " + request.getFriendId());
// Récupérer les utilisateurs concernés // Récupérer les utilisateurs concernés

View File

@@ -187,7 +187,7 @@ public class MessageService {
// Ne pas bloquer si la confirmation échoue // Ne pas bloquer si la confirmation échoue
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println("[ERROR] Erreur lors de la publication dans Kafka : " + e.getMessage()); io.quarkus.logging.Log.error("[ERROR] Erreur lors de la publication dans Kafka pour message " + message.getId(), e);
// Ne pas bloquer l'envoi du message si Kafka échoue // Ne pas bloquer l'envoi du message si Kafka échoue
} }

View File

@@ -263,4 +263,24 @@ public class UsersService {
Optional<Users> userOptional = usersRepository.findByEmail(email); Optional<Users> userOptional = usersRepository.findByEmail(email);
return userOptional.orElse(null); return userOptional.orElse(null);
} }
/**
* Attribue un rôle à un utilisateur (réservé au super administrateur).
*
* @param userId L'ID de l'utilisateur à modifier.
* @param newRole Le nouveau rôle (SUPER_ADMIN, ADMIN, MANAGER, USER).
* @return L'utilisateur mis à jour.
* @throws UserNotFoundException Si l'utilisateur n'existe pas.
*/
@Transactional
public Users assignRole(UUID userId, String newRole) {
Users user = usersRepository.findById(userId);
if (user == null) {
throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + userId);
}
user.setRole(newRole);
usersRepository.persist(user);
System.out.println("[LOG] Rôle attribué à " + user.getEmail() + " : " + newRole);
return user;
}
} }

View File

@@ -0,0 +1,216 @@
package com.lions.dev.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lions.dev.dto.response.establishment.InitiateSubscriptionResponseDTO;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.establishment.EstablishmentPayment;
import com.lions.dev.entity.establishment.EstablishmentSubscription;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.EstablishmentPaymentRepository;
import com.lions.dev.repository.EstablishmentRepository;
import com.lions.dev.repository.EstablishmentSubscriptionRepository;
import com.lions.dev.repository.UsersRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* Service d'intégration Wave pour le paiement des droits d'accès des établissements.
* Crée une session de paiement Wave et traite les webhooks (payment.completed, etc.).
*/
@ApplicationScoped
public class WavePaymentService {
private static final Logger LOG = Logger.getLogger(WavePaymentService.class);
private static final int MONTHLY_AMOUNT_XOF = 15_000;
private static final int YEARLY_AMOUNT_XOF = 150_000;
@Inject
EstablishmentRepository establishmentRepository;
@Inject
EstablishmentSubscriptionRepository subscriptionRepository;
@Inject
EstablishmentPaymentRepository paymentRepository;
@Inject
UsersRepository usersRepository;
@ConfigProperty(name = "wave.api.url", defaultValue = "https://api.wave.com")
String waveApiUrl;
@ConfigProperty(name = "wave.api.key", defaultValue = "")
Optional<String> waveApiKey;
private final ObjectMapper objectMapper = new ObjectMapper();
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
/**
* Initie un paiement Wave pour un établissement (droits d'accès).
* Crée une session Wave et retourne l'URL de redirection.
*/
@Transactional
public InitiateSubscriptionResponseDTO initiatePayment(UUID establishmentId, String plan, String clientPhone) {
Establishment establishment = establishmentRepository.findById(establishmentId);
if (establishment == null) {
throw new IllegalArgumentException("Établissement non trouvé : " + establishmentId);
}
int amountXof = EstablishmentSubscription.PLAN_MONTHLY.equals(plan) ? MONTHLY_AMOUNT_XOF : YEARLY_AMOUNT_XOF;
String description = "AfterWork - Abonnement " + plan + " - " + establishment.getName();
EstablishmentSubscription subscription = new EstablishmentSubscription();
subscription.setEstablishmentId(establishmentId);
subscription.setPlan(plan);
subscription.setStatus(EstablishmentSubscription.STATUS_PENDING);
subscription.setAmountXof(amountXof);
subscriptionRepository.persist(subscription);
EstablishmentPayment payment = new EstablishmentPayment();
payment.setEstablishmentId(establishmentId);
payment.setAmountXof(amountXof);
payment.setStatus(EstablishmentPayment.STATUS_PENDING);
payment.setClientPhone(clientPhone);
payment.setPlan(plan);
paymentRepository.persist(payment);
String waveSessionId = null;
String paymentUrl = null;
String apiKey = waveApiKey.orElse("");
if (!apiKey.isBlank()) {
try {
Map<String, Object> body = new HashMap<>();
body.put("amount", amountXof);
body.put("currency", "XOF");
body.put("description", description);
body.put("client_reference", subscription.getId().toString());
body.put("customer_phone_number", clientPhone.startsWith("+") ? clientPhone : "+" + clientPhone);
String bodyJson = objectMapper.writeValueAsString(body);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(waveApiUrl + "/wave/api/v1/checkout/sessions"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
.timeout(Duration.ofSeconds(15))
.POST(HttpRequest.BodyPublishers.ofString(bodyJson))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 200 && response.statusCode() < 300) {
JsonNode node = objectMapper.readTree(response.body());
waveSessionId = node.has("id") ? node.get("id").asText() : null;
paymentUrl = node.has("payment_url") ? node.get("payment_url").asText() : null;
} else {
LOG.warn("Wave API error: " + response.statusCode() + " " + response.body());
}
} catch (Exception e) {
LOG.error("Erreur appel Wave API", e);
}
} else {
LOG.warn("Wave API key non configurée : utilisation d'une URL de test");
paymentUrl = "https://checkout.wave.com/session/test?ref=" + subscription.getId();
waveSessionId = "test-" + subscription.getId();
}
subscription.setWaveSessionId(waveSessionId);
payment.setWaveSessionId(waveSessionId);
subscriptionRepository.persist(subscription);
paymentRepository.persist(payment);
return new InitiateSubscriptionResponseDTO(
paymentUrl != null ? paymentUrl : "",
waveSessionId,
amountXof,
plan,
EstablishmentSubscription.STATUS_PENDING
);
}
/**
* Vérifie si un établissement a un abonnement actif (droits d'accès payés).
*/
public boolean hasActiveSubscription(UUID establishmentId) {
return subscriptionRepository.findActiveByEstablishmentId(establishmentId).isPresent();
}
/**
* Traite un webhook Wave (payment.completed, payment.cancelled, etc.).
* Met à jour l'abonnement et le paiement.
*/
@Transactional
public void handleWebhook(JsonNode payload) {
String eventType = payload.has("type") ? payload.get("type").asText() : null;
JsonNode data = payload.has("data") ? payload.get("data") : null;
if (data == null) return;
String sessionId = data.has("id") ? data.get("id").asText() : (data.has("session_id") ? data.get("session_id").asText() : null);
if (sessionId == null) return;
subscriptionRepository.findByWaveSessionId(sessionId).ifPresent(sub -> {
UUID establishmentId = sub.getEstablishmentId();
Establishment establishment = establishmentRepository.findById(establishmentId);
if ("payment.completed".equals(eventType)) {
sub.setStatus(EstablishmentSubscription.STATUS_ACTIVE);
sub.setPaidAt(LocalDateTime.now());
if (EstablishmentSubscription.PLAN_MONTHLY.equals(sub.getPlan())) {
sub.setExpiresAt(LocalDateTime.now().plusMonths(1));
} else {
sub.setExpiresAt(LocalDateTime.now().plusYears(1));
}
subscriptionRepository.persist(sub);
// Activer l'établissement et le manager
if (establishment != null) {
establishment.setIsActive(true);
establishmentRepository.persist(establishment);
Users manager = establishment.getManager();
if (manager != null) {
manager.setActive(true);
usersRepository.persist(manager);
LOG.info("Webhook Wave: établissement et manager activés pour " + establishmentId);
}
}
} else if ("payment.cancelled".equals(eventType) || "payment.expired".equals(eventType)
|| "payment.failed".equals(eventType)) {
sub.setStatus(EstablishmentSubscription.STATUS_CANCELLED);
subscriptionRepository.persist(sub);
// Suspendre l'établissement et le manager
if (establishment != null) {
establishment.setIsActive(false);
establishmentRepository.persist(establishment);
Users manager = establishment.getManager();
if (manager != null) {
manager.setActive(false);
usersRepository.persist(manager);
LOG.info("Webhook Wave: établissement et manager suspendus pour " + establishmentId);
}
}
}
});
paymentRepository.findByWaveSessionId(sessionId).ifPresent(payment -> {
if ("payment.completed".equals(eventType)) {
payment.setStatus(EstablishmentPayment.STATUS_COMPLETED);
} else if ("payment.cancelled".equals(eventType) || "payment.expired".equals(eventType)) {
payment.setStatus(EstablishmentPayment.STATUS_CANCELLED);
}
paymentRepository.persist(payment);
});
}
}

View File

@@ -0,0 +1,46 @@
package com.lions.dev.util;
/**
* Rôles utilisateur de l'application AfterWork.
* Hiérarchie : SUPER_ADMIN > ADMIN > MANAGER > USER.
*/
public final class UserRole {
private UserRole() {}
/** Utilisateur standard (participation aux événements, profil, amis). */
public static final String USER = "USER";
/** Responsable d'établissement (gestion de son établissement). */
public static final String MANAGER = "MANAGER";
/** Administrateur (gestion des établissements, modération). */
public static final String ADMIN = "ADMIN";
/**
* Super administrateur : tous les droits (gestion des utilisateurs, attribution des rôles,
* gestion des établissements, accès aux paiements Wave, etc.).
*/
public static final String SUPER_ADMIN = "SUPER_ADMIN";
/**
* Vérifie si le rôle a les droits super admin (ou est super admin).
*/
public static boolean isSuperAdmin(String role) {
return SUPER_ADMIN.equals(role);
}
/**
* Vérifie si le rôle peut gérer les utilisateurs (attribution de rôles, etc.).
*/
public static boolean canManageUsers(String role) {
return SUPER_ADMIN.equals(role) || ADMIN.equals(role);
}
/**
* Vérifie si le rôle peut gérer les établissements (vérification, modération).
*/
public static boolean canManageEstablishments(String role) {
return SUPER_ADMIN.equals(role) || ADMIN.equals(role);
}
}

View File

@@ -0,0 +1,36 @@
package com.lions.dev.util;
/**
* Rôles utilisateur de l'application AfterWork.
* Hiérarchie : SUPER_ADMIN > ADMIN > MANAGER > USER.
*/
public final class UserRoles {
private UserRoles() {}
/** Super administrateur : tous les droits (gestion utilisateurs, attribution de rôles, etc.). */
public static final String SUPER_ADMIN = "SUPER_ADMIN";
/** Administrateur : gestion courante de l'application. */
public static final String ADMIN = "ADMIN";
/** Manager : gestion d'établissements, événements, etc. */
public static final String MANAGER = "MANAGER";
/** Utilisateur standard. */
public static final String USER = "USER";
/**
* Indique si le rôle a les droits super administrateur (ou est SUPER_ADMIN).
*/
public static boolean isSuperAdmin(String role) {
return SUPER_ADMIN.equals(role);
}
/**
* Indique si le rôle peut gérer les utilisateurs (attribution de rôles, etc.).
*/
public static boolean canManageUsers(String role) {
return SUPER_ADMIN.equals(role) || ADMIN.equals(role);
}
}

View File

@@ -81,25 +81,30 @@ public class ChatWebSocketNext {
String userId = connection.pathParam("userId"); String userId = connection.pathParam("userId");
Log.debug("[CHAT-WS-NEXT] Message reçu de " + userId + ": " + message); Log.debug("[CHAT-WS-NEXT] Message reçu de " + userId + ": " + message);
// Parser le message JSON
com.fasterxml.jackson.databind.ObjectMapper mapper = com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper(); new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> messageData = mapper.readValue(message, Map.class); Map<String, Object> raw = mapper.readValue(message, Map.class);
String type = (String) raw.get("type");
String type = (String) messageData.get("type"); @SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) raw.get("data");
switch (type) { switch (type) {
case "message": case "message":
handleChatMessage(messageData, userId); if (data != null) handleChatMessage(data, userId);
else Log.warn("[CHAT-WS-NEXT] Message sans 'data'");
break; break;
case "typing": case "typing":
handleTypingIndicator(messageData, userId); if (data != null) handleTypingIndicator(data, userId);
break; break;
case "read": case "read":
handleReadReceipt(messageData, userId); if (data != null) handleReadReceipt(data, userId);
else Log.warn("[CHAT-WS-NEXT] Read receipt sans 'data'");
break;
case "ping":
// Heartbeat - ignorer
break; break;
default: default:
Log.warn("[CHAT-WS-NEXT] Type de message inconnu: " + type); Log.warn("[CHAT-WS-NEXT] Type inconnu: " + type);
} }
} catch (Exception e) { } catch (Exception e) {
@@ -108,16 +113,17 @@ public class ChatWebSocketNext {
} }
/** /**
* Gère l'envoi d'un message de chat. * Gère l'envoi d'un message de chat via WebSocket.
* Le message est traité par MessageService qui publiera dans Kafka. * Note: L'envoi principal passe par REST (POST /messages). Cette méthode
* est pour compatibilité si le client envoie via WebSocket.
*/ */
private void handleChatMessage(Map<String, Object> messageData, String senderId) { private void handleChatMessage(Map<String, Object> data, String senderId) {
try { try {
UUID senderUUID = UUID.fromString(senderId); UUID senderUUID = UUID.fromString(senderId);
UUID recipientUUID = UUID.fromString((String) messageData.get("recipientId")); UUID recipientUUID = UUID.fromString((String) data.get("recipientId"));
String content = (String) messageData.get("content"); String content = (String) data.get("content");
String messageType = messageData.getOrDefault("messageType", "text").toString(); String messageType = data.getOrDefault("messageType", "text").toString();
String mediaUrl = (String) messageData.get("mediaUrl"); String mediaUrl = (String) data.get("mediaUrl");
// Enregistrer le message dans la base de données // Enregistrer le message dans la base de données
// MessageService publiera automatiquement dans Kafka // MessageService publiera automatiquement dans Kafka
@@ -146,13 +152,21 @@ public class ChatWebSocketNext {
/** /**
* Gère les indicateurs de frappe. * Gère les indicateurs de frappe.
* data doit contenir recipientId (ID du destinataire) et isTyping.
*/ */
private void handleTypingIndicator(Map<String, Object> messageData, String userId) { private void handleTypingIndicator(Map<String, Object> data, String userId) {
try { try {
UUID recipientUUID = UUID.fromString((String) messageData.get("recipientId")); Object recipientIdObj = data.get("recipientId");
boolean isTyping = (boolean) messageData.getOrDefault("isTyping", false); if (recipientIdObj == null) {
Log.warn("[CHAT-WS-NEXT] Typing sans recipientId - ignoré");
return;
}
UUID recipientUUID = UUID.fromString(recipientIdObj.toString());
Object isTypingObj = data.get("isTyping");
boolean isTyping = isTypingObj instanceof Boolean ? (Boolean) isTypingObj : Boolean.parseBoolean(String.valueOf(isTypingObj));
String response = buildJsonMessage("typing", Map.of( String response = buildJsonMessage("typing", Map.of(
"conversationId", data.getOrDefault("conversationId", ""),
"userId", userId, "userId", userId,
"isTyping", isTyping "isTyping", isTyping
)); ));
@@ -168,22 +182,22 @@ public class ChatWebSocketNext {
/** /**
* Gère les confirmations de lecture. * Gère les confirmations de lecture.
* Envoie type "read" (format attendu par le client Flutter).
*/ */
private void handleReadReceipt(Map<String, Object> messageData, String userId) { private void handleReadReceipt(Map<String, Object> data, String userId) {
try { try {
UUID messageUUID = UUID.fromString((String) messageData.get("messageId")); UUID messageUUID = UUID.fromString((String) data.get("messageId"));
// Marquer le message comme lu
Message message = messageService.markMessageAsRead(messageUUID); Message message = messageService.markMessageAsRead(messageUUID);
if (message != null) { if (message != null) {
// Envoyer confirmation de lecture à l'expéditeur via WebSocket
// (sera aussi publié dans Kafka par MessageService)
UUID senderUUID = message.getSender().getId(); UUID senderUUID = message.getSender().getId();
String response = buildJsonMessage("read_receipt", Map.of( long now = System.currentTimeMillis();
String timestampIso = java.time.Instant.ofEpochMilli(now).toString();
String response = buildJsonMessage("read", Map.of(
"messageId", messageUUID.toString(), "messageId", messageUUID.toString(),
"readBy", userId, "userId", userId,
"readAt", System.currentTimeMillis() "timestamp", timestampIso
)); ));
sendToUser(senderUUID, response); sendToUser(senderUUID, response);

View File

@@ -3,48 +3,51 @@ package com.lions.dev.websocket.bridge;
import com.lions.dev.dto.events.ChatMessageEvent; import com.lions.dev.dto.events.ChatMessageEvent;
import com.lions.dev.websocket.ChatWebSocketNext; import com.lions.dev.websocket.ChatWebSocketNext;
import io.quarkus.logging.Log; import io.quarkus.logging.Log;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.reactive.messaging.Incoming; import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message; import org.eclipse.microprofile.reactive.messaging.Message;
import java.util.UUID; import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletionStage; import java.util.concurrent.CompletionStage;
/** /**
* Bridge qui consomme depuis Kafka et envoie via WebSocket pour le chat. * Bridge qui consomme depuis Kafka et envoie via WebSocket pour le chat.
* *
* Architecture: * Architecture (best practice):
* MessageService → Kafka Topic (chat.messages) → Bridge → WebSocket → Client * MessageService → Kafka Topic (chat.messages) → Bridge → WebSocket → Client
*/ */
@ApplicationScoped @ApplicationScoped
public class ChatKafkaBridge { public class ChatKafkaBridge {
@PostConstruct
public void init() {
Log.info("[KAFKA-BRIDGE] Bridge démarré pour topic: chat.messages");
}
/** /**
* Consomme les messages chat depuis Kafka et les route vers WebSocket. * Consomme les messages chat depuis Kafka et les route vers WebSocket.
*
* @param message Message Kafka contenant un ChatMessageEvent
* @return CompletionStage pour gérer l'ack/nack asynchrone
*/ */
@Incoming("kafka-chat") @Incoming("kafka-chat")
public CompletionStage<Void> processChatMessage(Message<ChatMessageEvent> message) { public CompletionStage<Void> processChatMessage(Message<ChatMessageEvent> message) {
try { try {
ChatMessageEvent event = message.getPayload(); ChatMessageEvent event = message.getPayload();
Log.debug("[CHAT-BRIDGE] Message reçu: " + event.getEventType() + Log.debug("[CHAT-BRIDGE] Événement reçu: " + event.getEventType() +
" de " + event.getSenderId() + " à " + event.getRecipientId()); " de " + event.getSenderId() + " à " + event.getRecipientId());
UUID recipientId = UUID.fromString(event.getRecipientId()); UUID recipientId = UUID.fromString(event.getRecipientId());
// Construire le message JSON pour WebSocket
String wsMessage = buildWebSocketMessage(event); String wsMessage = buildWebSocketMessage(event);
// Envoyer via WebSocket au destinataire
ChatWebSocketNext.sendMessageToUser(recipientId, wsMessage); ChatWebSocketNext.sendMessageToUser(recipientId, wsMessage);
Log.debug("[CHAT-BRIDGE] Message routé vers WebSocket pour: " + event.getRecipientId()); Log.debug("[CHAT-BRIDGE] Message routé vers WebSocket pour: " + event.getRecipientId());
// Acknowledger le message Kafka
return message.ack(); return message.ack();
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
@@ -57,29 +60,66 @@ public class ChatKafkaBridge {
} }
/** /**
* Construit le message JSON pour WebSocket à partir de l'événement Kafka. * Construit le message JSON pour WebSocket (format attendu par le client Flutter).
*/ */
private String buildWebSocketMessage(ChatMessageEvent event) { private String buildWebSocketMessage(ChatMessageEvent event) {
try { try {
com.fasterxml.jackson.databind.ObjectMapper mapper = com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper(); new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> messageData = new java.util.HashMap<>(); String eventType = event.getEventType() != null ? event.getEventType() : "message";
messageData.put("id", event.getMessageId()); long ts = event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis();
messageData.put("conversationId", event.getConversationId()); String timestampIso = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(ts));
messageData.put("senderId", event.getSenderId());
messageData.put("recipientId", event.getRecipientId()); Map<String, Object> data;
messageData.put("content", event.getContent()); String type;
messageData.put("timestamp", event.getTimestamp());
if (event.getMetadata() != null) { switch (eventType) {
messageData.putAll(event.getMetadata()); case "delivery_confirmation":
type = "delivered";
data = new HashMap<>();
data.put("messageId", event.getMessageId());
data.put("isDelivered", event.getMetadata() != null &&
Boolean.TRUE.equals(event.getMetadata().get("isDelivered")));
data.put("timestamp", ts);
break;
case "read_confirmation":
type = "read";
data = new HashMap<>();
data.put("messageId", event.getMessageId());
data.put("userId", event.getMetadata() != null ?
event.getMetadata().get("readBy") : event.getSenderId());
data.put("timestamp", timestampIso);
break;
case "message":
default:
type = "message";
data = new HashMap<>();
data.put("id", event.getMessageId());
data.put("conversationId", event.getConversationId());
data.put("senderId", event.getSenderId());
data.put("recipientId", event.getRecipientId());
data.put("content", event.getContent() != null ? event.getContent() : "");
data.put("timestamp", timestampIso);
data.put("isRead", event.getMetadata() != null &&
Boolean.TRUE.equals(event.getMetadata().get("isRead")));
data.put("isDelivered", true);
if (event.getMetadata() != null) {
data.put("senderFirstName", event.getMetadata().getOrDefault("senderFirstName", ""));
data.put("senderLastName", event.getMetadata().getOrDefault("senderLastName", ""));
data.put("senderProfileImageUrl", event.getMetadata().getOrDefault("senderProfileImageUrl", ""));
data.put("attachmentUrl", event.getMetadata().getOrDefault("attachmentUrl", ""));
data.put("attachmentType", event.getMetadata().getOrDefault("attachmentType", "text"));
}
break;
} }
java.util.Map<String, Object> wsMessage = java.util.Map.of( Map<String, Object> wsMessage = new HashMap<>();
"type", event.getEventType() != null ? event.getEventType() : "message", wsMessage.put("type", type);
"data", messageData, wsMessage.put("data", data);
"timestamp", event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis() wsMessage.put("timestamp", ts);
);
return mapper.writeValueAsString(wsMessage); return mapper.writeValueAsString(wsMessage);
} catch (Exception e) { } catch (Exception e) {

View File

@@ -3,6 +3,7 @@ package com.lions.dev.websocket.bridge;
import com.lions.dev.dto.events.NotificationEvent; import com.lions.dev.dto.events.NotificationEvent;
import com.lions.dev.websocket.NotificationWebSocketNext; import com.lions.dev.websocket.NotificationWebSocketNext;
import io.quarkus.logging.Log; import io.quarkus.logging.Log;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.reactive.messaging.Incoming; import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message; import org.eclipse.microprofile.reactive.messaging.Message;
@@ -25,6 +26,11 @@ import java.util.concurrent.CompletionStage;
@ApplicationScoped @ApplicationScoped
public class NotificationKafkaBridge { public class NotificationKafkaBridge {
@PostConstruct
public void init() {
Log.info("[KAFKA-BRIDGE] Bridge démarré pour topic: notifications");
}
/** /**
* Consomme les événements depuis Kafka et les route vers WebSocket. * Consomme les événements depuis Kafka et les route vers WebSocket.
* *

View File

@@ -3,6 +3,7 @@ package com.lions.dev.websocket.bridge;
import com.lions.dev.dto.events.PresenceEvent; import com.lions.dev.dto.events.PresenceEvent;
import com.lions.dev.websocket.NotificationWebSocketNext; import com.lions.dev.websocket.NotificationWebSocketNext;
import io.quarkus.logging.Log; import io.quarkus.logging.Log;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.reactive.messaging.Incoming; import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message; import org.eclipse.microprofile.reactive.messaging.Message;
@@ -23,6 +24,11 @@ import java.util.concurrent.CompletionStage;
@ApplicationScoped @ApplicationScoped
public class PresenceKafkaBridge { public class PresenceKafkaBridge {
@PostConstruct
public void init() {
Log.info("[KAFKA-BRIDGE] Bridge démarré pour topic: presence.updates");
}
/** /**
* Consomme les événements de présence depuis Kafka et les route vers WebSocket. * Consomme les événements de présence depuis Kafka et les route vers WebSocket.
* *

View File

@@ -3,6 +3,7 @@ package com.lions.dev.websocket.bridge;
import com.lions.dev.dto.events.ReactionEvent; import com.lions.dev.dto.events.ReactionEvent;
import com.lions.dev.websocket.NotificationWebSocketNext; import com.lions.dev.websocket.NotificationWebSocketNext;
import io.quarkus.logging.Log; import io.quarkus.logging.Log;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.reactive.messaging.Incoming; import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message; import org.eclipse.microprofile.reactive.messaging.Message;
@@ -22,6 +23,11 @@ import java.util.concurrent.CompletionStage;
@ApplicationScoped @ApplicationScoped
public class ReactionKafkaBridge { public class ReactionKafkaBridge {
@PostConstruct
public void init() {
Log.info("[KAFKA-BRIDGE] Bridge démarré pour topic: reactions");
}
/** /**
* Consomme les réactions depuis Kafka et les route vers WebSocket. * Consomme les réactions depuis Kafka et les route vers WebSocket.
* *

View File

@@ -4,6 +4,13 @@
# Ce fichier est automatiquement chargé avec: mvn quarkus:dev # Ce fichier est automatiquement chargé avec: mvn quarkus:dev
# Les configurations ici surchargent celles de application.properties # Les configurations ici surchargent celles de application.properties
# ====================================================================
# Super administrateur (dev)
# ====================================================================
# En dev, clé par défaut pour PUT /users/{id}/role (header X-Super-Admin-Key).
# Saisir "dev-super-admin-key" dans l'app (Paramètres → Super Admin) pour attribuer des rôles.
afterwork.super-admin.api-key=${SUPER_ADMIN_API_KEY:dev-super-admin-key}
# ==================================================================== # ====================================================================
# Base de données H2 (en mémoire) # Base de données H2 (en mémoire)
# ==================================================================== # ====================================================================
@@ -24,6 +31,12 @@ quarkus.hibernate-orm.packages=com.lions.dev.entity
# Forcer la création du schéma au démarrage # Forcer la création du schéma au démarrage
quarkus.hibernate-orm.schema-generation.scripts.action=drop-and-create quarkus.hibernate-orm.schema-generation.scripts.action=drop-and-create
# ====================================================================
# Kafka (développement local)
# ====================================================================
# En dev, défaut localhost:9092. Définir KAFKA_BOOTSTRAP_SERVERS si Kafka tourne ailleurs.
kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
# ==================================================================== # ====================================================================
# Logging # Logging
# ==================================================================== # ====================================================================

View File

@@ -1,8 +1,9 @@
# ==================================================================== # ====================================================================
# AfterWork Server - Configuration PRODUCTION # AfterWork Server - Configuration PRODUCTION (profil prod)
# ==================================================================== # ====================================================================
# Ce fichier est automatiquement chargé avec: java -jar app.jar # Chargé avec QUARKUS_PROFILE=prod (Kubernetes ConfigMap).
# Les configurations ici surchargent celles de application.properties # Ce fichier remplace application-production.properties pour cohérence
# avec le déploiement (QUARKUS_PROFILE=prod).
# ==================================================================== # ====================================================================
# HTTP - Chemin de base de l'API # HTTP - Chemin de base de l'API
@@ -19,34 +20,26 @@
# - pathType: Prefix # - pathType: Prefix
# - PAS d'annotation rewrite-target # - PAS d'annotation rewrite-target
# #
# Pourquoi cette approche ?
# - Swagger UI nécessite que l'application connaisse son contexte
# - Les URLs générées (OpenAPI, WebSocket) sont correctes
# - Cohérent avec les applications context-aware (btpxpress, etc.)
quarkus.http.root-path=/afterwork quarkus.http.root-path=/afterwork
# ==================================================================== # ====================================================================
# Swagger/OpenAPI (Production) # Swagger/OpenAPI (Production)
# ==================================================================== # ====================================================================
# Configuration pour que Swagger UI fonctionne avec root-path
quarkus.swagger-ui.enable=true quarkus.swagger-ui.enable=true
quarkus.swagger-ui.always-include=true quarkus.swagger-ui.always-include=true
quarkus.swagger-ui.path=/q/swagger-ui quarkus.swagger-ui.path=/q/swagger-ui
# Configuration du chemin OpenAPI (relatif au root-path)
quarkus.smallrye-openapi.path=/openapi quarkus.smallrye-openapi.path=/openapi
# Configuration des serveurs OpenAPI pour que Swagger UI génère les bonnes URLs quarkus.smallrye-openapi.servers=https://api.lions.dev
quarkus.smallrye-openapi.servers=https://api.lions.dev/afterwork
# Configuration explicite de l'URL OpenAPI pour Swagger UI
# Essayer avec URL absolue pour forcer le bon chemin
quarkus.swagger-ui.urls.default=https://api.lions.dev/afterwork/openapi
# ==================================================================== # ====================================================================
# Base de données PostgreSQL # Base de données PostgreSQL
# ==================================================================== # ====================================================================
# IMPORTANT: Les credentials doivent être fournis via variables d'environnement
# en production (Kubernetes Secrets ou Vault).
quarkus.datasource.db-kind=postgresql quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:postgresql}:${DB_PORT:5432}/${DB_NAME:mic-after-work-server-impl-quarkus-main} quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:postgresql-service.postgresql.svc.cluster.local}:${DB_PORT:5432}/${DB_NAME:mic-after-work-server-impl-quarkus-main}
quarkus.datasource.username=${DB_USERNAME:lionsuser} quarkus.datasource.username=${DB_USERNAME:lionsuser}
quarkus.datasource.password=${DB_PASSWORD:LionsUser2025!} quarkus.datasource.password=${DB_PASSWORD}
quarkus.datasource.jdbc.driver=org.postgresql.Driver quarkus.datasource.jdbc.driver=org.postgresql.Driver
quarkus.datasource.jdbc.max-size=20 quarkus.datasource.jdbc.max-size=20
quarkus.datasource.jdbc.min-size=5 quarkus.datasource.jdbc.min-size=5
@@ -94,42 +87,18 @@ quarkus.log.category."io.quarkus".level=INFO
# ==================================================================== # ====================================================================
# Kafka Configuration (Production) # Kafka Configuration (Production)
# ==================================================================== # ====================================================================
# Kafka est deploye dans le namespace 'kafka' du cluster Kubernetes
# Service: kafka-service.kafka.svc.cluster.local:9092
kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:kafka-service.kafka.svc.cluster.local:9092} kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:kafka-service.kafka.svc.cluster.local:9092}
# Configuration de resilience Kafka
mp.messaging.connector.smallrye-kafka.health-enabled=true mp.messaging.connector.smallrye-kafka.health-enabled=true
mp.messaging.connector.smallrye-kafka.health-readiness-enabled=true mp.messaging.connector.smallrye-kafka.health-readiness-enabled=true
# Topics auto-crees par Quarkus SmallRye Kafka:
# - notifications
# - chat.messages
# - reactions
# - presence.updates
# ==================================================================== # ====================================================================
# WebSocket # WebSocket / SSL / Performance / Localisation
# ====================================================================
# Note: La propriété quarkus.websocket.max-frame-size n'existe pas dans Quarkus 3.16
# Les WebSockets Next utilisent une configuration différente si nécessaire
# ====================================================================
# SSL/TLS (géré par le reverse proxy)
# ==================================================================== # ====================================================================
quarkus.http.ssl.certificate.files= quarkus.http.ssl.certificate.files=
quarkus.http.ssl.certificate.key-files= quarkus.http.ssl.certificate.key-files=
quarkus.http.insecure-requests=enabled quarkus.http.insecure-requests=enabled
# ====================================================================
# Performance
# ====================================================================
quarkus.thread-pool.core-threads=2 quarkus.thread-pool.core-threads=2
quarkus.thread-pool.max-threads=16 quarkus.thread-pool.max-threads=16
quarkus.thread-pool.queue-size=100 quarkus.thread-pool.queue-size=100
# ====================================================================
# Localisation
# ====================================================================
quarkus.locales=fr-FR,en-US quarkus.locales=fr-FR,en-US
quarkus.default-locale=fr-FR quarkus.default-locale=fr-FR

View File

@@ -17,6 +17,23 @@ quarkus.swagger-ui.always-include=true
quarkus.swagger-ui.path=/q/swagger-ui quarkus.swagger-ui.path=/q/swagger-ui
quarkus.smallrye-openapi.path=/openapi quarkus.smallrye-openapi.path=/openapi
# ====================================================================
# Super administrateur (créé au démarrage si absent)
# ====================================================================
# En production, définir via variables d'environnement (SUPER_ADMIN_EMAIL, SUPER_ADMIN_PASSWORD).
afterwork.super-admin.email=${SUPER_ADMIN_EMAIL:superadmin@afterwork.lions.dev}
afterwork.super-admin.password=${SUPER_ADMIN_PASSWORD:SuperAdmin2025!}
afterwork.super-admin.first-name=${SUPER_ADMIN_FIRST_NAME:Super}
afterwork.super-admin.last-name=${SUPER_ADMIN_LAST_NAME:Administrator}
# Clé secrète pour les opérations admin (header X-Super-Admin-Key sur PUT /users/{id}/role, etc.)
afterwork.super-admin.api-key=${SUPER_ADMIN_API_KEY:}
# ====================================================================
# Wave API (paiement droits d'accès établissements)
# ====================================================================
wave.api.url=${WAVE_API_URL:https://api.wave.com}
wave.api.key=${WAVE_API_KEY:}
# ==================================================================== # ====================================================================
# HTTP (commun à tous les environnements) # HTTP (commun à tous les environnements)
# ==================================================================== # ====================================================================
@@ -62,24 +79,28 @@ kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:kafka-service.kafka.svc.cluste
# ==================================================================== # ====================================================================
# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter<EventType> # Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter<EventType>
# Topic: Notifications # Topic: Notifications
# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter<NotificationEvent>
mp.messaging.outgoing.notifications.connector=smallrye-kafka mp.messaging.outgoing.notifications.connector=smallrye-kafka
mp.messaging.outgoing.notifications.topic=notifications mp.messaging.outgoing.notifications.topic=notifications
mp.messaging.outgoing.notifications.key.serializer=org.apache.kafka.common.serialization.StringSerializer mp.messaging.outgoing.notifications.key.serializer=org.apache.kafka.common.serialization.StringSerializer
# value.serializer omis - Quarkus génère automatiquement depuis Emitter<NotificationEvent> # value.serializer omis - Quarkus génère automatiquement depuis Emitter<NotificationEvent>
# Topic: Chat Messages # Topic: Chat Messages
# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter<ChatMessageEvent>
mp.messaging.outgoing.chat-messages.connector=smallrye-kafka mp.messaging.outgoing.chat-messages.connector=smallrye-kafka
mp.messaging.outgoing.chat-messages.topic=chat.messages 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.key.serializer=org.apache.kafka.common.serialization.StringSerializer
# value.serializer omis - Quarkus génère automatiquement depuis Emitter<ChatMessageEvent> # value.serializer omis - Quarkus génère automatiquement depuis Emitter<ChatMessageEvent>
# Topic: Reactions (likes, comments, shares) # Topic: Reactions (likes, comments, shares)
# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter<ReactionEvent>
mp.messaging.outgoing.reactions.connector=smallrye-kafka mp.messaging.outgoing.reactions.connector=smallrye-kafka
mp.messaging.outgoing.reactions.topic=reactions mp.messaging.outgoing.reactions.topic=reactions
mp.messaging.outgoing.reactions.key.serializer=org.apache.kafka.common.serialization.StringSerializer mp.messaging.outgoing.reactions.key.serializer=org.apache.kafka.common.serialization.StringSerializer
# value.serializer omis - Quarkus génère automatiquement depuis Emitter<ReactionEvent> # value.serializer omis - Quarkus génère automatiquement depuis Emitter<ReactionEvent>
# Topic: Presence Updates # Topic: Presence Updates
# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter<PresenceEvent>
mp.messaging.outgoing.presence.connector=smallrye-kafka mp.messaging.outgoing.presence.connector=smallrye-kafka
mp.messaging.outgoing.presence.topic=presence.updates mp.messaging.outgoing.presence.topic=presence.updates
mp.messaging.outgoing.presence.key.serializer=org.apache.kafka.common.serialization.StringSerializer mp.messaging.outgoing.presence.key.serializer=org.apache.kafka.common.serialization.StringSerializer

View File

@@ -0,0 +1,65 @@
-- Migration V12: Nettoyage des colonnes legacy de la table users
-- Date: 2026-01-26
-- Description: Supprime les anciennes colonnes (nom, prenoms, mot_de_passe) qui coexistent
-- avec les nouvelles (first_name, last_name, password_hash) suite à la migration V3.
--
-- Contexte: La migration V3 devait renommer les colonnes, mais Hibernate avait déjà créé
-- les nouvelles colonnes. Résultat: la table avait les deux sets de colonnes.
DO $$
BEGIN
-- Étape 1: S'assurer que les nouvelles colonnes existent et ont des données
-- Si first_name est vide mais nom a des données, copier
UPDATE users SET first_name = nom
WHERE nom IS NOT NULL
AND nom != ''
AND (first_name IS NULL OR first_name = '');
UPDATE users SET last_name = prenoms
WHERE prenoms IS NOT NULL
AND prenoms != ''
AND (last_name IS NULL OR last_name = '');
UPDATE users SET password_hash = mot_de_passe
WHERE mot_de_passe IS NOT NULL
AND mot_de_passe != ''
AND (password_hash IS NULL OR password_hash = '');
-- Étape 2: Rendre les anciennes colonnes nullable (si elles existent et sont NOT NULL)
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'nom') THEN
ALTER TABLE users ALTER COLUMN nom DROP NOT NULL;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'prenoms') THEN
ALTER TABLE users ALTER COLUMN prenoms DROP NOT NULL;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'mot_de_passe') THEN
ALTER TABLE users ALTER COLUMN mot_de_passe DROP NOT NULL;
END IF;
-- Étape 3: Supprimer les anciennes colonnes (optionnel - décommenter si vous voulez un schéma propre)
-- Note: Gardées pour l'instant pour compatibilité avec d'éventuels anciens clients
/*
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'nom') THEN
ALTER TABLE users DROP COLUMN nom;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'prenoms') THEN
ALTER TABLE users DROP COLUMN prenoms;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'mot_de_passe') THEN
ALTER TABLE users DROP COLUMN mot_de_passe;
END IF;
*/
END $$;
-- Commentaire explicatif
COMMENT ON TABLE users IS 'Table utilisateurs v2.0 - Colonnes legacy (nom, prenoms, mot_de_passe) dépréciées, utiliser first_name, last_name, password_hash';

View File

@@ -0,0 +1,57 @@
-- Migration V13: Abonnements et paiements établissements (Wave)
-- Date: 2026-01-28
-- Description: Tables pour les droits d'accès des établissements payés via Wave
-- Abonnements établissements
CREATE TABLE IF NOT EXISTS establishment_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
establishment_id UUID NOT NULL,
plan VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
wave_session_id VARCHAR(255),
amount_xof INTEGER,
paid_at TIMESTAMP,
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_establishment_subscriptions_establishment
FOREIGN KEY (establishment_id) REFERENCES establishments(id) ON DELETE CASCADE,
CONSTRAINT chk_establishment_subscriptions_plan
CHECK (plan IN ('MONTHLY', 'YEARLY')),
CONSTRAINT chk_establishment_subscriptions_status
CHECK (status IN ('PENDING', 'ACTIVE', 'EXPIRED', 'CANCELLED'))
);
CREATE INDEX IF NOT EXISTS idx_establishment_subscriptions_establishment
ON establishment_subscriptions(establishment_id);
CREATE INDEX IF NOT EXISTS idx_establishment_subscriptions_status
ON establishment_subscriptions(status);
CREATE INDEX IF NOT EXISTS idx_establishment_subscriptions_wave_session
ON establishment_subscriptions(wave_session_id);
-- Paiements établissements (historique Wave)
CREATE TABLE IF NOT EXISTS establishment_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
establishment_id UUID NOT NULL,
amount_xof INTEGER NOT NULL,
wave_session_id VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
client_phone VARCHAR(30),
plan VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_establishment_payments_establishment
FOREIGN KEY (establishment_id) REFERENCES establishments(id) ON DELETE CASCADE,
CONSTRAINT chk_establishment_payments_status
CHECK (status IN ('PENDING', 'COMPLETED', 'FAILED', 'CANCELLED'))
);
CREATE INDEX IF NOT EXISTS idx_establishment_payments_establishment
ON establishment_payments(establishment_id);
CREATE INDEX IF NOT EXISTS idx_establishment_payments_wave_session
ON establishment_payments(wave_session_id);
CREATE INDEX IF NOT EXISTS idx_establishment_payments_status
ON establishment_payments(status);
COMMENT ON TABLE establishment_subscriptions IS 'Abonnements / droits d''accès des établissements (paiement Wave)';
COMMENT ON TABLE establishment_payments IS 'Historique des paiements Wave pour les établissements';

View File

@@ -0,0 +1,9 @@
-- V14: Ajout is_active pour Manager & Abonnement (suspension automatique Wave)
-- users.is_active : false = manager suspendu (paiement échoué/annulé)
-- establishments.is_active : false = établissement masqué (abonnement inactif)
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE establishments ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
COMMENT ON COLUMN users.is_active IS 'false = compte suspendu (ex: manager abonnement expiré)';
COMMENT ON COLUMN establishments.is_active IS 'false = établissement masqué (abonnement inactif)';

View File

@@ -0,0 +1,38 @@
-- Migration V15: Création de la table social_posts (Publications)
-- Date: 2026-01-28
-- Description: Table des posts sociaux pour le fil d'actualité AfterWork (correction 500)
CREATE TABLE IF NOT EXISTS social_posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
content VARCHAR(2000) NOT NULL,
user_id UUID NOT NULL,
image_url VARCHAR(500),
likes_count INTEGER NOT NULL DEFAULT 0,
comments_count INTEGER NOT NULL DEFAULT 0,
shares_count INTEGER NOT NULL DEFAULT 0,
CONSTRAINT fk_social_posts_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_social_posts_user_id ON social_posts(user_id);
CREATE INDEX IF NOT EXISTS idx_social_posts_created_at ON social_posts(created_at DESC);
CREATE OR REPLACE FUNCTION update_social_posts_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_social_posts_updated_at
BEFORE UPDATE ON social_posts
FOR EACH ROW
EXECUTE FUNCTION update_social_posts_updated_at();
COMMENT ON TABLE social_posts IS 'Publications (posts) sociaux du fil AfterWork';

View File

@@ -0,0 +1,81 @@
-- V1_1__Create_Base_Tables_For_Fresh_Db.sql
-- Création des tables de base pour une base de données vierge.
-- À exécuter après V1__Baseline lorsque le schéma n'a pas été créé par Hibernate.
-- Les migrations V2, V3, V4, V7+ supposent que users, establishments et events existent.
-- Table users (structure minimale attendue par V3 et l'entité Users)
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
first_name VARCHAR(100) NOT NULL DEFAULT '',
last_name VARCHAR(100) NOT NULL DEFAULT '',
email VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL DEFAULT '',
role VARCHAR(50) NOT NULL,
profile_image_url VARCHAR(500),
bio VARCHAR(500),
loyalty_points INTEGER NOT NULL DEFAULT 0,
preferences JSONB NOT NULL DEFAULT '{}',
is_verified BOOLEAN NOT NULL DEFAULT false,
is_online BOOLEAN NOT NULL DEFAULT false,
last_seen TIMESTAMP,
is_active BOOLEAN NOT NULL DEFAULT true
);
-- Table establishments (V4 renomme total_ratings_count -> total_reviews_count)
CREATE TABLE IF NOT EXISTS establishments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
name VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL,
address VARCHAR(500) NOT NULL,
city VARCHAR(100) NOT NULL,
postal_code VARCHAR(20) NOT NULL,
description VARCHAR(2000),
phone_number VARCHAR(50),
website VARCHAR(500),
average_rating DOUBLE PRECISION,
total_ratings_count INTEGER,
price_range VARCHAR(20),
verification_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
is_active BOOLEAN NOT NULL DEFAULT true,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
manager_id UUID NOT NULL REFERENCES users(id)
);
-- Table events (structure minimale ; V2 et V7 ajoutent les colonnes supplémentaires)
CREATE TABLE IF NOT EXISTS events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
title VARCHAR(255) NOT NULL,
description VARCHAR(1000),
start_date TIMESTAMP NOT NULL,
end_date TIMESTAMP NOT NULL,
category VARCHAR(100),
link VARCHAR(500),
image_url VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'OPEN',
creator_id UUID NOT NULL REFERENCES users(id)
);
-- Table de jointure event_participants (events <-> users)
CREATE TABLE IF NOT EXISTS event_participants (
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (event_id, user_id)
);
-- Table de jointure user_favorite_events (users <-> events)
CREATE TABLE IF NOT EXISTS user_favorite_events (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, event_id)
);
-- Index pour les FK et recherches courantes
CREATE INDEX IF NOT EXISTS idx_establishments_manager ON establishments(manager_id);
CREATE INDEX IF NOT EXISTS idx_events_creator ON events(creator_id);

View File

@@ -1,13 +1,38 @@
-- Migration V3: Migration de la table users vers l'architecture v2.0 -- Migration V3: Migration de la table users vers l'architecture v2.0
-- Date: 2026-01-15 -- Date: 2026-01-15
-- Description: Renommage des colonnes et ajout des nouveaux champs pour l'entité User -- Description: Renommage des colonnes et ajout des nouveaux champs pour l'entité User
-- Note: Migration rendue idempotente pour éviter les conflits avec Hibernate
-- Renommer les colonnes existantes -- Renommer/migrer les colonnes existantes (idempotent)
ALTER TABLE users RENAME COLUMN nom TO first_name; DO $$
ALTER TABLE users RENAME COLUMN prenoms TO last_name; BEGIN
ALTER TABLE users RENAME COLUMN mot_de_passe TO password_hash; -- Si first_name n'existe pas mais nom existe, renommer
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='first_name')
AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='nom') THEN
ALTER TABLE users RENAME COLUMN nom TO first_name;
-- Si first_name n'existe pas du tout, le créer
ELSIF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='first_name') THEN
ALTER TABLE users ADD COLUMN first_name VARCHAR(100) NOT NULL DEFAULT '';
END IF;
-- Ajouter les nouvelles colonnes -- Si last_name n'existe pas mais prenoms existe, renommer
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='last_name')
AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='prenoms') THEN
ALTER TABLE users RENAME COLUMN prenoms TO last_name;
ELSIF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='last_name') THEN
ALTER TABLE users ADD COLUMN last_name VARCHAR(100) NOT NULL DEFAULT '';
END IF;
-- Si password_hash n'existe pas mais mot_de_passe existe, renommer
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='password_hash')
AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='mot_de_passe') THEN
ALTER TABLE users RENAME COLUMN mot_de_passe TO password_hash;
ELSIF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='password_hash') THEN
ALTER TABLE users ADD COLUMN password_hash VARCHAR(255) NOT NULL DEFAULT '';
END IF;
END $$;
-- Ajouter les nouvelles colonnes (déjà idempotent)
ALTER TABLE users ADD COLUMN IF NOT EXISTS bio VARCHAR(500); ALTER TABLE users ADD COLUMN IF NOT EXISTS bio VARCHAR(500);
ALTER TABLE users ADD COLUMN IF NOT EXISTS loyalty_points INTEGER DEFAULT 0 NOT NULL; ALTER TABLE users ADD COLUMN IF NOT EXISTS loyalty_points INTEGER DEFAULT 0 NOT NULL;

View File

@@ -47,10 +47,11 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- EXECUTE PROCEDURE pour compatibilité PostgreSQL < 11 (EXECUTE FUNCTION à partir de PG 11)
CREATE TRIGGER trigger_update_business_hours_updated_at CREATE TRIGGER trigger_update_business_hours_updated_at
BEFORE UPDATE ON business_hours BEFORE UPDATE ON business_hours
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_business_hours_updated_at(); EXECUTE PROCEDURE update_business_hours_updated_at();
-- Commentaires pour documentation -- Commentaires pour documentation
COMMENT ON TABLE business_hours IS 'Horaires d''ouverture des établissements (v2.0)'; COMMENT ON TABLE business_hours IS 'Horaires d''ouverture des établissements (v2.0)';