From ce89face738bed1437fe7044856b5331200c357d Mon Sep 17 00:00:00 2001 From: dahoud Date: Thu, 29 Jan 2026 00:44:40 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v2.0=20=E2=80=93=20r=C3=A9org=20docker/?= =?UTF-8?q?scripts,=20prod,=20r=C3=A9sas,=20abonnements=20Wave,=20Flyway?= =?UTF-8?q?=20base=20vierge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 4 + DEPLOYMENT.md | 14 +- DIAGNOSTIC_KAFKA_WEBSOCKET.md | 257 ++++++++++++++++++ QUICK_DEPLOY.md | 14 +- docker-compose.yml | 52 ---- Dockerfile => docker/Dockerfile | 4 +- Dockerfile.prod => docker/Dockerfile.prod | 2 +- docker/README.md | 54 ++++ docker/docker-compose.yml | 26 ++ kubernetes/afterwork-ingress.yaml | 9 +- scripts/README.md | 20 ++ deploy.ps1 => scripts/deploy.ps1 | 85 +++--- .../lions/dev/config/SuperAdminStartup.java | 59 ++++ .../booking/ReservationCreateRequestDTO.java | 66 +++++ .../InitiateSubscriptionRequestDTO.java | 25 ++ .../FriendshipCreateOneRequestDTO.java | 4 + .../request/users/AssignRoleRequestDTO.java | 26 ++ .../admin/AdminRevenueResponseDTO.java | 20 ++ .../admin/ManagerStatsResponseDTO.java | 26 ++ .../booking/ReservationResponseDTO.java | 45 +++ .../InitiateSubscriptionResponseDTO.java | 22 ++ .../response/users/UserCreateResponseDTO.java | 2 + .../entity/establishment/Establishment.java | 4 + .../establishment/EstablishmentPayment.java | 48 ++++ .../EstablishmentSubscription.java | 56 ++++ .../lions/dev/entity/social/SocialPost.java | 4 +- .../com/lions/dev/entity/users/Users.java | 4 + .../dev/repository/BookingRepository.java | 16 ++ .../EstablishmentPaymentRepository.java | 21 ++ .../repository/EstablishmentRepository.java | 11 + .../EstablishmentSubscriptionRepository.java | 26 ++ .../dev/repository/EventsRepository.java | 20 ++ .../dev/repository/FriendshipRepository.java | 9 +- .../dev/resource/AdminStatsResource.java | 69 +++++ .../lions/dev/resource/BookingResource.java | 100 +++++++ .../dev/resource/EstablishmentResource.java | 25 ++ .../EstablishmentSubscriptionResource.java | 66 +++++ .../dev/resource/FriendshipResource.java | 11 + .../dev/resource/SocialPostResource.java | 16 ++ .../com/lions/dev/resource/UsersResource.java | 112 +++++++- .../dev/resource/WaveWebhookResource.java | 38 +++ .../lions/dev/service/AdminStatsService.java | 78 ++++++ .../com/lions/dev/service/BookingService.java | 108 ++++++++ .../dev/service/EstablishmentService.java | 1 + .../com/lions/dev/service/EventService.java | 11 +- .../lions/dev/service/FriendshipService.java | 5 + .../com/lions/dev/service/MessageService.java | 2 +- .../com/lions/dev/service/UsersService.java | 20 ++ .../lions/dev/service/WavePaymentService.java | 216 +++++++++++++++ .../java/com/lions/dev/util/UserRole.java | 46 ++++ .../java/com/lions/dev/util/UserRoles.java | 36 +++ .../dev/websocket/ChatWebSocketNext.java | 66 +++-- .../dev/websocket/bridge/ChatKafkaBridge.java | 88 ++++-- .../bridge/NotificationKafkaBridge.java | 6 + .../websocket/bridge/PresenceKafkaBridge.java | 6 + .../websocket/bridge/ReactionKafkaBridge.java | 6 + src/main/resources/application-dev.properties | 13 + ...properties => application-prod.properties} | 51 +--- src/main/resources/application.properties | 21 ++ .../V12__Cleanup_Users_Legacy_Columns.sql | 65 +++++ ...tablishment_Subscriptions_And_Payments.sql | 57 ++++ ...V14__Add_IsActive_Users_Establishments.sql | 9 + .../V15__Create_Social_Posts_Table.sql | 38 +++ .../V1_1__Create_Base_Tables_For_Fresh_Db.sql | 81 ++++++ .../db/migration/V3__Migrate_Users_To_V2.sql | 35 ++- .../V5__Create_Business_Hours_Table.sql | 3 +- 66 files changed, 2333 insertions(+), 227 deletions(-) create mode 100644 DIAGNOSTIC_KAFKA_WEBSOCKET.md delete mode 100644 docker-compose.yml rename Dockerfile => docker/Dockerfile (88%) rename Dockerfile.prod => docker/Dockerfile.prod (96%) create mode 100644 docker/README.md create mode 100644 docker/docker-compose.yml create mode 100644 scripts/README.md rename deploy.ps1 => scripts/deploy.ps1 (81%) create mode 100644 src/main/java/com/lions/dev/config/SuperAdminStartup.java create mode 100644 src/main/java/com/lions/dev/dto/request/booking/ReservationCreateRequestDTO.java create mode 100644 src/main/java/com/lions/dev/dto/request/establishment/InitiateSubscriptionRequestDTO.java create mode 100644 src/main/java/com/lions/dev/dto/request/users/AssignRoleRequestDTO.java create mode 100644 src/main/java/com/lions/dev/dto/response/admin/AdminRevenueResponseDTO.java create mode 100644 src/main/java/com/lions/dev/dto/response/admin/ManagerStatsResponseDTO.java create mode 100644 src/main/java/com/lions/dev/dto/response/booking/ReservationResponseDTO.java create mode 100644 src/main/java/com/lions/dev/dto/response/establishment/InitiateSubscriptionResponseDTO.java create mode 100644 src/main/java/com/lions/dev/entity/establishment/EstablishmentPayment.java create mode 100644 src/main/java/com/lions/dev/entity/establishment/EstablishmentSubscription.java create mode 100644 src/main/java/com/lions/dev/repository/BookingRepository.java create mode 100644 src/main/java/com/lions/dev/repository/EstablishmentPaymentRepository.java create mode 100644 src/main/java/com/lions/dev/repository/EstablishmentSubscriptionRepository.java create mode 100644 src/main/java/com/lions/dev/resource/AdminStatsResource.java create mode 100644 src/main/java/com/lions/dev/resource/BookingResource.java create mode 100644 src/main/java/com/lions/dev/resource/EstablishmentSubscriptionResource.java create mode 100644 src/main/java/com/lions/dev/resource/WaveWebhookResource.java create mode 100644 src/main/java/com/lions/dev/service/AdminStatsService.java create mode 100644 src/main/java/com/lions/dev/service/BookingService.java create mode 100644 src/main/java/com/lions/dev/service/WavePaymentService.java create mode 100644 src/main/java/com/lions/dev/util/UserRole.java create mode 100644 src/main/java/com/lions/dev/util/UserRoles.java rename src/main/resources/{application-production.properties => application-prod.properties} (68%) create mode 100644 src/main/resources/db/migration/V12__Cleanup_Users_Legacy_Columns.sql create mode 100644 src/main/resources/db/migration/V13__Create_Establishment_Subscriptions_And_Payments.sql create mode 100644 src/main/resources/db/migration/V14__Add_IsActive_Users_Establishments.sql create mode 100644 src/main/resources/db/migration/V15__Create_Social_Posts_Table.sql create mode 100644 src/main/resources/db/migration/V1_1__Create_Base_Tables_For_Fresh_Db.sql diff --git a/.dockerignore b/.dockerignore index d20e31d..22ad69b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -42,3 +42,7 @@ logs/ *.temp tmp/ temp/ + +# Scripts et Docker (hors contexte utile pour le build) +scripts/ +docker/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 61c33fc..055604a 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -38,9 +38,9 @@ DB_USERNAME: afterwork # Utilisateur de la base de données DB_PASSWORD: # Mot de passe (à définir dans le secret) ``` -### 2. Dockerfile.prod +### 2. docker/Dockerfile.prod -Le fichier `Dockerfile.prod` utilise une approche multi-stage : +Le fichier `docker/Dockerfile.prod` utilise une approche multi-stage : - **Stage 1** : Build avec Maven dans une image UBI8 OpenJDK 17 - **Stage 2** : Runtime optimisé avec l'uber-jar compilé @@ -59,8 +59,8 @@ Configuration production avec : ### Build Local (Test) ```bash -# Build de l'image -docker build -f Dockerfile.prod -t afterwork-api:latest . +# Build de l'image (Dockerfiles dans docker/) +docker build -f docker/Dockerfile.prod -t afterwork-api:latest . # Test local docker run -p 8080:8080 \ @@ -284,8 +284,8 @@ ls target/*-runner.jar ### Étape 2 : Build Docker ```bash -# Build l'image de production -docker build -f Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 . +# Build l'image de production (Dockerfiles dans docker/) +docker build -f docker/Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 . # Test local (optionnel) docker run --rm -p 8080:8080 \ @@ -361,7 +361,7 @@ curl https://api.lions.dev/afterwork/api/users/test ```bash # 1. Build nouvelle version mvn clean package -DskipTests -docker build -f Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.1 . +docker build -f docker/Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.1 . docker push registry.lions.dev/afterwork-api:1.0.1 # 2. Mise à jour du déploiement diff --git a/DIAGNOSTIC_KAFKA_WEBSOCKET.md b/DIAGNOSTIC_KAFKA_WEBSOCKET.md new file mode 100644 index 0000000..03fb307 --- /dev/null +++ b/DIAGNOSTIC_KAFKA_WEBSOCKET.md @@ -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/) diff --git a/QUICK_DEPLOY.md b/QUICK_DEPLOY.md index 0be2801..bd005b1 100644 --- a/QUICK_DEPLOY.md +++ b/QUICK_DEPLOY.md @@ -8,15 +8,15 @@ cd C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main # Déploiement complet (build + push + deploy) -.\deploy.ps1 -Action all -Version 1.0.0 +.\scripts\deploy.ps1 -Action all -Version 1.0.0 # Ou étape par étape -.\deploy.ps1 -Action build # Build Maven + Docker -.\deploy.ps1 -Action push # Push vers registry -.\deploy.ps1 -Action deploy # Déploiement K8s +.\scripts\deploy.ps1 -Action build # Build Maven + Docker +.\scripts\deploy.ps1 -Action push # Push vers registry +.\scripts\deploy.ps1 -Action deploy # Déploiement K8s # Vérifier le statut -.\deploy.ps1 -Action status +.\scripts\deploy.ps1 -Action status ``` ### Option 2 : Déploiement Manuel @@ -28,7 +28,7 @@ cd C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main mvn clean package -DskipTests # 2. Build Docker -docker build -f Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 -t registry.lions.dev/afterwork-api:latest . +docker build -f docker/Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 -t registry.lions.dev/afterwork-api:latest . # 3. Push vers Registry docker login registry.lions.dev @@ -55,7 +55,7 @@ cd C:\Users\dadyo\PersonalProjects\mic-after-work-server-impl-quarkus-main # Build local mvn clean package -DskipTests -docker build -f Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 . +docker build -f docker/Dockerfile.prod -t registry.lions.dev/afterwork-api:1.0.0 . docker push registry.lions.dev/afterwork-api:1.0.0 # Déploiement diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 44c8e86..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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 diff --git a/Dockerfile b/docker/Dockerfile similarity index 88% rename from Dockerfile rename to docker/Dockerfile index 43183c4..ea9d074 100644 --- a/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,6 @@ ## ## AfterWork Server - Development Dockerfile -## Image légère avec JRE Alpine +## Image légère avec JRE Alpine (JAR pré-buildé requis) ## FROM eclipse-temurin:17-jre-alpine @@ -25,7 +25,7 @@ RUN mkdir -p /app /tmp/uploads && \ WORKDIR /app -# Copie du JAR +# Copie du JAR (context = racine du projet, build après mvn package) COPY --chown=appuser:appuser target/*-runner.jar /app/app.jar # Exposition du port diff --git a/Dockerfile.prod b/docker/Dockerfile.prod similarity index 96% rename from Dockerfile.prod rename to docker/Dockerfile.prod index 3ddbf1d..ad15dad 100644 --- a/Dockerfile.prod +++ b/docker/Dockerfile.prod @@ -13,7 +13,7 @@ USER root # Installation de Maven RUN microdnf install -y maven && microdnf clean all -# Copie des fichiers du projet +# Copie des fichiers du projet (context = racine du projet) WORKDIR /build COPY pom.xml . COPY src ./src diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..be6dafb --- /dev/null +++ b/docker/README.md @@ -0,0 +1,54 @@ +# Docker AfterWork + +Fichiers Docker pour le build et l’exécution de l’API 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 l’hô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 d’exécution sur l’hô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) + +L’application se connecte à PostgreSQL sur l’hôte (`host.docker.internal:5432`). Sans identifiants, l’erreur **« 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 l’utilisateur 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/`. diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..b9da525 --- /dev/null +++ b/docker/docker-compose.yml @@ -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 diff --git a/kubernetes/afterwork-ingress.yaml b/kubernetes/afterwork-ingress.yaml index a6132e0..7533fbc 100644 --- a/kubernetes/afterwork-ingress.yaml +++ b/kubernetes/afterwork-ingress.yaml @@ -46,9 +46,8 @@ metadata: nginx.ingress.kubernetes.io/rate-limit: "1000" nginx.ingress.kubernetes.io/rate-limit-window: "1m" - # Rewrite (important pour /afterwork) - nginx.ingress.kubernetes.io/use-regex: "true" - nginx.ingress.kubernetes.io/rewrite-target: /$2 + # PAS de rewrite-target : le backend sert sous quarkus.http.root-path=/afterwork, + # l'Ingress doit transmettre le chemin complet (/afterwork/...) au service. spec: ingressClassName: nginx @@ -60,8 +59,8 @@ spec: - host: api.lions.dev http: paths: - - path: /afterwork(/|$)(.*) - pathType: ImplementationSpecific + - path: /afterwork + pathType: Prefix backend: service: name: mic-after-work-server-impl-quarkus-main-service diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..7aa946b --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,20 @@ +# Scripts AfterWork + +Scripts de déploiement et d’outillage. + +## 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/`). diff --git a/deploy.ps1 b/scripts/deploy.ps1 similarity index 81% rename from deploy.ps1 rename to scripts/deploy.ps1 index 30694f1..7ff4ac6 100644 --- a/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -3,6 +3,7 @@ # ==================================================================== # Ce script automatise le processus de build et déploiement # de l'API AfterWork sur le VPS via Kubernetes. +# Exécuter depuis la racine du projet ou depuis scripts/ # ==================================================================== param( @@ -20,6 +21,10 @@ param( $ErrorActionPreference = "Stop" +# Racine du projet (parent du dossier scripts) +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ProjectRoot = (Resolve-Path (Join-Path $ScriptDir "..")).Path + # Couleurs function Write-Info { param($msg) Write-Host $msg -ForegroundColor Cyan } function Write-Success { param($msg) Write-Host $msg -ForegroundColor Green } @@ -42,6 +47,7 @@ Write-Host " - Version: $Version" Write-Host " - Registry: $Registry" Write-Host " - Image: $ImageName" Write-Host " - Namespace: $Namespace" +Write-Host " - Racine projet: $ProjectRoot" Write-Host "" # ====================================================================== @@ -50,27 +56,31 @@ Write-Host "" function Build-Application { Write-Info "[1/5] Build Maven..." - $mavenArgs = "clean", "package", "-Dquarkus.package.type=uber-jar" - if ($SkipTests) { - $mavenArgs += "-DskipTests" - } else { - $mavenArgs += "-DtestFailureIgnore=true" - } + Push-Location $ProjectRoot + try { + $mavenArgs = "clean", "package", "-Dquarkus.package.type=uber-jar" + if ($SkipTests) { + $mavenArgs += "-DskipTests" + } else { + $mavenArgs += "-DtestFailureIgnore=true" + } - & mvn $mavenArgs - if ($LASTEXITCODE -ne 0) { - Write-Error "Erreur lors du build Maven" - exit 1 - } + & mvn $mavenArgs + if ($LASTEXITCODE -ne 0) { + Write-Error "Erreur lors du build Maven" + exit 1 + } - # Vérifier que le JAR existe - $jar = Get-ChildItem -Path "target" -Filter "*-runner.jar" | Select-Object -First 1 - if (-not $jar) { - Write-Error "JAR runner non trouvé dans target/" - exit 1 - } + $jar = Get-ChildItem -Path (Join-Path $ProjectRoot "target") -Filter "*-runner.jar" | Select-Object -First 1 + if (-not $jar) { + Write-Error "JAR runner non trouvé dans target/" + exit 1 + } - Write-Success "Build Maven réussi : $($jar.Name)" + Write-Success "Build Maven réussi : $($jar.Name)" + } finally { + Pop-Location + } } # ====================================================================== @@ -79,13 +89,19 @@ function Build-Application { function Build-DockerImage { Write-Info "[2/5] Build Docker Image..." - docker build -f Dockerfile.prod -t $ImageName -t $ImageLatest . - if ($LASTEXITCODE -ne 0) { - Write-Error "Erreur lors du build Docker" - exit 1 - } + Push-Location $ProjectRoot + try { + $dockerDir = Join-Path $ProjectRoot "docker" + docker build -f (Join-Path $dockerDir "Dockerfile.prod") -t $ImageName -t $ImageLatest . + if ($LASTEXITCODE -ne 0) { + Write-Error "Erreur lors du build Docker" + exit 1 + } - Write-Success "Image Docker créée : $ImageName" + Write-Success "Image Docker créée : $ImageName" + } finally { + Pop-Location + } } # ====================================================================== @@ -94,7 +110,6 @@ function Build-DockerImage { function Push-ToRegistry { Write-Info "[3/5] Push vers Registry..." - # Vérifier si on est connecté au registry $loginTest = docker login $Registry 2>&1 if ($LASTEXITCODE -ne 0 -and -not $loginTest.ToString().Contains("Succeeded")) { Write-Warning "Connexion au registry nécessaire..." @@ -105,7 +120,6 @@ function Push-ToRegistry { } } - # Push des images docker push $ImageName if ($LASTEXITCODE -ne 0) { Write-Error "Erreur lors du push de $ImageName" @@ -127,20 +141,18 @@ function Push-ToRegistry { function Deploy-ToKubernetes { Write-Info "[4/5] Déploiement Kubernetes..." - # Vérifier que kubectl est disponible $kubectlCheck = kubectl version --client 2>&1 if ($LASTEXITCODE -ne 0) { Write-Error "kubectl n'est pas installé ou configuré" exit 1 } - # Créer le namespace si nécessaire + $k8sDir = Join-Path $ProjectRoot "kubernetes" Write-Info "Création du namespace $Namespace..." kubectl create namespace $Namespace --dry-run=client -o yaml | kubectl apply -f - - # Appliquer les manifests Write-Info "Application des ConfigMaps et Secrets..." - kubectl apply -f kubernetes/afterwork-configmap.yaml + kubectl apply -f (Join-Path $k8sDir "afterwork-configmap.yaml") if ($LASTEXITCODE -ne 0) { Write-Warning "ConfigMap déjà existante ou erreur" } @@ -155,26 +167,26 @@ function Deploy-ToKubernetes { } } - kubectl apply -f kubernetes/afterwork-secrets.yaml + kubectl apply -f (Join-Path $k8sDir "afterwork-secrets.yaml") if ($LASTEXITCODE -ne 0) { Write-Error "Erreur lors de l'application des secrets" exit 1 } Write-Info "Déploiement de l'application..." - kubectl apply -f kubernetes/afterwork-deployment.yaml + kubectl apply -f (Join-Path $k8sDir "afterwork-deployment.yaml") if ($LASTEXITCODE -ne 0) { Write-Error "Erreur lors du déploiement" exit 1 } - kubectl apply -f kubernetes/afterwork-service.yaml + kubectl apply -f (Join-Path $k8sDir "afterwork-service.yaml") if ($LASTEXITCODE -ne 0) { Write-Error "Erreur lors de la création du service" exit 1 } - kubectl apply -f kubernetes/afterwork-ingress.yaml + kubectl apply -f (Join-Path $k8sDir "afterwork-ingress.yaml") if ($LASTEXITCODE -ne 0) { Write-Error "Erreur lors de la création de l'ingress" exit 1 @@ -182,7 +194,6 @@ function Deploy-ToKubernetes { Write-Success "Déploiement Kubernetes réussi" - # Attendre que le déploiement soit prêt Write-Info "Attente du rollout..." kubectl rollout status deployment/$AppName -n $Namespace --timeout=5m if ($LASTEXITCODE -ne 0) { @@ -196,19 +207,15 @@ function Deploy-ToKubernetes { function Verify-Deployment { Write-Info "[5/5] Vérification du déploiement..." - # Status des pods Write-Info "Pods:" kubectl get pods -n $Namespace -l app=$AppName - # Status du service Write-Info "`nService:" kubectl get svc -n $Namespace $AppName - # Status de l'ingress Write-Info "`nIngress:" kubectl get ingress -n $Namespace $AppName - # Test health check Write-Info "`nTest Health Check..." Start-Sleep -Seconds 5 diff --git a/src/main/java/com/lions/dev/config/SuperAdminStartup.java b/src/main/java/com/lions/dev/config/SuperAdminStartup.java new file mode 100644 index 0000000..f1da856 --- /dev/null +++ b/src/main/java/com/lions/dev/config/SuperAdminStartup.java @@ -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 + ")"); + } +} diff --git a/src/main/java/com/lions/dev/dto/request/booking/ReservationCreateRequestDTO.java b/src/main/java/com/lions/dev/dto/request/booking/ReservationCreateRequestDTO.java new file mode 100644 index 0000000..0a9e710 --- /dev/null +++ b/src/main/java/com/lions/dev/dto/request/booking/ReservationCreateRequestDTO.java @@ -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; + } +} diff --git a/src/main/java/com/lions/dev/dto/request/establishment/InitiateSubscriptionRequestDTO.java b/src/main/java/com/lions/dev/dto/request/establishment/InitiateSubscriptionRequestDTO.java new file mode 100644 index 0000000..787e098 --- /dev/null +++ b/src/main/java/com/lions/dev/dto/request/establishment/InitiateSubscriptionRequestDTO.java @@ -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; +} diff --git a/src/main/java/com/lions/dev/dto/request/friends/FriendshipCreateOneRequestDTO.java b/src/main/java/com/lions/dev/dto/request/friends/FriendshipCreateOneRequestDTO.java index b439a6b..b22ec6a 100644 --- a/src/main/java/com/lions/dev/dto/request/friends/FriendshipCreateOneRequestDTO.java +++ b/src/main/java/com/lions/dev/dto/request/friends/FriendshipCreateOneRequestDTO.java @@ -1,5 +1,6 @@ package com.lions.dev.dto.request.friends; +import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.Setter; import lombok.NoArgsConstructor; @@ -15,7 +16,10 @@ import java.util.UUID; @NoArgsConstructor public class FriendshipCreateOneRequestDTO { + @NotNull(message = "L'identifiant de l'utilisateur est requis") private UUID userId; // ID de l'utilisateur qui envoie la demande + + @NotNull(message = "L'identifiant de l'ami est requis") private UUID friendId; // ID de l'utilisateur qui reçoit la demande /** diff --git a/src/main/java/com/lions/dev/dto/request/users/AssignRoleRequestDTO.java b/src/main/java/com/lions/dev/dto/request/users/AssignRoleRequestDTO.java new file mode 100644 index 0000000..9e6a8fe --- /dev/null +++ b/src/main/java/com/lions/dev/dto/request/users/AssignRoleRequestDTO.java @@ -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; + } +} diff --git a/src/main/java/com/lions/dev/dto/response/admin/AdminRevenueResponseDTO.java b/src/main/java/com/lions/dev/dto/response/admin/AdminRevenueResponseDTO.java new file mode 100644 index 0000000..5e86b00 --- /dev/null +++ b/src/main/java/com/lions/dev/dto/response/admin/AdminRevenueResponseDTO.java @@ -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; +} diff --git a/src/main/java/com/lions/dev/dto/response/admin/ManagerStatsResponseDTO.java b/src/main/java/com/lions/dev/dto/response/admin/ManagerStatsResponseDTO.java new file mode 100644 index 0000000..775343f --- /dev/null +++ b/src/main/java/com/lions/dev/dto/response/admin/ManagerStatsResponseDTO.java @@ -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; +} diff --git a/src/main/java/com/lions/dev/dto/response/booking/ReservationResponseDTO.java b/src/main/java/com/lions/dev/dto/response/booking/ReservationResponseDTO.java new file mode 100644 index 0000000..354d635 --- /dev/null +++ b/src/main/java/com/lions/dev/dto/response/booking/ReservationResponseDTO.java @@ -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; + } +} diff --git a/src/main/java/com/lions/dev/dto/response/establishment/InitiateSubscriptionResponseDTO.java b/src/main/java/com/lions/dev/dto/response/establishment/InitiateSubscriptionResponseDTO.java new file mode 100644 index 0000000..8abddb4 --- /dev/null +++ b/src/main/java/com/lions/dev/dto/response/establishment/InitiateSubscriptionResponseDTO.java @@ -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; +} diff --git a/src/main/java/com/lions/dev/dto/response/users/UserCreateResponseDTO.java b/src/main/java/com/lions/dev/dto/response/users/UserCreateResponseDTO.java index 0266ea5..05fa409 100644 --- a/src/main/java/com/lions/dev/dto/response/users/UserCreateResponseDTO.java +++ b/src/main/java/com/lions/dev/dto/response/users/UserCreateResponseDTO.java @@ -21,6 +21,7 @@ public class UserCreateResponseDTO { private String lastName; // v2.0 - Nom de famille de l'utilisateur private String email; // Email de l'utilisateur private String role; // Rôle de l'utilisateur + private Boolean isActive = true; // false = manager suspendu (abonnement expiré) private String profileImageUrl; // URL de l'image de profil de l'utilisateur private String bio; // v2.0 - Biographie courte private Integer loyaltyPoints; // v2.0 - Points de fidélité @@ -56,6 +57,7 @@ public class UserCreateResponseDTO { this.lastName = user.getLastName(); // v2.0 this.email = user.getEmail(); this.role = user.getRole(); + this.isActive = user.isActive(); this.profileImageUrl = user.getProfileImageUrl(); this.bio = user.getBio(); // v2.0 this.loyaltyPoints = user.getLoyaltyPoints(); // v2.0 diff --git a/src/main/java/com/lions/dev/entity/establishment/Establishment.java b/src/main/java/com/lions/dev/entity/establishment/Establishment.java index 6ab022d..dc2387e 100644 --- a/src/main/java/com/lions/dev/entity/establishment/Establishment.java +++ b/src/main/java/com/lions/dev/entity/establishment/Establishment.java @@ -62,6 +62,10 @@ public class Establishment extends BaseEntity { @Column(name = "verification_status", nullable = false) private String verificationStatus = "PENDING"; // Statut de vérification: PENDING, VERIFIED, REJECTED (v2.0) + /** true = visible dans l'app ; false = masqué (abonnement inactif / suspension Wave). Par défaut true. */ + @Column(name = "is_active", nullable = false) + private Boolean isActive = true; + @Column(name = "latitude") private Double latitude; // Latitude pour la géolocalisation diff --git a/src/main/java/com/lions/dev/entity/establishment/EstablishmentPayment.java b/src/main/java/com/lions/dev/entity/establishment/EstablishmentPayment.java new file mode 100644 index 0000000..d50851e --- /dev/null +++ b/src/main/java/com/lions/dev/entity/establishment/EstablishmentPayment.java @@ -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"; +} diff --git a/src/main/java/com/lions/dev/entity/establishment/EstablishmentSubscription.java b/src/main/java/com/lions/dev/entity/establishment/EstablishmentSubscription.java new file mode 100644 index 0000000..a457113 --- /dev/null +++ b/src/main/java/com/lions/dev/entity/establishment/EstablishmentSubscription.java @@ -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"; +} diff --git a/src/main/java/com/lions/dev/entity/social/SocialPost.java b/src/main/java/com/lions/dev/entity/social/SocialPost.java index 2648245..48ca873 100644 --- a/src/main/java/com/lions/dev/entity/social/SocialPost.java +++ b/src/main/java/com/lions/dev/entity/social/SocialPost.java @@ -27,9 +27,9 @@ public class SocialPost extends BaseEntity { @Column(name = "content", nullable = false, length = 2000) private String content; // Le contenu textuel du post - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "user_id", nullable = false) - private Users user; // L'utilisateur créateur du post + private Users user; // L'utilisateur créateur du post (EAGER pour éviter 500 sur mapping DTO) @Column(name = "image_url", length = 500) private String imageUrl; // URL de l'image associée (optionnel) diff --git a/src/main/java/com/lions/dev/entity/users/Users.java b/src/main/java/com/lions/dev/entity/users/Users.java index 3bb87a7..d0b1984 100644 --- a/src/main/java/com/lions/dev/entity/users/Users.java +++ b/src/main/java/com/lions/dev/entity/users/Users.java @@ -69,6 +69,10 @@ public class Users extends BaseEntity { @Column(name = "last_seen") private java.time.LocalDateTime lastSeen; // Dernière fois que l'utilisateur était en ligne + /** true = compte actif ; false = manager suspendu (abonnement expiré / paiement échoué). Par défaut true. */ + @Column(name = "is_active", nullable = false) + private boolean isActive = true; + // Utilisation de BCrypt pour hacher les mots de passe de manière sécurisée // private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); diff --git a/src/main/java/com/lions/dev/repository/BookingRepository.java b/src/main/java/com/lions/dev/repository/BookingRepository.java new file mode 100644 index 0000000..9dbf34e --- /dev/null +++ b/src/main/java/com/lions/dev/repository/BookingRepository.java @@ -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 { + + public List findByUserId(UUID userId) { + return list("user.id", userId); + } +} diff --git a/src/main/java/com/lions/dev/repository/EstablishmentPaymentRepository.java b/src/main/java/com/lions/dev/repository/EstablishmentPaymentRepository.java new file mode 100644 index 0000000..645460c --- /dev/null +++ b/src/main/java/com/lions/dev/repository/EstablishmentPaymentRepository.java @@ -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 { + + public Optional findByWaveSessionId(String waveSessionId) { + return find("waveSessionId", waveSessionId).firstResultOptional(); + } + + public List findByEstablishmentId(UUID establishmentId) { + return list("establishmentId", establishmentId); + } +} diff --git a/src/main/java/com/lions/dev/repository/EstablishmentRepository.java b/src/main/java/com/lions/dev/repository/EstablishmentRepository.java index 3732bba..471c597 100644 --- a/src/main/java/com/lions/dev/repository/EstablishmentRepository.java +++ b/src/main/java/com/lions/dev/repository/EstablishmentRepository.java @@ -84,5 +84,16 @@ public class EstablishmentRepository implements PanacheRepositoryBase { + + public Optional findByWaveSessionId(String waveSessionId) { + return find("waveSessionId", waveSessionId).firstResultOptional(); + } + + public List findByEstablishmentId(UUID establishmentId) { + return list("establishmentId", establishmentId); + } + + public Optional findActiveByEstablishmentId(UUID establishmentId) { + return find("establishmentId = ?1 and status = ?2", establishmentId, EstablishmentSubscription.STATUS_ACTIVE) + .firstResultOptional(); + } +} diff --git a/src/main/java/com/lions/dev/repository/EventsRepository.java b/src/main/java/com/lions/dev/repository/EventsRepository.java index 6afd355..1e6ae3c 100644 --- a/src/main/java/com/lions/dev/repository/EventsRepository.java +++ b/src/main/java/com/lions/dev/repository/EventsRepository.java @@ -80,4 +80,24 @@ public class EventsRepository implements PanacheRepositoryBase { LOG.info("[LOG] " + events.size() + " événement(s) récupéré(s) des amis"); 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 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(); + } } diff --git a/src/main/java/com/lions/dev/repository/FriendshipRepository.java b/src/main/java/com/lions/dev/repository/FriendshipRepository.java index c5df74e..c988423 100644 --- a/src/main/java/com/lions/dev/repository/FriendshipRepository.java +++ b/src/main/java/com/lions/dev/repository/FriendshipRepository.java @@ -26,8 +26,9 @@ public class FriendshipRepository implements PanacheRepositoryBase findByUsers(Users user, Users friend) { 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 - Optional friendship = find("user = ?1 and friend = ?2", user, friend).firstResultOptional(); + // Vérifier dans les deux sens : (user, friend) ET (friend, user) + Optional friendship = find("(user = ?1 AND friend = ?2) OR (user = ?2 AND friend = ?1)", user, friend).firstResultOptional(); if (friendship.isPresent()) { logger.infof("Relation d'amitié trouvée entre %s et %s", user.getId(), friend.getId()); diff --git a/src/main/java/com/lions/dev/resource/AdminStatsResource.java b/src/main/java/com/lions/dev/resource/AdminStatsResource.java new file mode 100644 index 0000000..722ba2c --- /dev/null +++ b/src/main/java/com/lions/dev/resource/AdminStatsResource.java @@ -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 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 list = adminStatsService.getManagers(); + return Response.ok(list).build(); + } +} diff --git a/src/main/java/com/lions/dev/resource/BookingResource.java b/src/main/java/com/lions/dev/resource/BookingResource.java new file mode 100644 index 0000000..2c46d19 --- /dev/null +++ b/src/main/java/com/lions/dev/resource/BookingResource.java @@ -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 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(); + } +} diff --git a/src/main/java/com/lions/dev/resource/EstablishmentResource.java b/src/main/java/com/lions/dev/resource/EstablishmentResource.java index f98333b..4d6d41e 100644 --- a/src/main/java/com/lions/dev/resource/EstablishmentResource.java +++ b/src/main/java/com/lions/dev/resource/EstablishmentResource.java @@ -5,8 +5,10 @@ import com.lions.dev.dto.request.establishment.EstablishmentUpdateRequestDTO; import com.lions.dev.dto.response.establishment.EstablishmentResponseDTO; import com.lions.dev.entity.establishment.Establishment; import com.lions.dev.entity.users.Users; +import com.lions.dev.repository.EstablishmentRepository; import com.lions.dev.repository.UsersRepository; import com.lions.dev.service.EstablishmentService; +import com.lions.dev.util.UserRoles; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.validation.Valid; @@ -38,6 +40,9 @@ public class EstablishmentResource { @Inject UsersRepository usersRepository; + @Inject + EstablishmentRepository establishmentRepository; + private static final Logger LOG = Logger.getLogger(EstablishmentResource.class); // *********** Création d'un établissement *********** @@ -67,6 +72,26 @@ public class EstablishmentResource { .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 Establishment establishment = new Establishment(); establishment.setName(requestDTO.getName()); diff --git a/src/main/java/com/lions/dev/resource/EstablishmentSubscriptionResource.java b/src/main/java/com/lions/dev/resource/EstablishmentSubscriptionResource.java new file mode 100644 index 0000000..87be2d9 --- /dev/null +++ b/src/main/java/com/lions/dev/resource/EstablishmentSubscriptionResource.java @@ -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(); + } +} diff --git a/src/main/java/com/lions/dev/resource/FriendshipResource.java b/src/main/java/com/lions/dev/resource/FriendshipResource.java index 425c0ae..9cbaf34 100644 --- a/src/main/java/com/lions/dev/resource/FriendshipResource.java +++ b/src/main/java/com/lions/dev/resource/FriendshipResource.java @@ -9,6 +9,7 @@ import com.lions.dev.dto.response.friends.FriendshipReadStatusResponseDTO; import com.lions.dev.entity.friends.FriendshipStatus; import com.lions.dev.exception.UserNotFoundException; import com.lions.dev.service.FriendshipService; +import jakarta.ws.rs.NotFoundException; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -82,6 +83,16 @@ public class FriendshipResource { + " et " + friendshipResponse.getFriendId()); 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) { logger.error("[ERROR] Erreur lors de l'envoi de la demande d'amitié : " + e.getMessage(), e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) diff --git a/src/main/java/com/lions/dev/resource/SocialPostResource.java b/src/main/java/com/lions/dev/resource/SocialPostResource.java index 63f6ea1..6a446e1 100644 --- a/src/main/java/com/lions/dev/resource/SocialPostResource.java +++ b/src/main/java/com/lions/dev/resource/SocialPostResource.java @@ -3,6 +3,7 @@ package com.lions.dev.resource; import com.lions.dev.dto.request.social.SocialPostCreateRequestDTO; import com.lions.dev.dto.response.social.SocialPostResponseDTO; import com.lions.dev.entity.social.SocialPost; +import com.lions.dev.exception.UserNotFoundException; import com.lions.dev.service.SocialPostService; import jakarta.inject.Inject; import jakarta.transaction.Transactional; @@ -121,6 +122,11 @@ public class SocialPostResource { ); SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post); 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) { LOG.error("[ERROR] Erreur lors de la création du post : " + e.getMessage(), e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) @@ -382,6 +388,11 @@ public class SocialPostResource { .collect(Collectors.toList()); 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) { LOG.error("[ERROR] Erreur lors de la récupération des posts : " + e.getMessage(), e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) @@ -416,6 +427,11 @@ public class SocialPostResource { .collect(Collectors.toList()); 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) { 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) diff --git a/src/main/java/com/lions/dev/resource/UsersResource.java b/src/main/java/com/lions/dev/resource/UsersResource.java index af6d5c8..06e7260 100644 --- a/src/main/java/com/lions/dev/resource/UsersResource.java +++ b/src/main/java/com/lions/dev/resource/UsersResource.java @@ -1,6 +1,7 @@ package com.lions.dev.resource; import com.lions.dev.dto.PasswordResetRequest; +import com.lions.dev.dto.request.users.AssignRoleRequestDTO; import com.lions.dev.dto.request.users.UserAuthenticateRequestDTO; import com.lions.dev.dto.request.users.UserCreateRequestDTO; 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.core.MediaType; import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; import java.io.File; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; @@ -39,8 +42,13 @@ public class UsersResource { @Inject UsersService userService; + @ConfigProperty(name = "afterwork.super-admin.api-key", defaultValue = "") + Optional superAdminApiKey; + 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. * @@ -296,11 +304,11 @@ public class UsersResource { Users user = userService.findByEmail(request.getEmail()); if (user != null) { - // TODO: Generer un token de reset et l'envoyer par email - // Pour l'instant, on retourne success pour ne pas reveler si l'email existe - // String resetToken = generateResetToken(); - // emailService.sendPasswordResetEmail(user.getEmail(), resetToken); - LOG.info("Utilisateur trouve, email de reinitialisation devrait etre envoye : " + request.getEmail()); + // En standby : pas encore de service d'envoi de mail. Quand disponible : + // - generer un token de reset (table dédiée ou champ user avec expiration) + // - appeler 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 (en standby - pas de mail service) : " + request.getEmail()); } else { 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 <token>) 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(); + } + } + } diff --git a/src/main/java/com/lions/dev/resource/WaveWebhookResource.java b/src/main/java/com/lions/dev/resource/WaveWebhookResource.java new file mode 100644 index 0000000..6e2772e --- /dev/null +++ b/src/main/java/com/lions/dev/resource/WaveWebhookResource.java @@ -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(); + } + } +} diff --git a/src/main/java/com/lions/dev/service/AdminStatsService.java b/src/main/java/com/lions/dev/service/AdminStatsService.java new file mode 100644 index 0000000..a7d561c --- /dev/null +++ b/src/main/java/com/lions/dev/service/AdminStatsService.java @@ -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 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 getManagers() { + List managers = usersRepository.list("role", UserRoles.MANAGER); + List result = new ArrayList<>(); + for (Users manager : managers) { + List 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 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; + } +} diff --git a/src/main/java/com/lions/dev/service/BookingService.java b/src/main/java/com/lions/dev/service/BookingService.java new file mode 100644 index 0000000..de02601 --- /dev/null +++ b/src/main/java/com/lions/dev/service/BookingService.java @@ -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 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(); + } + } + } +} diff --git a/src/main/java/com/lions/dev/service/EstablishmentService.java b/src/main/java/com/lions/dev/service/EstablishmentService.java index 5410674..c83e426 100644 --- a/src/main/java/com/lions/dev/service/EstablishmentService.java +++ b/src/main/java/com/lions/dev/service/EstablishmentService.java @@ -75,6 +75,7 @@ public class EstablishmentService { "SELECT DISTINCT e FROM Establishment e " + "LEFT JOIN FETCH e.medias m " + "LEFT JOIN FETCH e.manager " + + "WHERE (e.isActive IS NULL OR e.isActive = true) " + "ORDER BY e.name ASC", Establishment.class ) diff --git a/src/main/java/com/lions/dev/service/EventService.java b/src/main/java/com/lions/dev/service/EventService.java index 0c11e43..4d5c203 100644 --- a/src/main/java/com/lions/dev/service/EventService.java +++ b/src/main/java/com/lions/dev/service/EventService.java @@ -152,7 +152,7 @@ public class EventService { notificationEmitter.send(kafkaEvent); logger.debug("[logger] Événement event_created publié dans Kafka pour: {}", friend.getId()); } 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 } } @@ -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. - * @return La liste des événements situés à cette localisation. + * @param location Fragment de localisation (ville ou adresse). + * @return La liste des événements dont l'établissement matche. */ public List findEventsByLocation(String location) { logger.info("[logger] Récupération des événements pour la localisation : " + location); - List events = eventsRepository.find("location", location).list(); + List events = eventsRepository.findEventsByEstablishmentLocation(location); logger.info("[logger] Nombre d'événements trouvés pour la localisation '" + location + "' : " + events.size()); return events; } diff --git a/src/main/java/com/lions/dev/service/FriendshipService.java b/src/main/java/com/lions/dev/service/FriendshipService.java index fda3ed4..3bbbd9a 100644 --- a/src/main/java/com/lions/dev/service/FriendshipService.java +++ b/src/main/java/com/lions/dev/service/FriendshipService.java @@ -56,6 +56,11 @@ public class FriendshipService { */ @Transactional 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()); // Récupérer les utilisateurs concernés diff --git a/src/main/java/com/lions/dev/service/MessageService.java b/src/main/java/com/lions/dev/service/MessageService.java index 6a9347e..5a10e65 100644 --- a/src/main/java/com/lions/dev/service/MessageService.java +++ b/src/main/java/com/lions/dev/service/MessageService.java @@ -187,7 +187,7 @@ public class MessageService { // Ne pas bloquer si la confirmation échoue } } 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 } diff --git a/src/main/java/com/lions/dev/service/UsersService.java b/src/main/java/com/lions/dev/service/UsersService.java index 588c963..af36eb4 100644 --- a/src/main/java/com/lions/dev/service/UsersService.java +++ b/src/main/java/com/lions/dev/service/UsersService.java @@ -263,4 +263,24 @@ public class UsersService { Optional userOptional = usersRepository.findByEmail(email); 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; + } } diff --git a/src/main/java/com/lions/dev/service/WavePaymentService.java b/src/main/java/com/lions/dev/service/WavePaymentService.java new file mode 100644 index 0000000..67807b7 --- /dev/null +++ b/src/main/java/com/lions/dev/service/WavePaymentService.java @@ -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 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 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 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); + }); + } +} diff --git a/src/main/java/com/lions/dev/util/UserRole.java b/src/main/java/com/lions/dev/util/UserRole.java new file mode 100644 index 0000000..3eabaef --- /dev/null +++ b/src/main/java/com/lions/dev/util/UserRole.java @@ -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); + } +} diff --git a/src/main/java/com/lions/dev/util/UserRoles.java b/src/main/java/com/lions/dev/util/UserRoles.java new file mode 100644 index 0000000..ef119d6 --- /dev/null +++ b/src/main/java/com/lions/dev/util/UserRoles.java @@ -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); + } +} diff --git a/src/main/java/com/lions/dev/websocket/ChatWebSocketNext.java b/src/main/java/com/lions/dev/websocket/ChatWebSocketNext.java index 06e6ae8..a7bcc82 100644 --- a/src/main/java/com/lions/dev/websocket/ChatWebSocketNext.java +++ b/src/main/java/com/lions/dev/websocket/ChatWebSocketNext.java @@ -81,25 +81,30 @@ public class ChatWebSocketNext { String userId = connection.pathParam("userId"); Log.debug("[CHAT-WS-NEXT] Message reçu de " + userId + ": " + message); - // Parser le message JSON com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - Map messageData = mapper.readValue(message, Map.class); - - String type = (String) messageData.get("type"); + Map raw = mapper.readValue(message, Map.class); + String type = (String) raw.get("type"); + @SuppressWarnings("unchecked") + Map data = (Map) raw.get("data"); switch (type) { case "message": - handleChatMessage(messageData, userId); + if (data != null) handleChatMessage(data, userId); + else Log.warn("[CHAT-WS-NEXT] Message sans 'data'"); break; case "typing": - handleTypingIndicator(messageData, userId); + if (data != null) handleTypingIndicator(data, userId); break; 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; default: - Log.warn("[CHAT-WS-NEXT] Type de message inconnu: " + type); + Log.warn("[CHAT-WS-NEXT] Type inconnu: " + type); } } catch (Exception e) { @@ -108,16 +113,17 @@ public class ChatWebSocketNext { } /** - * Gère l'envoi d'un message de chat. - * Le message est traité par MessageService qui publiera dans Kafka. + * Gère l'envoi d'un message de chat via WebSocket. + * 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 messageData, String senderId) { + private void handleChatMessage(Map data, String senderId) { try { UUID senderUUID = UUID.fromString(senderId); - UUID recipientUUID = UUID.fromString((String) messageData.get("recipientId")); - String content = (String) messageData.get("content"); - String messageType = messageData.getOrDefault("messageType", "text").toString(); - String mediaUrl = (String) messageData.get("mediaUrl"); + UUID recipientUUID = UUID.fromString((String) data.get("recipientId")); + String content = (String) data.get("content"); + String messageType = data.getOrDefault("messageType", "text").toString(); + String mediaUrl = (String) data.get("mediaUrl"); // Enregistrer le message dans la base de données // MessageService publiera automatiquement dans Kafka @@ -146,13 +152,21 @@ public class ChatWebSocketNext { /** * Gère les indicateurs de frappe. + * data doit contenir recipientId (ID du destinataire) et isTyping. */ - private void handleTypingIndicator(Map messageData, String userId) { + private void handleTypingIndicator(Map data, String userId) { try { - UUID recipientUUID = UUID.fromString((String) messageData.get("recipientId")); - boolean isTyping = (boolean) messageData.getOrDefault("isTyping", false); + Object recipientIdObj = data.get("recipientId"); + 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( + "conversationId", data.getOrDefault("conversationId", ""), "userId", userId, "isTyping", isTyping )); @@ -168,22 +182,22 @@ public class ChatWebSocketNext { /** * Gère les confirmations de lecture. + * Envoie type "read" (format attendu par le client Flutter). */ - private void handleReadReceipt(Map messageData, String userId) { + private void handleReadReceipt(Map data, String userId) { 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); if (message != null) { - // Envoyer confirmation de lecture à l'expéditeur via WebSocket - // (sera aussi publié dans Kafka par MessageService) 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(), - "readBy", userId, - "readAt", System.currentTimeMillis() + "userId", userId, + "timestamp", timestampIso )); sendToUser(senderUUID, response); diff --git a/src/main/java/com/lions/dev/websocket/bridge/ChatKafkaBridge.java b/src/main/java/com/lions/dev/websocket/bridge/ChatKafkaBridge.java index dc60826..a15aaa7 100644 --- a/src/main/java/com/lions/dev/websocket/bridge/ChatKafkaBridge.java +++ b/src/main/java/com/lions/dev/websocket/bridge/ChatKafkaBridge.java @@ -3,48 +3,51 @@ package com.lions.dev.websocket.bridge; import com.lions.dev.dto.events.ChatMessageEvent; import com.lions.dev.websocket.ChatWebSocketNext; import io.quarkus.logging.Log; +import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import org.eclipse.microprofile.reactive.messaging.Incoming; 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.UUID; import java.util.concurrent.CompletionStage; /** * Bridge qui consomme depuis Kafka et envoie via WebSocket pour le chat. * - * Architecture: + * Architecture (best practice): * MessageService → Kafka Topic (chat.messages) → Bridge → WebSocket → Client */ @ApplicationScoped 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. - * - * @param message Message Kafka contenant un ChatMessageEvent - * @return CompletionStage pour gérer l'ack/nack asynchrone */ @Incoming("kafka-chat") public CompletionStage processChatMessage(Message message) { try { 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()); UUID recipientId = UUID.fromString(event.getRecipientId()); - // Construire le message JSON pour WebSocket String wsMessage = buildWebSocketMessage(event); - // Envoyer via WebSocket au destinataire ChatWebSocketNext.sendMessageToUser(recipientId, wsMessage); Log.debug("[CHAT-BRIDGE] Message routé vers WebSocket pour: " + event.getRecipientId()); - // Acknowledger le message Kafka return message.ack(); } 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) { try { com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - Map messageData = new java.util.HashMap<>(); - messageData.put("id", event.getMessageId()); - messageData.put("conversationId", event.getConversationId()); - messageData.put("senderId", event.getSenderId()); - messageData.put("recipientId", event.getRecipientId()); - messageData.put("content", event.getContent()); - messageData.put("timestamp", event.getTimestamp()); - if (event.getMetadata() != null) { - messageData.putAll(event.getMetadata()); + String eventType = event.getEventType() != null ? event.getEventType() : "message"; + long ts = event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis(); + String timestampIso = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(ts)); + + Map data; + String type; + + switch (eventType) { + 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 wsMessage = java.util.Map.of( - "type", event.getEventType() != null ? event.getEventType() : "message", - "data", messageData, - "timestamp", event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis() - ); + Map wsMessage = new HashMap<>(); + wsMessage.put("type", type); + wsMessage.put("data", data); + wsMessage.put("timestamp", ts); return mapper.writeValueAsString(wsMessage); } catch (Exception e) { diff --git a/src/main/java/com/lions/dev/websocket/bridge/NotificationKafkaBridge.java b/src/main/java/com/lions/dev/websocket/bridge/NotificationKafkaBridge.java index 92da2a5..6d8d708 100644 --- a/src/main/java/com/lions/dev/websocket/bridge/NotificationKafkaBridge.java +++ b/src/main/java/com/lions/dev/websocket/bridge/NotificationKafkaBridge.java @@ -3,6 +3,7 @@ package com.lions.dev.websocket.bridge; import com.lions.dev.dto.events.NotificationEvent; import com.lions.dev.websocket.NotificationWebSocketNext; import io.quarkus.logging.Log; +import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import org.eclipse.microprofile.reactive.messaging.Incoming; import org.eclipse.microprofile.reactive.messaging.Message; @@ -25,6 +26,11 @@ import java.util.concurrent.CompletionStage; @ApplicationScoped 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. * diff --git a/src/main/java/com/lions/dev/websocket/bridge/PresenceKafkaBridge.java b/src/main/java/com/lions/dev/websocket/bridge/PresenceKafkaBridge.java index 5ba741a..e846a12 100644 --- a/src/main/java/com/lions/dev/websocket/bridge/PresenceKafkaBridge.java +++ b/src/main/java/com/lions/dev/websocket/bridge/PresenceKafkaBridge.java @@ -3,6 +3,7 @@ package com.lions.dev.websocket.bridge; import com.lions.dev.dto.events.PresenceEvent; import com.lions.dev.websocket.NotificationWebSocketNext; import io.quarkus.logging.Log; +import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import org.eclipse.microprofile.reactive.messaging.Incoming; import org.eclipse.microprofile.reactive.messaging.Message; @@ -23,6 +24,11 @@ import java.util.concurrent.CompletionStage; @ApplicationScoped 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. * diff --git a/src/main/java/com/lions/dev/websocket/bridge/ReactionKafkaBridge.java b/src/main/java/com/lions/dev/websocket/bridge/ReactionKafkaBridge.java index 6d2ae7d..dceefe5 100644 --- a/src/main/java/com/lions/dev/websocket/bridge/ReactionKafkaBridge.java +++ b/src/main/java/com/lions/dev/websocket/bridge/ReactionKafkaBridge.java @@ -3,6 +3,7 @@ package com.lions.dev.websocket.bridge; import com.lions.dev.dto.events.ReactionEvent; import com.lions.dev.websocket.NotificationWebSocketNext; import io.quarkus.logging.Log; +import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import org.eclipse.microprofile.reactive.messaging.Incoming; import org.eclipse.microprofile.reactive.messaging.Message; @@ -22,6 +23,11 @@ import java.util.concurrent.CompletionStage; @ApplicationScoped 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. * diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 490eb5c..eb07216 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -4,6 +4,13 @@ # Ce fichier est automatiquement chargé avec: mvn quarkus:dev # 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) # ==================================================================== @@ -24,6 +31,12 @@ quarkus.hibernate-orm.packages=com.lions.dev.entity # Forcer la création du schéma au démarrage quarkus.hibernate-orm.schema-generation.scripts.action=drop-and-create +# ==================================================================== +# 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 # ==================================================================== diff --git a/src/main/resources/application-production.properties b/src/main/resources/application-prod.properties similarity index 68% rename from src/main/resources/application-production.properties rename to src/main/resources/application-prod.properties index 020845c..7956f81 100644 --- a/src/main/resources/application-production.properties +++ b/src/main/resources/application-prod.properties @@ -1,8 +1,9 @@ # ==================================================================== -# AfterWork Server - Configuration PRODUCTION +# AfterWork Server - Configuration PRODUCTION (profil prod) # ==================================================================== -# Ce fichier est automatiquement chargé avec: java -jar app.jar -# Les configurations ici surchargent celles de application.properties +# Chargé avec QUARKUS_PROFILE=prod (Kubernetes ConfigMap). +# Ce fichier remplace application-production.properties pour cohérence +# avec le déploiement (QUARKUS_PROFILE=prod). # ==================================================================== # HTTP - Chemin de base de l'API @@ -19,34 +20,26 @@ # - pathType: Prefix # - 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 # ==================================================================== # Swagger/OpenAPI (Production) # ==================================================================== -# Configuration pour que Swagger UI fonctionne avec root-path quarkus.swagger-ui.enable=true quarkus.swagger-ui.always-include=true quarkus.swagger-ui.path=/q/swagger-ui -# Configuration du chemin OpenAPI (relatif au root-path) 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/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 +quarkus.smallrye-openapi.servers=https://api.lions.dev # ==================================================================== # 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.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.password=${DB_PASSWORD:LionsUser2025!} +quarkus.datasource.password=${DB_PASSWORD} quarkus.datasource.jdbc.driver=org.postgresql.Driver quarkus.datasource.jdbc.max-size=20 quarkus.datasource.jdbc.min-size=5 @@ -94,42 +87,18 @@ quarkus.log.category."io.quarkus".level=INFO # ==================================================================== # 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} - -# Configuration de resilience Kafka mp.messaging.connector.smallrye-kafka.health-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 -# ==================================================================== -# 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) +# WebSocket / SSL / Performance / Localisation # ==================================================================== quarkus.http.ssl.certificate.files= quarkus.http.ssl.certificate.key-files= quarkus.http.insecure-requests=enabled - -# ==================================================================== -# Performance -# ==================================================================== quarkus.thread-pool.core-threads=2 quarkus.thread-pool.max-threads=16 quarkus.thread-pool.queue-size=100 - -# ==================================================================== -# Localisation -# ==================================================================== quarkus.locales=fr-FR,en-US quarkus.default-locale=fr-FR diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9967b7d..9fc713d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,6 +17,23 @@ quarkus.swagger-ui.always-include=true quarkus.swagger-ui.path=/q/swagger-ui 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) # ==================================================================== @@ -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 # Topic: Notifications +# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter mp.messaging.outgoing.notifications.connector=smallrye-kafka mp.messaging.outgoing.notifications.topic=notifications mp.messaging.outgoing.notifications.key.serializer=org.apache.kafka.common.serialization.StringSerializer # value.serializer omis - Quarkus génère automatiquement depuis Emitter # Topic: Chat Messages +# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter mp.messaging.outgoing.chat-messages.connector=smallrye-kafka mp.messaging.outgoing.chat-messages.topic=chat.messages mp.messaging.outgoing.chat-messages.key.serializer=org.apache.kafka.common.serialization.StringSerializer # value.serializer omis - Quarkus génère automatiquement depuis Emitter # Topic: Reactions (likes, comments, shares) +# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter mp.messaging.outgoing.reactions.connector=smallrye-kafka mp.messaging.outgoing.reactions.topic=reactions mp.messaging.outgoing.reactions.key.serializer=org.apache.kafka.common.serialization.StringSerializer # value.serializer omis - Quarkus génère automatiquement depuis Emitter # Topic: Presence Updates +# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter mp.messaging.outgoing.presence.connector=smallrye-kafka mp.messaging.outgoing.presence.topic=presence.updates mp.messaging.outgoing.presence.key.serializer=org.apache.kafka.common.serialization.StringSerializer diff --git a/src/main/resources/db/migration/V12__Cleanup_Users_Legacy_Columns.sql b/src/main/resources/db/migration/V12__Cleanup_Users_Legacy_Columns.sql new file mode 100644 index 0000000..ce48b93 --- /dev/null +++ b/src/main/resources/db/migration/V12__Cleanup_Users_Legacy_Columns.sql @@ -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'; diff --git a/src/main/resources/db/migration/V13__Create_Establishment_Subscriptions_And_Payments.sql b/src/main/resources/db/migration/V13__Create_Establishment_Subscriptions_And_Payments.sql new file mode 100644 index 0000000..8af4582 --- /dev/null +++ b/src/main/resources/db/migration/V13__Create_Establishment_Subscriptions_And_Payments.sql @@ -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'; diff --git a/src/main/resources/db/migration/V14__Add_IsActive_Users_Establishments.sql b/src/main/resources/db/migration/V14__Add_IsActive_Users_Establishments.sql new file mode 100644 index 0000000..b72b0a3 --- /dev/null +++ b/src/main/resources/db/migration/V14__Add_IsActive_Users_Establishments.sql @@ -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)'; diff --git a/src/main/resources/db/migration/V15__Create_Social_Posts_Table.sql b/src/main/resources/db/migration/V15__Create_Social_Posts_Table.sql new file mode 100644 index 0000000..6a39e39 --- /dev/null +++ b/src/main/resources/db/migration/V15__Create_Social_Posts_Table.sql @@ -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'; diff --git a/src/main/resources/db/migration/V1_1__Create_Base_Tables_For_Fresh_Db.sql b/src/main/resources/db/migration/V1_1__Create_Base_Tables_For_Fresh_Db.sql new file mode 100644 index 0000000..e108fef --- /dev/null +++ b/src/main/resources/db/migration/V1_1__Create_Base_Tables_For_Fresh_Db.sql @@ -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); diff --git a/src/main/resources/db/migration/V3__Migrate_Users_To_V2.sql b/src/main/resources/db/migration/V3__Migrate_Users_To_V2.sql index b8523de..485061f 100644 --- a/src/main/resources/db/migration/V3__Migrate_Users_To_V2.sql +++ b/src/main/resources/db/migration/V3__Migrate_Users_To_V2.sql @@ -1,13 +1,38 @@ -- Migration V3: Migration de la table users vers l'architecture v2.0 -- Date: 2026-01-15 -- 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 -ALTER TABLE users RENAME COLUMN nom TO first_name; -ALTER TABLE users RENAME COLUMN prenoms TO last_name; -ALTER TABLE users RENAME COLUMN mot_de_passe TO password_hash; +-- Renommer/migrer les colonnes existantes (idempotent) +DO $$ +BEGIN + -- 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 loyalty_points INTEGER DEFAULT 0 NOT NULL; diff --git a/src/main/resources/db/migration/V5__Create_Business_Hours_Table.sql b/src/main/resources/db/migration/V5__Create_Business_Hours_Table.sql index b9be2bc..7cddb1d 100644 --- a/src/main/resources/db/migration/V5__Create_Business_Hours_Table.sql +++ b/src/main/resources/db/migration/V5__Create_Business_Hours_Table.sql @@ -47,10 +47,11 @@ BEGIN END; $$ LANGUAGE plpgsql; +-- EXECUTE PROCEDURE pour compatibilité PostgreSQL < 11 (EXECUTE FUNCTION à partir de PG 11) CREATE TRIGGER trigger_update_business_hours_updated_at BEFORE UPDATE ON business_hours FOR EACH ROW - EXECUTE FUNCTION update_business_hours_updated_at(); + EXECUTE PROCEDURE update_business_hours_updated_at(); -- Commentaires pour documentation COMMENT ON TABLE business_hours IS 'Horaires d''ouverture des établissements (v2.0)';