From 93c63fd60014c58e0dc38f1820ba5cdb1dbb4e59 Mon Sep 17 00:00:00 2001 From: dahoud Date: Wed, 21 Jan 2026 13:46:16 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20migration=20compl=C3=A8te=20vers=20WebS?= =?UTF-8?q?ockets=20Next=20+=20Kafka=20pour=20temps=20r=C3=A9el?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration de Jakarta WebSocket vers Quarkus WebSockets Next - Implémentation de l'architecture Kafka pour événements temps réel - Ajout des DTOs d'événements (NotificationEvent, ChatMessageEvent, ReactionEvent, PresenceEvent) - Création des bridges Kafka → WebSocket (NotificationKafkaBridge, ChatKafkaBridge, ReactionKafkaBridge) - Mise à jour des services pour publier dans Kafka au lieu d'appeler directement WebSocket - Suppression des classes obsolètes (ChatWebSocket, NotificationWebSocket) - Correction de l'injection des paramètres path dans WebSockets Next (utilisation de connection.pathParam) - Ajout des migrations DB pour bookings, promotions, business hours, amenities, reviews - Mise à jour de la configuration application.properties pour Kafka et WebSockets Next - Mise à jour .gitignore pour ignorer les fichiers de logs --- .gitignore | 6 + .mvn/jvm.config | 3 + REALTIME_ARCHITECTURE_BRAINSTORM.md | 450 +++++++++ REALTIME_IMPLEMENTATION_EXAMPLES.md | 930 ++++++++++++++++++ backend_log.txt | 2 +- pom.xml | 13 +- .../dev/dto/events/ChatMessageEvent.java | 75 ++ .../dev/dto/events/NotificationEvent.java | 52 + .../lions/dev/dto/events/PresenceEvent.java | 48 + .../lions/dev/dto/events/ReactionEvent.java | 63 ++ .../EstablishmentCreateRequestDTO.java | 48 +- .../EstablishmentMediaRequestDTO.java | 31 + .../request/events/EventCreateRequestDTO.java | 25 +- .../events/EventReadManyByIdRequestDTO.java | 5 +- .../social/SocialPostCreateRequestDTO.java | 2 +- .../request/story/StoryCreateRequestDTO.java | 2 +- .../users/UserAuthenticateRequestDTO.java | 24 +- .../request/users/UserCreateRequestDTO.java | 101 +- .../chat/ConversationResponseDTO.java | 5 +- .../dto/response/chat/MessageResponseDTO.java | 7 +- .../response/comments/CommentResponseDTO.java | 5 +- .../EstablishmentMediaResponseDTO.java | 5 +- .../EstablishmentResponseDTO.java | 100 +- .../events/EventReadManyByIdResponseDTO.java | 8 +- .../FriendshipReadStatusResponseDTO.java | 9 +- .../social/SocialPostResponseDTO.java | 7 +- .../dto/response/story/StoryResponseDTO.java | 5 +- .../users/FriendSuggestionResponseDTO.java | 7 +- .../users/UserAuthenticateResponseDTO.java | 65 +- .../response/users/UserCreateResponseDTO.java | 54 +- .../dto/response/users/UserResponseDTO.java | 77 +- .../com/lions/dev/entity/booking/Booking.java | 105 ++ .../entity/establishment/BusinessHours.java | 90 ++ .../entity/establishment/Establishment.java | 49 +- .../establishment/EstablishmentAmenity.java | 98 ++ .../dev/entity/establishment/Review.java | 92 ++ .../com/lions/dev/entity/events/Events.java | 54 +- .../lions/dev/entity/promotion/Promotion.java | 119 +++ .../lions/dev/entity/reaction/Reaction.java | 71 +- .../EstablishmentRatingRepository.java | 14 +- .../lions/dev/repository/StoryRepository.java | 35 +- .../resource/EstablishmentMediaResource.java | 41 +- .../resource/EstablishmentRatingResource.java | 126 ++- .../dev/resource/EstablishmentResource.java | 12 - .../lions/dev/resource/EventsResource.java | 25 +- .../dev/resource/FileUploadResource.java | 3 +- .../dev/resource/NotificationResource.java | 2 +- .../dev/resource/SocialPostResource.java | 61 +- .../com/lions/dev/resource/StoryResource.java | 27 +- .../com/lions/dev/resource/UsersResource.java | 14 +- .../service/EstablishmentMediaService.java | 22 +- .../service/EstablishmentRatingService.java | 3 +- .../dev/service/EstablishmentService.java | 29 +- .../com/lions/dev/service/EventService.java | 111 ++- .../lions/dev/service/FriendshipService.java | 68 +- .../com/lions/dev/service/MessageService.java | 137 ++- .../lions/dev/service/PresenceService.java | 6 +- .../lions/dev/service/SocialPostService.java | 104 +- .../com/lions/dev/service/StoryService.java | 32 +- .../com/lions/dev/service/UsersService.java | 79 +- .../lions/dev/websocket/ChatWebSocket.java | 361 ------- .../dev/websocket/ChatWebSocketNext.java | 301 ++++++ .../dev/websocket/NotificationWebSocket.java | 370 ------- .../websocket/NotificationWebSocketNext.java | 282 ++++++ .../dev/websocket/bridge/ChatKafkaBridge.java | 86 ++ .../bridge/NotificationKafkaBridge.java | 81 ++ .../websocket/bridge/ReactionKafkaBridge.java | 93 ++ src/main/resources/application-dev.properties | 3 + src/main/resources/application.properties | 64 ++ .../migration/V10__Create_Bookings_Table.sql | 81 ++ .../V11__Create_Promotions_Table.sql | 80 ++ .../db/migration/V3__Migrate_Users_To_V2.sql | 48 + .../V4__Migrate_Establishments_To_V2.sql | 74 ++ .../V5__Create_Business_Hours_Table.sql | 64 ++ ...__Create_Establishment_Amenities_Table.sql | 65 ++ .../db/migration/V7__Migrate_Events_To_V2.sql | 55 ++ .../db/migration/V8__Create_Reviews_Table.sql | 97 ++ .../migration/V9__Create_Reactions_Table.sql | 64 ++ 78 files changed, 5019 insertions(+), 1113 deletions(-) create mode 100644 .mvn/jvm.config create mode 100644 REALTIME_ARCHITECTURE_BRAINSTORM.md create mode 100644 REALTIME_IMPLEMENTATION_EXAMPLES.md create mode 100644 src/main/java/com/lions/dev/dto/events/ChatMessageEvent.java create mode 100644 src/main/java/com/lions/dev/dto/events/NotificationEvent.java create mode 100644 src/main/java/com/lions/dev/dto/events/PresenceEvent.java create mode 100644 src/main/java/com/lions/dev/dto/events/ReactionEvent.java create mode 100644 src/main/java/com/lions/dev/dto/request/establishment/EstablishmentMediaRequestDTO.java create mode 100644 src/main/java/com/lions/dev/entity/booking/Booking.java create mode 100644 src/main/java/com/lions/dev/entity/establishment/BusinessHours.java create mode 100644 src/main/java/com/lions/dev/entity/establishment/EstablishmentAmenity.java create mode 100644 src/main/java/com/lions/dev/entity/establishment/Review.java create mode 100644 src/main/java/com/lions/dev/entity/promotion/Promotion.java delete mode 100644 src/main/java/com/lions/dev/websocket/ChatWebSocket.java create mode 100644 src/main/java/com/lions/dev/websocket/ChatWebSocketNext.java delete mode 100644 src/main/java/com/lions/dev/websocket/NotificationWebSocket.java create mode 100644 src/main/java/com/lions/dev/websocket/NotificationWebSocketNext.java create mode 100644 src/main/java/com/lions/dev/websocket/bridge/ChatKafkaBridge.java create mode 100644 src/main/java/com/lions/dev/websocket/bridge/NotificationKafkaBridge.java create mode 100644 src/main/java/com/lions/dev/websocket/bridge/ReactionKafkaBridge.java create mode 100644 src/main/resources/db/migration/V10__Create_Bookings_Table.sql create mode 100644 src/main/resources/db/migration/V11__Create_Promotions_Table.sql create mode 100644 src/main/resources/db/migration/V3__Migrate_Users_To_V2.sql create mode 100644 src/main/resources/db/migration/V4__Migrate_Establishments_To_V2.sql create mode 100644 src/main/resources/db/migration/V5__Create_Business_Hours_Table.sql create mode 100644 src/main/resources/db/migration/V6__Create_Establishment_Amenities_Table.sql create mode 100644 src/main/resources/db/migration/V7__Migrate_Events_To_V2.sql create mode 100644 src/main/resources/db/migration/V8__Create_Reviews_Table.sql create mode 100644 src/main/resources/db/migration/V9__Create_Reactions_Table.sql diff --git a/.gitignore b/.gitignore index 91a800a..6488ce2 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,9 @@ nb-configuration.xml /.quarkus/cli/plugins/ # TLS Certificates .certs/ + +# Logs +*.log +hs_err_pid*.log +replay_pid*.log +backend_log.txt diff --git a/.mvn/jvm.config b/.mvn/jvm.config new file mode 100644 index 0000000..89ad17d --- /dev/null +++ b/.mvn/jvm.config @@ -0,0 +1,3 @@ +-Xmx2048m +-Xms1024m +-XX:MaxMetaspaceSize=512m diff --git a/REALTIME_ARCHITECTURE_BRAINSTORM.md b/REALTIME_ARCHITECTURE_BRAINSTORM.md new file mode 100644 index 0000000..7dbe9c6 --- /dev/null +++ b/REALTIME_ARCHITECTURE_BRAINSTORM.md @@ -0,0 +1,450 @@ +# 🚀 Architecture Temps Réel - Brainstorming & Plan d'Implémentation + +## 📋 Contexte Actuel + +### État des Lieux +- ✅ **Backend**: Jakarta WebSocket (`@ServerEndpoint`) - API legacy +- ✅ **Frontend**: `web_socket_channel` pour WebSocket +- ✅ **Services existants**: + - `NotificationWebSocket` (`/notifications/ws/{userId}`) + - `ChatWebSocket` (`/chat/ws/{userId}`) + - Services Flutter: `RealtimeNotificationService`, `ChatWebSocketService` + +### Limitations Actuelles +1. **Pas de persistance des événements** : Si un utilisateur est déconnecté, les messages sont perdus +2. **Pas de scalabilité horizontale** : Les sessions WebSocket sont en mémoire, ne fonctionnent pas avec plusieurs instances +3. **Pas de garantie de livraison** : Pas de mécanisme de retry ou de queue +4. **Pas de découplage** : Services directement couplés aux WebSockets + +--- + +## 🎯 Objectifs + +1. **Garantir la livraison** : Aucun événement ne doit être perdu +2. **Scalabilité horizontale** : Support de plusieurs instances Quarkus +3. **Temps réel garanti** : Latence < 100ms pour les notifications critiques +4. **Durabilité** : Persistance des événements pour récupération après déconnexion +5. **Découplage** : Services métier indépendants des WebSockets + +--- + +## 🏗️ Architecture Proposée + +### Option 1 : WebSockets Next + Kafka (Recommandée) ⭐ + +``` +┌─────────────┐ +│ Flutter │ +│ Client │ +└──────┬──────┘ + │ WebSocket (wss://) + │ +┌──────▼─────────────────────────────────────┐ +│ Quarkus WebSockets Next │ +│ ┌──────────────────────────────────────┐ │ +│ │ @WebSocket("/notifications/{userId}")│ │ +│ │ @WebSocket("/chat/{userId}") │ │ +│ └──────┬───────────────────────────────┘ │ +│ │ │ +│ ┌──────▼──────────────────────────────┐ │ +│ │ Reactive Messaging Bridge │ │ +│ │ @Incoming("kafka-notifications") │ │ +│ │ @Outgoing("websocket-notifications") │ │ +│ └──────┬───────────────────────────────┘ │ +└─────────┼──────────────────────────────────┘ + │ + │ Kafka Topics + │ +┌─────────▼──────────────────────────────────┐ +│ Apache Kafka Cluster │ +│ ┌──────────────────────────────────────┐ │ +│ │ Topics: │ │ +│ │ - notifications.{userId} │ │ +│ │ - chat.messages │ │ +│ │ - reactions.{postId} │ │ +│ │ - presence.updates │ │ +│ └──────────────────────────────────────┘ │ +└─────────┬──────────────────────────────────┘ + │ + │ Producers + │ +┌─────────▼──────────────────────────────────┐ +│ Services Métier (Quarkus) │ +│ - FriendshipService │ +│ - MessageService │ +│ - SocialPostService │ +│ - EventService │ +└────────────────────────────────────────────┘ +``` + +### Avantages +- ✅ **Scalabilité** : Kafka gère la distribution entre instances +- ✅ **Durabilité** : Messages persistés dans Kafka (rétention configurable) +- ✅ **Découplage** : Services publient dans Kafka, WebSocket consomme +- ✅ **Performance** : WebSockets Next est plus performant que Jakarta WS +- ✅ **Replay** : Possibilité de rejouer les événements pour récupération +- ✅ **Monitoring** : Kafka fournit des métriques natives + +### Inconvénients +- ⚠️ **Complexité** : Nécessite un cluster Kafka (mais Quarkus Dev Services le gère automatiquement) +- ⚠️ **Latence** : Légèrement plus élevée (Kafka + WebSocket vs WebSocket direct) +- ⚠️ **Ressources** : Kafka consomme plus de mémoire + +--- + +## 📦 Technologies & Dépendances + +### Backend (Quarkus) + +#### 1. WebSockets Next (Remplace Jakarta WebSocket) +```xml + + io.quarkus + quarkus-websockets-next + +``` + +**Documentation**: https://quarkus.io/guides/websockets-next + +#### 2. Kafka Reactive Messaging +```xml + + io.quarkus + quarkus-messaging-kafka + +``` + +**Documentation**: https://quarkus.io/guides/kafka + +#### 3. Reactive Messaging HTTP (Bridge Kafka ↔ WebSocket) +```xml + + io.quarkiverse.reactivemessaginghttp + quarkus-reactive-messaging-http + 1.0.0 + +``` + +**Documentation**: https://docs.quarkiverse.io/quarkus-reactive-messaging-http/dev/reactive-messaging-websocket.html + +### Frontend (Flutter) + +#### Package WebSocket (Déjà utilisé) +```yaml +dependencies: + web_socket_channel: ^2.4.0 +``` + +**Documentation**: https://pub.dev/packages/web_socket_channel + +--- + +## 🔧 Configuration + +### application.properties (Quarkus) + +```properties +# ============================================ +# Kafka Configuration +# ============================================ +kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + +# Topics +mp.messaging.outgoing.notifications.connector=smallrye-kafka +mp.messaging.outgoing.notifications.topic=notifications +mp.messaging.outgoing.notifications.key.serializer=org.apache.kafka.common.serialization.StringSerializer +mp.messaging.outgoing.notifications.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer + +mp.messaging.outgoing.chat-messages.connector=smallrye-kafka +mp.messaging.outgoing.chat-messages.topic=chat.messages +mp.messaging.outgoing.chat-messages.key.serializer=org.apache.kafka.common.serialization.StringSerializer +mp.messaging.outgoing.chat-messages.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer + +mp.messaging.outgoing.reactions.connector=smallrye-kafka +mp.messaging.outgoing.reactions.topic=reactions +mp.messaging.outgoing.reactions.key.serializer=org.apache.kafka.common.serialization.StringSerializer +mp.messaging.outgoing.reactions.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer + +# ============================================ +# WebSocket Configuration +# ============================================ +# WebSockets Next +quarkus.websockets-next.server.enabled=true +quarkus.websockets-next.server.port=8080 + +# ============================================ +# Reactive Messaging HTTP (Bridge) +# ============================================ +# Incoming Kafka → Outgoing WebSocket +mp.messaging.incoming.kafka-notifications.connector=smallrye-kafka +mp.messaging.incoming.kafka-notifications.topic=notifications +mp.messaging.incoming.kafka-notifications.group.id=websocket-bridge +mp.messaging.incoming.kafka-notifications.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer +mp.messaging.incoming.kafka-notifications.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer + +mp.messaging.outgoing.ws-notifications.connector=quarkus-websocket +mp.messaging.outgoing.ws-notifications.path=/notifications/{userId} +``` + +--- + +## 💻 Implémentation + +### Backend : Migration vers WebSockets Next + +#### 1. Nouveau NotificationWebSocket (WebSockets Next) + +```java +package com.lions.dev.websocket; + +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection; +import io.smallrye.mutiny.Multi; +import jakarta.inject.Inject; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Outgoing; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; + +/** + * WebSocket endpoint pour les notifications en temps réel (WebSockets Next). + * + * Architecture: + * - Services métier → Kafka Topic → WebSocket Bridge → Client + */ +@WebSocket(path = "/notifications/{userId}") +public class NotificationWebSocketNext { + + @Inject + @Channel("ws-notifications") + Emitter notificationEmitter; + + // Stockage des connexions actives (pour routing) + private static final Map connections = new ConcurrentHashMap<>(); + + @OnOpen + public void onOpen(WebSocketConnection connection, String userId) { + UUID userUUID = UUID.fromString(userId); + connections.put(userUUID, connection); + + // Envoyer confirmation de connexion + connection.sendText("{\"type\":\"connected\",\"timestamp\":" + System.currentTimeMillis() + "}"); + } + + @OnClose + public void onClose(String userId) { + UUID userUUID = UUID.fromString(userId); + connections.remove(userUUID); + } + + @OnTextMessage + public void onMessage(String message, String userId) { + // Gérer les messages du client (ping, ack, etc.) + // ... + } + + /** + * Bridge: Consomme depuis Kafka et envoie via WebSocket + */ + @Incoming("kafka-notifications") + @Outgoing("ws-notifications") + public Multi bridgeNotifications(String kafkaMessage) { + // Parser le message Kafka + // Extraire userId depuis la clé Kafka + // Router vers la bonne connexion WebSocket + return Multi.createFrom().item(kafkaMessage); + } +} +``` + +#### 2. Service Métier Publie dans Kafka + +```java +package com.lions.dev.service; + +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import jakarta.inject.Inject; + +@ApplicationScoped +public class FriendshipService { + + @Inject + @Channel("notifications") + Emitter notificationEmitter; + + public void sendFriendRequest(UUID fromUserId, UUID toUserId) { + // ... logique métier ... + + // Publier dans Kafka au lieu d'appeler directement WebSocket + NotificationEvent event = new NotificationEvent( + toUserId.toString(), + "friend_request", + Map.of("fromUserId", fromUserId.toString(), "fromName", fromUser.getFirstName()) + ); + + notificationEmitter.send(event); + } +} +``` + +### Frontend : Amélioration du Service WebSocket + +```dart +// afterwork/lib/data/services/realtime_notification_service_v2.dart + +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/status.dart' as status; + +class RealtimeNotificationServiceV2 { + WebSocketChannel? _channel; + Timer? _heartbeatTimer; + Timer? _reconnectTimer; + int _reconnectAttempts = 0; + static const int _maxReconnectAttempts = 5; + static const Duration _heartbeatInterval = Duration(seconds: 30); + static const Duration _reconnectDelay = Duration(seconds: 5); + + Future connect(String userId, String authToken) async { + final uri = Uri.parse('wss://api.afterwork.lions.dev/notifications/$userId'); + + _channel = WebSocketChannel.connect( + uri, + protocols: ['notifications-v2'], + headers: { + 'Authorization': 'Bearer $authToken', + }, + ); + + // Heartbeat pour maintenir la connexion + _heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) { + _channel?.sink.add(jsonEncode({'type': 'ping'})); + }); + + // Écouter les messages + _channel!.stream.listen( + _handleMessage, + onError: _handleError, + onDone: _handleDisconnection, + cancelOnError: false, + ); + } + + void _handleDisconnection() { + _heartbeatTimer?.cancel(); + _scheduleReconnect(); + } + + void _scheduleReconnect() { + if (_reconnectAttempts < _maxReconnectAttempts) { + _reconnectTimer = Timer(_reconnectDelay * (_reconnectAttempts + 1), () { + _reconnectAttempts++; + connect(_userId, _authToken); + }); + } + } +} +``` + +--- + +## 📊 Comparaison des Options + +| Critère | Jakarta WS (Actuel) | WebSockets Next | WebSockets Next + Kafka | +|---------|---------------------|-----------------|-------------------------| +| **Performance** | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | +| **Scalabilité** | ❌ (1 instance) | ⚠️ (limité) | ✅ (illimitée) | +| **Durabilité** | ❌ | ❌ | ✅ | +| **Découplage** | ❌ | ⚠️ | ✅ | +| **Complexité** | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | +| **Latence** | < 50ms | < 50ms | < 100ms | +| **Replay Events** | ❌ | ❌ | ✅ | +| **Monitoring** | ⚠️ | ⚠️ | ✅ (Kafka metrics) | + +--- + +## 🎯 Plan d'Implémentation Recommandé + +### Phase 1 : Migration WebSockets Next (Sans Kafka) +**Durée**: 1-2 semaines +- Migrer `NotificationWebSocket` vers WebSockets Next +- Migrer `ChatWebSocket` vers WebSockets Next +- Tester avec le frontend existant +- **Avantage**: Amélioration immédiate des performances + +### Phase 2 : Intégration Kafka +**Durée**: 2-3 semaines +- Ajouter dépendances Kafka +- Créer les topics Kafka +- Implémenter les bridges Kafka ↔ WebSocket +- Migrer les services pour publier dans Kafka +- **Avantage**: Scalabilité et durabilité + +### Phase 3 : Optimisations +**Durée**: 1 semaine +- Compression des messages +- Batching pour les notifications +- Monitoring et alertes +- **Avantage**: Performance et observabilité + +--- + +## 🔍 Alternatives Considérées + +### Option 2 : Server-Sent Events (SSE) +- ✅ Plus simple que WebSocket +- ❌ Unidirectionnel (serveur → client uniquement) +- ❌ Pas adapté pour le chat bidirectionnel + +### Option 3 : gRPC Streaming +- ✅ Performant +- ❌ Plus complexe à configurer +- ❌ Nécessite HTTP/2 + +### Option 4 : Socket.IO +- ✅ Reconnexion automatique +- ❌ Nécessite Node.js (pas compatible Quarkus) + +--- + +## 📚 Ressources & Documentation + +### Quarkus +- [WebSockets Next Guide](https://quarkus.io/guides/websockets-next) +- [Kafka Guide](https://quarkus.io/guides/kafka) +- [Reactive Messaging](https://quarkus.io/guides/messaging) + +### Kafka +- [Kafka Documentation](https://kafka.apache.org/documentation/) +- [Quarkus Kafka Dev Services](https://quarkus.io/guides/kafka-dev-services) + +### Flutter +- [web_socket_channel Package](https://pub.dev/packages/web_socket_channel) +- [Flutter WebSocket Best Practices](https://ably.com/topic/websockets-flutter) + +--- + +## ✅ Recommandation Finale + +**Architecture recommandée**: **WebSockets Next + Kafka** + +**Raisons**: +1. ✅ Scalabilité horizontale garantie +2. ✅ Durabilité des événements +3. ✅ Découplage des services +4. ✅ Compatible avec l'existant (migration progressive) +5. ✅ Support natif Quarkus (Dev Services pour Kafka) +6. ✅ Monitoring intégré + +**Prochaines étapes**: +1. Ajouter les dépendances dans `pom.xml` +2. Créer un POC avec un seul endpoint (notifications) +3. Tester la scalabilité avec 2 instances Quarkus +4. Migrer progressivement les autres endpoints diff --git a/REALTIME_IMPLEMENTATION_EXAMPLES.md b/REALTIME_IMPLEMENTATION_EXAMPLES.md new file mode 100644 index 0000000..91151c0 --- /dev/null +++ b/REALTIME_IMPLEMENTATION_EXAMPLES.md @@ -0,0 +1,930 @@ +# 💻 Exemples d'Implémentation - Temps Réel avec Kafka + +## 📦 Étape 1 : Ajouter les Dépendances + +### pom.xml + +```xml + + + io.quarkus + quarkus-websockets-next + + + + + io.quarkus + quarkus-messaging-kafka + + + + + io.quarkiverse.reactivemessaginghttp + quarkus-reactive-messaging-http + 1.0.0 + + + + + io.quarkus + quarkus-jsonb + +``` + +--- + +## 🔧 Étape 2 : Configuration application.properties + +```properties +# ============================================ +# Kafka Configuration +# ============================================ +kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + +# Topic: Notifications +mp.messaging.outgoing.notifications.connector=smallrye-kafka +mp.messaging.outgoing.notifications.topic=notifications +mp.messaging.outgoing.notifications.key.serializer=org.apache.kafka.common.serialization.StringSerializer +mp.messaging.outgoing.notifications.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer + +# Topic: Chat Messages +mp.messaging.outgoing.chat-messages.connector=smallrye-kafka +mp.messaging.outgoing.chat-messages.topic=chat.messages +mp.messaging.outgoing.chat-messages.key.serializer=org.apache.kafka.common.serialization.StringSerializer +mp.messaging.outgoing.chat-messages.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer + +# Topic: Reactions (likes, comments) +mp.messaging.outgoing.reactions.connector=smallrye-kafka +mp.messaging.outgoing.reactions.topic=reactions +mp.messaging.outgoing.reactions.key.serializer=org.apache.kafka.common.serialization.StringSerializer +mp.messaging.outgoing.reactions.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer + +# Topic: Presence Updates +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 +mp.messaging.outgoing.presence.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer + +# ============================================ +# Kafka → WebSocket Bridge (Incoming) +# ============================================ +# Consommer depuis Kafka et router vers WebSocket +mp.messaging.incoming.kafka-notifications.connector=smallrye-kafka +mp.messaging.incoming.kafka-notifications.topic=notifications +mp.messaging.incoming.kafka-notifications.group.id=websocket-notifications-bridge +mp.messaging.incoming.kafka-notifications.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer +mp.messaging.incoming.kafka-notifications.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer +mp.messaging.incoming.kafka-notifications.enable.auto.commit=true + +mp.messaging.incoming.kafka-chat.connector=smallrye-kafka +mp.messaging.incoming.kafka-chat.topic=chat.messages +mp.messaging.incoming.kafka-chat.group.id=websocket-chat-bridge +mp.messaging.incoming.kafka-chat.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer +mp.messaging.incoming.kafka-chat.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer +mp.messaging.incoming.kafka-chat.enable.auto.commit=true + +# ============================================ +# WebSocket Configuration +# ============================================ +quarkus.websockets-next.server.enabled=true +``` + +--- + +## 📝 Étape 3 : DTOs pour les Événements Kafka + +### NotificationEvent.java + +```java +package com.lions.dev.dto.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.util.Map; +import java.util.UUID; + +/** + * Événement de notification publié dans Kafka. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class NotificationEvent { + private String userId; // Clé Kafka (pour routing) + private String type; // friend_request, event_reminder, message_alert, etc. + private Map data; + private Long timestamp; + + public NotificationEvent(String userId, String type, Map data) { + this.userId = userId; + this.type = type; + this.data = data; + this.timestamp = System.currentTimeMillis(); + } +} +``` + +### ChatMessageEvent.java + +```java +package com.lions.dev.dto.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.util.UUID; + +/** + * Événement de message chat publié dans Kafka. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ChatMessageEvent { + private String conversationId; // Clé Kafka + private String senderId; + private String recipientId; + private String content; + private String messageId; + private Long timestamp; +} +``` + +### ReactionEvent.java + +```java +package com.lions.dev.dto.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Événement de réaction (like, comment) publié dans Kafka. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ReactionEvent { + private String postId; // Clé Kafka + private String userId; + private String reactionType; // like, comment, share + private Map data; + private Long timestamp; +} +``` + +--- + +## 🔌 Étape 4 : WebSocket avec WebSockets Next + +### NotificationWebSocketNext.java + +```java +package com.lions.dev.websocket; + +import io.quarkus.logging.Log; +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * WebSocket endpoint pour les notifications en temps réel (WebSockets Next). + * + * Architecture: + * Services → Kafka → Bridge → WebSocket → Client + */ +@WebSocket(path = "/notifications/{userId}") +@ApplicationScoped +public class NotificationWebSocketNext { + + // Stockage des connexions actives par utilisateur (multi-device support) + private static final Map> userConnections = new ConcurrentHashMap<>(); + + @OnOpen + public void onOpen(WebSocketConnection connection, String userId) { + try { + UUID userUUID = UUID.fromString(userId); + + // Ajouter la connexion à l'ensemble des connexions de l'utilisateur + userConnections.computeIfAbsent(userUUID, k -> ConcurrentHashMap.newKeySet()) + .add(connection); + + Log.info("[WS-NEXT] Connexion ouverte pour l'utilisateur: " + userId + + " (Total: " + userConnections.get(userUUID).size() + ")"); + + // Envoyer confirmation + connection.sendText("{\"type\":\"connected\",\"timestamp\":" + + System.currentTimeMillis() + "}"); + + } catch (IllegalArgumentException e) { + Log.error("[WS-NEXT] UUID invalide: " + userId, e); + connection.close(); + } + } + + @OnClose + public void onClose(String userId) { + try { + UUID userUUID = UUID.fromString(userId); + Set connections = userConnections.get(userUUID); + + if (connections != null) { + connections.removeIf(conn -> !conn.isOpen()); + + if (connections.isEmpty()) { + userConnections.remove(userUUID); + Log.info("[WS-NEXT] Toutes les connexions fermées pour: " + userId); + } else { + Log.info("[WS-NEXT] Connexion fermée pour: " + userId + + " (Restantes: " + connections.size() + ")"); + } + } + } catch (Exception e) { + Log.error("[WS-NEXT] Erreur lors de la fermeture", e); + } + } + + @OnTextMessage + public void onMessage(String message, String userId) { + try { + Log.debug("[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"); + + switch (type) { + case "ping": + handlePing(userId); + break; + case "ack": + handleAck(messageData, userId); + break; + default: + Log.warn("[WS-NEXT] Type de message inconnu: " + type); + } + } catch (Exception e) { + Log.error("[WS-NEXT] Erreur traitement message", e); + } + } + + private void handlePing(String userId) { + UUID userUUID = UUID.fromString(userId); + Set connections = userConnections.get(userUUID); + + if (connections != null) { + String pong = "{\"type\":\"pong\",\"timestamp\":" + + System.currentTimeMillis() + "}"; + connections.forEach(conn -> { + if (conn.isOpen()) { + conn.sendText(pong); + } + }); + } + } + + private void handleAck(Map messageData, String userId) { + String notificationId = (String) messageData.get("notificationId"); + Log.debug("[WS-NEXT] ACK reçu pour notification " + notificationId + + " de " + userId); + } + + /** + * Envoie une notification à un utilisateur spécifique. + * Appelé par le bridge Kafka → WebSocket. + */ + public static void sendToUser(UUID userId, String message) { + Set connections = userConnections.get(userId); + + if (connections == null || connections.isEmpty()) { + Log.debug("[WS-NEXT] Utilisateur " + userId + " non connecté"); + return; + } + + int success = 0; + int failed = 0; + + for (WebSocketConnection conn : connections) { + if (conn.isOpen()) { + try { + conn.sendText(message); + success++; + } catch (Exception e) { + failed++; + Log.error("[WS-NEXT] Erreur envoi à " + userId, e); + } + } else { + failed++; + } + } + + Log.info("[WS-NEXT] Notification envoyée à " + userId + + " (Succès: " + success + ", Échec: " + failed + ")"); + } +} +``` + +--- + +## 🌉 Étape 5 : Bridge Kafka → WebSocket + +### NotificationKafkaBridge.java + +```java +package com.lions.dev.websocket; + +import com.lions.dev.dto.events.NotificationEvent; +import io.quarkus.logging.Log; +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Message; + +import java.util.UUID; + +/** + * Bridge qui consomme depuis Kafka et envoie via WebSocket. + * + * Architecture: + * Kafka Topic (notifications) → Bridge → WebSocket (NotificationWebSocketNext) + */ +@ApplicationScoped +public class NotificationKafkaBridge { + + /** + * Consomme les événements depuis Kafka et les route vers WebSocket. + */ + @Incoming("kafka-notifications") + public void processNotification(Message message) { + try { + NotificationEvent event = message.getPayload(); + + Log.debug("[KAFKA-BRIDGE] Événement reçu: " + event.getType() + + " pour utilisateur: " + event.getUserId()); + + UUID userId = UUID.fromString(event.getUserId()); + + // Construire le message JSON pour WebSocket + String wsMessage = buildWebSocketMessage(event); + + // Envoyer via WebSocket + NotificationWebSocketNext.sendToUser(userId, wsMessage); + + // Acknowledger le message Kafka + message.ack(); + + } catch (Exception e) { + Log.error("[KAFKA-BRIDGE] Erreur traitement événement", e); + message.nack(e); + } + } + + private String buildWebSocketMessage(NotificationEvent event) { + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = + new com.fasterxml.jackson.databind.ObjectMapper(); + + java.util.Map wsMessage = java.util.Map.of( + "type", event.getType(), + "data", event.getData(), + "timestamp", event.getTimestamp() + ); + + return mapper.writeValueAsString(wsMessage); + } catch (Exception e) { + Log.error("[KAFKA-BRIDGE] Erreur construction message", e); + return "{\"type\":\"error\",\"message\":\"Erreur de traitement\"}"; + } + } +} +``` + +--- + +## 📤 Étape 6 : Services Publient dans Kafka + +### FriendshipService (Modifié) + +```java +package com.lions.dev.service; + +import com.lions.dev.dto.events.NotificationEvent; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import jakarta.inject.Inject; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class FriendshipService { + + @Inject + @Channel("notifications") + Emitter notificationEmitter; + + // ... autres dépendances ... + + /** + * Envoie une demande d'amitié (publie dans Kafka). + */ + @Transactional + public FriendshipCreateOneResponseDTO sendFriendRequest( + FriendshipCreateOneRequestDTO request) { + + // ... logique métier existante ... + + // ✅ NOUVEAU: Publier dans Kafka au lieu d'appeler directement WebSocket + try { + NotificationEvent event = new NotificationEvent( + request.getFriendId().toString(), // userId destinataire + "friend_request", + java.util.Map.of( + "fromUserId", request.getUserId().toString(), + "fromFirstName", user.getFirstName(), + "fromLastName", user.getLastName(), + "requestId", response.getFriendshipId().toString() + ) + ); + + notificationEmitter.send(event); + logger.info("[LOG] Événement friend_request publié dans Kafka pour: " + + request.getFriendId()); + + } catch (Exception e) { + logger.error("[ERROR] Erreur publication Kafka", e); + // Ne pas bloquer la demande d'amitié si Kafka échoue + } + + return response; + } + + /** + * Accepte une demande d'amitié (publie dans Kafka). + */ + @Transactional + public FriendshipCreateOneResponseDTO acceptFriendRequest(UUID friendshipId) { + // ... logique métier existante ... + + // ✅ NOUVEAU: Publier dans Kafka + try { + NotificationEvent event = new NotificationEvent( + originalRequest.getUserId().toString(), // userId émetteur + "friend_request_accepted", + java.util.Map.of( + "friendId", friend.getId().toString(), + "friendFirstName", friend.getFirstName(), + "friendLastName", friend.getLastName(), + "friendshipId", response.getFriendshipId().toString() + ) + ); + + notificationEmitter.send(event); + logger.info("[LOG] Événement friend_request_accepted publié dans Kafka"); + + } catch (Exception e) { + logger.error("[ERROR] Erreur publication Kafka", e); + } + + return response; + } +} +``` + +### MessageService (Modifié) + +```java +package com.lions.dev.service; + +import com.lions.dev.dto.events.ChatMessageEvent; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import jakarta.inject.Inject; + +@ApplicationScoped +public class MessageService { + + @Inject + @Channel("chat-messages") + Emitter chatMessageEmitter; + + /** + * Envoie un message (publie dans Kafka). + */ + @Transactional + public MessageResponseDTO sendMessage(SendMessageRequestDTO request) { + // ... logique métier existante ... + + // ✅ NOUVEAU: Publier dans Kafka + try { + ChatMessageEvent event = new ChatMessageEvent(); + event.setConversationId(conversation.getId().toString()); + event.setSenderId(senderId.toString()); + event.setRecipientId(recipientId.toString()); + event.setContent(request.getContent()); + event.setMessageId(message.getId().toString()); + event.setTimestamp(System.currentTimeMillis()); + + // Utiliser conversationId comme clé Kafka pour garantir l'ordre + chatMessageEmitter.send(org.eclipse.microprofile.reactive.messaging.Message.of( + event, + () -> CompletableFuture.completedFuture(null), // ack + throwable -> { + logger.error("[ERROR] Erreur envoi Kafka", throwable); + return CompletableFuture.completedFuture(null); // nack + } + ).addMetadata(org.eclipse.microprofile.reactive.messaging.OutgoingMessageMetadata.builder() + .withKey(conversation.getId().toString()) + .build())); + + logger.info("[LOG] Message publié dans Kafka: " + message.getId()); + + } catch (Exception e) { + logger.error("[ERROR] Erreur publication Kafka", e); + // Ne pas bloquer l'envoi du message si Kafka échoue + } + + return response; + } +} +``` + +### SocialPostService (Modifié pour les Réactions) + +```java +package com.lions.dev.service; + +import com.lions.dev.dto.events.ReactionEvent; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import jakarta.inject.Inject; + +@ApplicationScoped +public class SocialPostService { + + @Inject + @Channel("reactions") + Emitter reactionEmitter; + + /** + * Like un post (publie dans Kafka). + */ + @Transactional + public SocialPost likePost(UUID postId, UUID userId) { + // ... logique métier existante ... + + // ✅ NOUVEAU: Publier dans Kafka pour notifier en temps réel + try { + ReactionEvent event = new ReactionEvent(); + event.setPostId(postId.toString()); + event.setUserId(userId.toString()); + event.setReactionType("like"); + event.setData(java.util.Map.of( + "postId", postId.toString(), + "userId", userId.toString(), + "likesCount", post.getLikesCount() + )); + event.setTimestamp(System.currentTimeMillis()); + + reactionEmitter.send(event); + logger.info("[LOG] Réaction like publiée dans Kafka pour post: " + postId); + + } catch (Exception e) { + logger.error("[ERROR] Erreur publication Kafka", e); + } + + return post; + } +} +``` + +--- + +## 🎨 Frontend : Amélioration du Service WebSocket + +### realtime_notification_service_v2.dart + +```dart +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/status.dart' as status; + +class RealtimeNotificationServiceV2 extends ChangeNotifier { + RealtimeNotificationServiceV2(this.userId, this.authToken); + + final String userId; + final String authToken; + WebSocketChannel? _channel; + StreamSubscription? _subscription; + Timer? _heartbeatTimer; + Timer? _reconnectTimer; + + bool _isConnected = false; + bool get isConnected => _isConnected; + + int _reconnectAttempts = 0; + static const int _maxReconnectAttempts = 5; + static const Duration _heartbeatInterval = Duration(seconds: 30); + static const Duration _reconnectDelay = Duration(seconds: 5); + + // Streams pour différents types d'événements + final _friendRequestController = StreamController>.broadcast(); + final _systemNotificationController = StreamController>.broadcast(); + final _reactionController = StreamController>.broadcast(); + + Stream> get friendRequestStream => _friendRequestController.stream; + Stream> get systemNotificationStream => _systemNotificationController.stream; + Stream> get reactionStream => _reactionController.stream; + + String get _wsUrl { + final baseUrl = 'wss://api.afterwork.lions.dev'; // Production + return '$baseUrl/notifications/$userId'; + } + + Future connect() async { + if (_isConnected) return; + + try { + _channel = WebSocketChannel.connect( + Uri.parse(_wsUrl), + protocols: ['notifications-v2'], + headers: { + 'Authorization': 'Bearer $authToken', + }, + ); + + // Heartbeat pour maintenir la connexion + _heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) { + _channel?.sink.add(jsonEncode({'type': 'ping'})); + }); + + // Écouter les messages + _subscription = _channel!.stream.listen( + _handleMessage, + onError: _handleError, + onDone: _handleDisconnection, + cancelOnError: false, + ); + + _isConnected = true; + notifyListeners(); + + } catch (e) { + _isConnected = false; + notifyListeners(); + _scheduleReconnect(); + } + } + + void _handleMessage(dynamic message) { + try { + final data = jsonDecode(message as String) as Map; + final type = data['type'] as String; + + switch (type) { + case 'connected': + _reconnectAttempts = 0; // Reset sur reconnexion réussie + break; + case 'pong': + // Heartbeat réponse + break; + case 'friend_request': + case 'friend_request_accepted': + _friendRequestController.add(data); + break; + case 'event_reminder': + case 'system_notification': + _systemNotificationController.add(data); + break; + case 'reaction': + _reactionController.add(data); + break; + default: + // Type inconnu, ignorer ou logger + break; + } + } catch (e) { + // Erreur de parsing, ignorer + } + } + + void _handleError(dynamic error) { + _isConnected = false; + notifyListeners(); + _scheduleReconnect(); + } + + void _handleDisconnection() { + _isConnected = false; + notifyListeners(); + _scheduleReconnect(); + } + + void _scheduleReconnect() { + if (_reconnectAttempts >= _maxReconnectAttempts) { + // Arrêter les tentatives après max + return; + } + + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(_reconnectDelay * (_reconnectAttempts + 1), () { + _reconnectAttempts++; + connect(); + }); + } + + Future disconnect() async { + _heartbeatTimer?.cancel(); + _reconnectTimer?.cancel(); + await _subscription?.cancel(); + await _channel?.sink.close(status.normalClosure); + _isConnected = false; + notifyListeners(); + } + + @override + void dispose() { + disconnect(); + _friendRequestController.close(); + _systemNotificationController.close(); + _reactionController.close(); + super.dispose(); + } +} +``` + +--- + +## 🧪 Tests + +### Test du Bridge Kafka → WebSocket + +```java +package com.lions.dev.websocket; + +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.junit.jupiter.api.Test; + +@QuarkusTest +public class NotificationKafkaBridgeTest { + + @Inject + @Channel("notifications") + Emitter notificationEmitter; + + @Test + public void testNotificationFlow() { + // Publier un événement dans Kafka + NotificationEvent event = new NotificationEvent( + "user-123", + "friend_request", + Map.of("fromUserId", "user-456") + ); + + notificationEmitter.send(event); + + // Vérifier que le message arrive bien via WebSocket + // (nécessite un client WebSocket de test) + } +} +``` + +--- + +## 📊 Monitoring + +### Métriques Kafka à Surveiller + +1. **Lag Consumer** : Délai entre production et consommation +2. **Throughput** : Messages/seconde +3. **Error Rate** : Taux d'erreur +4. **Connection Count** : Nombre de connexions WebSocket actives + +### Endpoint de Santé + +```java +@Path("/health/realtime") +public class RealtimeHealthResource { + + @GET + public Response health() { + return Response.ok(Map.of( + "websocket_connections", NotificationWebSocketNext.getConnectionCount(), + "kafka_consumers", getKafkaConsumerCount(), + "status", "healthy" + )).build(); + } +} +``` + +--- + +## 🚀 Déploiement + +### Docker Compose (Kafka Local) + +```yaml +version: '3.8' +services: + zookeeper: + image: confluentinc/cp-zookeeper:latest + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + + kafka: + image: confluentinc/cp-kafka:latest + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 +``` + +### Production (Kubernetes) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: afterwork-backend +spec: + replicas: 3 # ✅ Scalabilité horizontale + template: + spec: + containers: + - name: quarkus + env: + - name: KAFKA_BOOTSTRAP_SERVERS + value: "kafka-service:9092" +``` + +--- + +## ✅ Checklist d'Implémentation + +### Phase 1 : Setup +- [ ] Ajouter dépendances dans `pom.xml` +- [ ] Configurer `application.properties` +- [ ] Tester Kafka avec Quarkus Dev Services +- [ ] Créer les DTOs d'événements + +### Phase 2 : Migration WebSocket +- [ ] Créer `NotificationWebSocketNext` +- [ ] Créer `ChatWebSocketNext` +- [ ] Tester avec le frontend existant +- [ ] Comparer performances (avant/après) + +### Phase 3 : Intégration Kafka +- [ ] Créer `NotificationKafkaBridge` +- [ ] Créer `ChatKafkaBridge` +- [ ] Modifier `FriendshipService` pour publier dans Kafka +- [ ] Modifier `MessageService` pour publier dans Kafka +- [ ] Modifier `SocialPostService` pour les réactions + +### Phase 4 : Frontend +- [ ] Améliorer `RealtimeNotificationService` avec heartbeat +- [ ] Améliorer `ChatWebSocketService` avec reconnect +- [ ] Tester la reconnexion automatique +- [ ] Tester multi-device + +### Phase 5 : Tests & Monitoring +- [ ] Tests unitaires des bridges +- [ ] Tests d'intégration end-to-end +- [ ] Configurer monitoring Kafka +- [ ] Configurer alertes + +--- + +## 📚 Ressources Complémentaires + +- [Quarkus WebSockets Next Tutorial](https://quarkus.io/guides/websockets-next-tutorial) +- [Quarkus Kafka Guide](https://quarkus.io/guides/kafka) +- [Reactive Messaging HTTP Extension](https://docs.quarkiverse.io/quarkus-reactive-messaging-http/dev/reactive-messaging-websocket.html) +- [Kafka Best Practices](https://kafka.apache.org/documentation/#bestPractices) diff --git a/backend_log.txt b/backend_log.txt index fbaef68..c7e4095 100644 --- a/backend_log.txt +++ b/backend_log.txt @@ -8860,7 +8860,7 @@ Removed unused interceptor INTERCEPTOR bean [bindings=[@MethodValidated], target {"timestamp":"2026-01-05T22:35:44.20583Z","sequence":8419,"loggerClassName":"org.jboss.logging.DelegatingBasicLogger","loggerName":"org.hibernate.event.internal.AbstractFlushingEventListener","level":"DEBUG","message":"Flushed: 1 insertions, 0 updates, 0 deletions to 1 objects","threadName":"executor-thread-1","threadId":244,"mdc":{},"ndc":"","hostName":"gbanedahoud","processName":"C:\\Program Files\\Java\\jdk-17\\bin\\java.exe","processId":28280} {"timestamp":"2026-01-05T22:35:44.20583Z","sequence":8421,"loggerClassName":"org.jboss.logging.DelegatingBasicLogger","loggerName":"org.hibernate.event.internal.AbstractFlushingEventListener","level":"DEBUG","message":"Flushed: 1 (re)creations, 0 updates, 0 removals to 1 collections","threadName":"executor-thread-1","threadId":244,"mdc":{},"ndc":"","hostName":"gbanedahoud","processName":"C:\\Program Files\\Java\\jdk-17\\bin\\java.exe","processId":28280} {"timestamp":"2026-01-05T22:35:44.20828Z","sequence":8423,"loggerClassName":"org.jboss.logging.DelegatingBasicLogger","loggerName":"org.hibernate.internal.util.EntityPrinter","level":"DEBUG","message":"Listing entities:","threadName":"executor-thread-1","threadId":244,"mdc":{},"ndc":"","hostName":"gbanedahoud","processName":"C:\\Program Files\\Java\\jdk-17\\bin\\java.exe","processId":28280} -{"timestamp":"2026-01-05T22:35:44.209296Z","sequence":8424,"loggerClassName":"org.jboss.logging.DelegatingBasicLogger","loggerName":"org.hibernate.internal.util.EntityPrinter","level":"DEBUG","message":"com.lions.dev.entity.users.Users{createdAt=2026-01-05T22:35:44.135208300, motDePasse=$2a$12$bOn5irq0ntL5gZ0MgW3LdeeSpQv6fqKioxRcH/EUiYpw8oVXch9g2, preferredCategory=null, role=USER, favoriteEvents=[], id=9c46a967-dd49-494c-a9b1-cf5bb601f1d0, nom=Dady, profileImageUrl=https://via.placeholder.com/150, email=admin@afterwork.lions.dev, prenoms=One, updatedAt=2026-01-05T22:35:44.135208300}","threadName":"executor-thread-1","threadId":244,"mdc":{},"ndc":"","hostName":"gbanedahoud","processName":"C:\\Program Files\\Java\\jdk-17\\bin\\java.exe","processId":28280} +{"timestamp":"2026-01-05T22:35:44.209296Z","sequence":8424,"loggerClassName":"org.jboss.logging.DelegatingBasicLogger","loggerName":"org.hibernate.internal.util.EntityPrinter","level":"DEBUG","message":"com.lions.dev.entity.users.Users{createdAt=2026-01-05T22:35:44.135208300, motDePasse=$2a$12$bOn5irq0ntL5gZ0MgW3LdeeSpQv6fqKioxRcH/EUiYpw8oVXch9g2, preferredCategory=null, role=USER, favoriteEvents=[], id=9c46a967-dd49-494c-a9b1-cf5bb601f1d0, nom=Dady, profileImageUrl=https://placehold.co/150x150.png, email=admin@afterwork.lions.dev, prenoms=One, updatedAt=2026-01-05T22:35:44.135208300}","threadName":"executor-thread-1","threadId":244,"mdc":{},"ndc":"","hostName":"gbanedahoud","processName":"C:\\Program Files\\Java\\jdk-17\\bin\\java.exe","processId":28280} {"timestamp":"2026-01-05T22:35:44.2325806Z","sequence":8425,"loggerClassName":"org.jboss.logging.Logger","loggerName":"org.hibernate.SQL","level":"DEBUG","message":"\r\n \u001b[34minsert\u001b[0m \r\n \u001b[34minto\u001b[0m\r\n users\r\n (created_at, email, mot_de_passe, nom, preferred_category, prenoms, profile_image_url, role, updated_at, id) \r\n \u001b[34mvalues\u001b[0m\r\n (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)","threadName":"executor-thread-1","threadId":244,"mdc":{},"ndc":"","hostName":"gbanedahoud","processName":"C:\\Program Files\\Java\\jdk-17\\bin\\java.exe","processId":28280} [Hibernate] insert diff --git a/pom.xml b/pom.xml index 7f8c016..c16e416 100644 --- a/pom.xml +++ b/pom.xml @@ -76,9 +76,20 @@ io.quarkus quarkus-arc + io.quarkus - quarkus-websockets + quarkus-websockets-next + + + + io.quarkus + quarkus-messaging-kafka + + + + io.quarkus + quarkus-jsonb org.projectlombok diff --git a/src/main/java/com/lions/dev/dto/events/ChatMessageEvent.java b/src/main/java/com/lions/dev/dto/events/ChatMessageEvent.java new file mode 100644 index 0000000..f7bedae --- /dev/null +++ b/src/main/java/com/lions/dev/dto/events/ChatMessageEvent.java @@ -0,0 +1,75 @@ +package com.lions.dev.dto.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.util.UUID; + +/** + * Événement de message chat publié dans Kafka. + * + * Utilisé pour garantir la livraison des messages même si le destinataire + * est temporairement déconnecté. Le message est persisté dans Kafka et + * délivré dès la reconnexion. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ChatMessageEvent { + + /** + * ID de la conversation (utilisé comme clé Kafka pour garantir l'ordre). + */ + private String conversationId; + + /** + * ID de l'expéditeur. + */ + private String senderId; + + /** + * ID du destinataire. + */ + private String recipientId; + + /** + * Contenu du message. + */ + private String content; + + /** + * ID unique du message. + */ + private String messageId; + + /** + * Timestamp de création. + */ + private Long timestamp; + + /** + * Type d'événement (message, typing, read_receipt, delivery_confirmation). + */ + private String eventType; + + /** + * Données additionnelles (pour typing indicators, read receipts, etc.). + */ + private java.util.Map metadata; + + /** + * Constructeur pour un message standard. + */ + public ChatMessageEvent(String conversationId, String senderId, String recipientId, + String content, String messageId) { + this.conversationId = conversationId; + this.senderId = senderId; + this.recipientId = recipientId; + this.content = content; + this.messageId = messageId; + this.eventType = "message"; + this.timestamp = System.currentTimeMillis(); + } +} diff --git a/src/main/java/com/lions/dev/dto/events/NotificationEvent.java b/src/main/java/com/lions/dev/dto/events/NotificationEvent.java new file mode 100644 index 0000000..6664158 --- /dev/null +++ b/src/main/java/com/lions/dev/dto/events/NotificationEvent.java @@ -0,0 +1,52 @@ +package com.lions.dev.dto.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.util.Map; +import java.util.UUID; + +/** + * Événement de notification publié dans Kafka. + * + * Utilisé pour découpler les services métier des WebSockets. + * Les services publient dans Kafka, et un bridge consomme depuis Kafka + * pour envoyer via WebSocket aux clients connectés. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class NotificationEvent { + + /** + * ID de l'utilisateur destinataire (utilisé comme clé Kafka pour routing). + */ + private String userId; + + /** + * Type de notification (friend_request, friend_request_accepted, event_reminder, etc.). + */ + private String type; + + /** + * Données de la notification (contenu spécifique au type). + */ + private Map data; + + /** + * Timestamp de création de l'événement. + */ + private Long timestamp; + + /** + * Constructeur simplifié (timestamp auto-généré). + */ + public NotificationEvent(String userId, String type, Map data) { + this.userId = userId; + this.type = type; + this.data = data; + this.timestamp = System.currentTimeMillis(); + } +} diff --git a/src/main/java/com/lions/dev/dto/events/PresenceEvent.java b/src/main/java/com/lions/dev/dto/events/PresenceEvent.java new file mode 100644 index 0000000..1f4d856 --- /dev/null +++ b/src/main/java/com/lions/dev/dto/events/PresenceEvent.java @@ -0,0 +1,48 @@ +package com.lions.dev.dto.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Événement de présence (online/offline) publié dans Kafka. + * + * Utilisé pour notifier les amis quand un utilisateur se connecte/déconnecte. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PresenceEvent { + + /** + * ID de l'utilisateur concerné. + */ + private String userId; + + /** + * Statut (online, offline). + */ + private String status; + + /** + * Timestamp de dernière activité. + */ + private Long lastSeen; + + /** + * Timestamp de l'événement. + */ + private Long timestamp; + + /** + * Constructeur simplifié. + */ + public PresenceEvent(String userId, String status, Long lastSeen) { + this.userId = userId; + this.status = status; + this.lastSeen = lastSeen; + this.timestamp = System.currentTimeMillis(); + } +} diff --git a/src/main/java/com/lions/dev/dto/events/ReactionEvent.java b/src/main/java/com/lions/dev/dto/events/ReactionEvent.java new file mode 100644 index 0000000..473183c --- /dev/null +++ b/src/main/java/com/lions/dev/dto/events/ReactionEvent.java @@ -0,0 +1,63 @@ +package com.lions.dev.dto.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.util.Map; + +/** + * Événement de réaction (like, comment, share) publié dans Kafka. + * + * Utilisé pour notifier en temps réel les réactions sur les posts, + * stories et événements. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ReactionEvent { + + /** + * ID du post/story/event concerné (utilisé comme clé Kafka). + */ + private String targetId; + + /** + * Type de cible (post, story, event). + */ + private String targetType; + + /** + * ID de l'utilisateur qui réagit. + */ + private String userId; + + /** + * Type de réaction (like, comment, share). + */ + private String reactionType; + + /** + * Données additionnelles (contenu du commentaire, etc.). + */ + private Map data; + + /** + * Timestamp de création. + */ + private Long timestamp; + + /** + * Constructeur simplifié. + */ + public ReactionEvent(String targetId, String targetType, String userId, + String reactionType, Map data) { + this.targetId = targetId; + this.targetType = targetType; + this.userId = userId; + this.reactionType = reactionType; + this.data = data; + this.timestamp = System.currentTimeMillis(); + } +} diff --git a/src/main/java/com/lions/dev/dto/request/establishment/EstablishmentCreateRequestDTO.java b/src/main/java/com/lions/dev/dto/request/establishment/EstablishmentCreateRequestDTO.java index a38abdd..8614024 100644 --- a/src/main/java/com/lions/dev/dto/request/establishment/EstablishmentCreateRequestDTO.java +++ b/src/main/java/com/lions/dev/dto/request/establishment/EstablishmentCreateRequestDTO.java @@ -8,6 +8,10 @@ import java.util.UUID; /** * DTO pour la création d'un établissement. + * + * Version 2.0 - Architecture refactorée avec nommage standardisé. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * * Seuls les responsables d'établissement peuvent créer des établissements. */ @Getter @@ -32,18 +36,50 @@ public class EstablishmentCreateRequestDTO { private String description; private String phoneNumber; - private String email; private String website; - private String imageUrl; - private Double rating; private String priceRange; - private Integer capacity; - private String amenities; - private String openingHours; + private String verificationStatus = "PENDING"; // v2.0 - Par défaut PENDING private Double latitude; private Double longitude; @NotNull(message = "L'identifiant du responsable est obligatoire.") private UUID managerId; + + // Champs dépréciés (v1.0) - conservés pour compatibilité mais ignorés + /** + * @deprecated Supprimé en v2.0 (utiliser manager.email à la place). + */ + @Deprecated + private String email; + + /** + * @deprecated Supprimé en v2.0 (utiliser establishment_media à la place). + */ + @Deprecated + private String imageUrl; + + /** + * @deprecated Utiliser averageRating calculé depuis reviews à la place. + */ + @Deprecated + private Double rating; + + /** + * @deprecated Supprimé en v2.0. + */ + @Deprecated + private Integer capacity; + + /** + * @deprecated Supprimé en v2.0 (utiliser establishment_amenities à la place). + */ + @Deprecated + private String amenities; + + /** + * @deprecated Supprimé en v2.0 (utiliser business_hours à la place). + */ + @Deprecated + private String openingHours; } diff --git a/src/main/java/com/lions/dev/dto/request/establishment/EstablishmentMediaRequestDTO.java b/src/main/java/com/lions/dev/dto/request/establishment/EstablishmentMediaRequestDTO.java new file mode 100644 index 0000000..80f1d9e --- /dev/null +++ b/src/main/java/com/lions/dev/dto/request/establishment/EstablishmentMediaRequestDTO.java @@ -0,0 +1,31 @@ +package com.lions.dev.dto.request.establishment; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +/** + * DTO pour la requête d'upload d'un média d'établissement. + * + * Version 2.0 - Architecture refactorée avec nommage standardisé. + */ +@Getter +@Setter +public class EstablishmentMediaRequestDTO { + + @NotBlank(message = "L'URL du média est obligatoire") + private String mediaUrl; + + @NotBlank(message = "Le type de média est obligatoire") + private String mediaType; // PHOTO ou VIDEO + + private String name; // Nom du fichier (fileName) - optionnel, peut être extrait de mediaUrl si non fourni + + private String thumbnailUrl; // Optionnel, pour les vidéos + + private Integer displayOrder = 0; // Ordre d'affichage (par défaut 0) + + private String uploadedByUserId; // ID de l'utilisateur qui upload (optionnel, peut être extrait du contexte) +} + diff --git a/src/main/java/com/lions/dev/dto/request/events/EventCreateRequestDTO.java b/src/main/java/com/lions/dev/dto/request/events/EventCreateRequestDTO.java index b4d494d..d6b64a7 100644 --- a/src/main/java/com/lions/dev/dto/request/events/EventCreateRequestDTO.java +++ b/src/main/java/com/lions/dev/dto/request/events/EventCreateRequestDTO.java @@ -9,6 +9,10 @@ import java.time.LocalDateTime; /** * DTO pour la création d'un événement. + * + * Version 2.0 - Architecture refactorée avec nommage standardisé. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * * Ce DTO est utilisé dans les requêtes de création d'événements, envoyant les informations * nécessaires comme le titre, les dates, la description, le créateur, et d'autres attributs. */ @@ -28,7 +32,7 @@ public class EventCreateRequestDTO { @NotNull(message = "La date de fin est obligatoire.") private LocalDateTime endDate; // Date de fin de l'événement - private String location; // Lieu de l'événement + private UUID establishmentId; // v2.0 - ID de l'établissement où se déroule l'événement private String category; // Catégorie de l'événement private String link; // Lien d'information supplémentaire private String imageUrl; // URL de l'image associée à l'événement @@ -37,6 +41,8 @@ public class EventCreateRequestDTO { private String tags; // Tags/mots-clés associés à l'événement (séparés par des virgules) private String organizer; // Nom de l'organisateur de l'événement private Integer participationFee; // Frais de participation en centimes + private Boolean isPrivate = false; // v2.0 - Indique si l'événement est privé + private Boolean waitlistEnabled = false; // v2.0 - Indique si la liste d'attente est activée private String privacyRules; // Règles de confidentialité de l'événement private String transportInfo; // Informations sur les transports disponibles private String accommodationInfo; // Informations sur l'hébergement @@ -47,7 +53,24 @@ public class EventCreateRequestDTO { @NotNull(message = "L'identifiant du créateur est obligatoire.") private UUID creatorId; // Identifiant du créateur de l'événement + // Champ déprécié (v1.0) - conservé pour compatibilité mais ignoré + /** + * @deprecated Supprimé en v2.0 (utiliser establishmentId à la place). + */ + @Deprecated + private String location; + public EventCreateRequestDTO() { System.out.println("[LOG] DTO de requête de création d'événement initialisé."); } + + /** + * Méthode pour obtenir le lieu (compatibilité v1.0 et v2.0). + * Retourne null car location est déprécié en v2.0. + * + * @return Le lieu (null en v2.0, utiliser establishmentId à la place). + */ + public String getLocation() { + return location; // Retourne null en v2.0 + } } diff --git a/src/main/java/com/lions/dev/dto/request/events/EventReadManyByIdRequestDTO.java b/src/main/java/com/lions/dev/dto/request/events/EventReadManyByIdRequestDTO.java index a77220d..17785e9 100644 --- a/src/main/java/com/lions/dev/dto/request/events/EventReadManyByIdRequestDTO.java +++ b/src/main/java/com/lions/dev/dto/request/events/EventReadManyByIdRequestDTO.java @@ -16,7 +16,10 @@ import lombok.Setter; @AllArgsConstructor public class EventReadManyByIdRequestDTO { - private UUID userId; // Identifiant de l'utilisateur pour lequel on souhaite obtenir les événements + private UUID id; // v2.0 - Identifiant de l'utilisateur pour lequel on souhaite obtenir les événements + + private Integer page = 0; // v2.0 - Numéro de la page (0-indexé) + private Integer size = 10; // v2.0 - Taille de la page // Ajoutez ici d'autres critères de filtre si besoin, comme une plage de dates, un statut, etc. } diff --git a/src/main/java/com/lions/dev/dto/request/social/SocialPostCreateRequestDTO.java b/src/main/java/com/lions/dev/dto/request/social/SocialPostCreateRequestDTO.java index bc7bb72..1fec60e 100644 --- a/src/main/java/com/lions/dev/dto/request/social/SocialPostCreateRequestDTO.java +++ b/src/main/java/com/lions/dev/dto/request/social/SocialPostCreateRequestDTO.java @@ -23,7 +23,7 @@ public class SocialPostCreateRequestDTO { private String content; // Le contenu textuel du post @NotNull(message = "L'identifiant de l'utilisateur est obligatoire.") - private UUID userId; // L'ID de l'utilisateur créateur + private UUID creatorId; // v2.0 - L'ID de l'utilisateur créateur @Size(max = 500, message = "L'URL de l'image ne peut pas dépasser 500 caractères.") private String imageUrl; // URL de l'image (optionnel) diff --git a/src/main/java/com/lions/dev/dto/request/story/StoryCreateRequestDTO.java b/src/main/java/com/lions/dev/dto/request/story/StoryCreateRequestDTO.java index 0463be5..0898689 100644 --- a/src/main/java/com/lions/dev/dto/request/story/StoryCreateRequestDTO.java +++ b/src/main/java/com/lions/dev/dto/request/story/StoryCreateRequestDTO.java @@ -19,7 +19,7 @@ import lombok.Setter; public class StoryCreateRequestDTO { @NotNull(message = "L'identifiant de l'utilisateur est obligatoire.") - private UUID userId; // L'ID de l'utilisateur créateur + private UUID creatorId; // v2.0 - L'ID de l'utilisateur créateur @NotNull(message = "Le type de média est obligatoire.") private MediaType mediaType; // Type de média (IMAGE ou VIDEO) diff --git a/src/main/java/com/lions/dev/dto/request/users/UserAuthenticateRequestDTO.java b/src/main/java/com/lions/dev/dto/request/users/UserAuthenticateRequestDTO.java index 92a0895..977d18a 100644 --- a/src/main/java/com/lions/dev/dto/request/users/UserAuthenticateRequestDTO.java +++ b/src/main/java/com/lions/dev/dto/request/users/UserAuthenticateRequestDTO.java @@ -9,6 +9,10 @@ import org.slf4j.LoggerFactory; /** * DTO pour la requête d'authentification de l'utilisateur. + * + * Version 2.0 - Architecture refactorée avec nommage standardisé. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * * Utilisé pour encapsuler les informations nécessaires lors de l'authentification d'un utilisateur. */ @Getter @@ -25,9 +29,16 @@ public class UserAuthenticateRequestDTO { private String email; /** - * Mot de passe de l'utilisateur en texte clair. - * Ce champ sera haché avant d'être utilisé pour l'authentification. + * Mot de passe hashé de l'utilisateur (v2.0). + * Format standardisé pour l'authentification. */ + private String password_hash; // v2.0 + + /** + * Mot de passe de l'utilisateur en texte clair (v1.0 - déprécié). + * @deprecated Utiliser {@link #password_hash} à la place. + */ + @Deprecated private String motDePasse; /** @@ -37,6 +48,15 @@ public class UserAuthenticateRequestDTO { logger.info("UserAuthenticateRequestDTO - DTO pour l'authentification initialisé"); } + /** + * Méthode pour obtenir le mot de passe (compatibilité v1.0 et v2.0). + * + * @return Le mot de passe (password_hash ou motDePasse). + */ + public String getPassword() { + return password_hash != null ? password_hash : motDePasse; + } + // Méthode personnalisée pour loguer les détails de la requête public void logRequestDetails() { logger.info("Authentification demandée pour l'email: {}", email); diff --git a/src/main/java/com/lions/dev/dto/request/users/UserCreateRequestDTO.java b/src/main/java/com/lions/dev/dto/request/users/UserCreateRequestDTO.java index 4e47ec9..40c54d8 100644 --- a/src/main/java/com/lions/dev/dto/request/users/UserCreateRequestDTO.java +++ b/src/main/java/com/lions/dev/dto/request/users/UserCreateRequestDTO.java @@ -7,21 +7,25 @@ import lombok.Getter; import lombok.Setter; /** - * DTO pour la création et l'authentification d'un utilisateur. - * Ce DTO est utilisé dans les requêtes pour créer ou authentifier un utilisateur, - * contenant les informations comme le nom, les prénoms, l'email, et le mot de passe. + * DTO pour la création d'un utilisateur. + * + * Version 2.0 - Architecture refactorée avec nommage standardisé. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * + * Ce DTO est utilisé dans les requêtes pour créer un utilisateur, + * contenant les informations comme le prénom, le nom, l'email, et le mot de passe. */ @Getter @Setter public class UserCreateRequestDTO { - @NotNull(message = "Le nom est obligatoire.") - @Size(min = 1, max = 100, message = "Le nom doit comporter entre 1 et 100 caractères.") - private String nom; + @NotNull(message = "Le prénom est obligatoire.") + @Size(min = 1, max = 100, message = "Le prénom doit comporter entre 1 et 100 caractères.") + private String firstName; // v2.0 - @NotNull(message = "Les prénoms sont obligatoires.") - @Size(min = 1, max = 100, message = "Les prénoms doivent comporter entre 1 et 100 caractères.") - private String prenoms; + @NotNull(message = "Le nom de famille est obligatoire.") + @Size(min = 1, max = 100, message = "Le nom de famille doit comporter entre 1 et 100 caractères.") + private String lastName; // v2.0 @NotNull(message = "L'adresse email est obligatoire.") @Email(message = "Veuillez fournir une adresse email valide.") @@ -29,11 +33,86 @@ public class UserCreateRequestDTO { @NotNull(message = "Le mot de passe est obligatoire.") @Size(min = 6, message = "Le mot de passe doit comporter au moins 6 caractères.") - private String motDePasse; + private String password; // v2.0 - sera hashé en passwordHash private String profileImageUrl; + private String bio; // v2.0 + + private Integer loyaltyPoints = 0; // v2.0 + + /** + * Préférences utilisateur (v2.0). + * + * Structure attendue: + * { + * "preferredCategory": "RESTAURANT" | "BAR" | "CLUB" | "CAFE" | "EVENT" | null, + * "notifications": { + * "email": boolean, + * "push": boolean + * }, + * "language": "fr" | "en" | "es" + * } + * + * Exemple: + * { + * "preferredCategory": "RESTAURANT", + * "notifications": { + * "email": true, + * "push": true + * }, + * "language": "fr" + * } + */ + private java.util.Map preferences; // v2.0 + // Ajout du rôle avec validation @NotNull(message = "Le rôle est obligatoire.") - private String role; // Rôle de l'utilisateur (par exemple : ADMIN, USER, etc.) + private String role; // Rôle de l'utilisateur (par exemple : ADMIN, USER, MANAGER, etc.) + + // Champs de compatibilité v1.0 (dépréciés mais supportés pour migration progressive) + /** + * @deprecated Utiliser {@link #firstName} à la place. + */ + @Deprecated + private String prenoms; + + /** + * @deprecated Utiliser {@link #lastName} à la place. + */ + @Deprecated + private String nom; + + /** + * @deprecated Utiliser {@link #password} à la place. + */ + @Deprecated + private String motDePasse; + + /** + * Méthode pour obtenir le prénom (compatibilité v1.0 et v2.0). + * + * @return Le prénom (firstName ou prenoms). + */ + public String getFirstName() { + return firstName != null ? firstName : prenoms; + } + + /** + * Méthode pour obtenir le nom de famille (compatibilité v1.0 et v2.0). + * + * @return Le nom de famille (lastName ou nom). + */ + public String getLastName() { + return lastName != null ? lastName : nom; + } + + /** + * Méthode pour obtenir le mot de passe (compatibilité v1.0 et v2.0). + * + * @return Le mot de passe (password ou motDePasse). + */ + public String getPassword() { + return password != null ? password : motDePasse; + } } diff --git a/src/main/java/com/lions/dev/dto/response/chat/ConversationResponseDTO.java b/src/main/java/com/lions/dev/dto/response/chat/ConversationResponseDTO.java index 6f76d65..2da84ee 100644 --- a/src/main/java/com/lions/dev/dto/response/chat/ConversationResponseDTO.java +++ b/src/main/java/com/lions/dev/dto/response/chat/ConversationResponseDTO.java @@ -40,8 +40,9 @@ public class ConversationResponseDTO { Users otherUser = conversation.getOtherUser(currentUser); if (otherUser != null) { this.participantId = otherUser.getId(); - this.participantFirstName = otherUser.getPrenoms(); - this.participantLastName = otherUser.getNom(); + // v2.0 - Utiliser les nouveaux noms de champs + this.participantFirstName = otherUser.getFirstName(); + this.participantLastName = otherUser.getLastName(); this.participantProfileImageUrl = otherUser.getProfileImageUrl(); } diff --git a/src/main/java/com/lions/dev/dto/response/chat/MessageResponseDTO.java b/src/main/java/com/lions/dev/dto/response/chat/MessageResponseDTO.java index 8c7a05a..8d63caa 100644 --- a/src/main/java/com/lions/dev/dto/response/chat/MessageResponseDTO.java +++ b/src/main/java/com/lions/dev/dto/response/chat/MessageResponseDTO.java @@ -30,14 +30,15 @@ public class MessageResponseDTO { private LocalDateTime timestamp; /** - * Constructeur depuis une entité Message. + * Constructeur depuis une entité Message (v2.0). */ public MessageResponseDTO(Message message) { this.id = message.getId(); this.conversationId = message.getConversation().getId(); this.senderId = message.getSender().getId(); - this.senderFirstName = message.getSender().getPrenoms(); - this.senderLastName = message.getSender().getNom(); + // v2.0 - Utiliser les nouveaux noms de champs + this.senderFirstName = message.getSender().getFirstName(); + this.senderLastName = message.getSender().getLastName(); this.senderProfileImageUrl = message.getSender().getProfileImageUrl(); this.content = message.getContent(); this.attachmentType = message.getMessageType(); diff --git a/src/main/java/com/lions/dev/dto/response/comments/CommentResponseDTO.java b/src/main/java/com/lions/dev/dto/response/comments/CommentResponseDTO.java index 3ef04d5..d105e8c 100644 --- a/src/main/java/com/lions/dev/dto/response/comments/CommentResponseDTO.java +++ b/src/main/java/com/lions/dev/dto/response/comments/CommentResponseDTO.java @@ -64,8 +64,9 @@ public class CommentResponseDTO { this.id = comment.getId(); // Identifiant unique du commentaire this.texte = comment.getText(); // Texte du commentaire this.userId = comment.getUser().getId(); // Identifiant de l'utilisateur (auteur du commentaire) - this.userNom = comment.getUser().getNom(); // Nom de l'utilisateur - this.userPrenoms = comment.getUser().getPrenoms(); // Prénom de l'utilisateur + // v2.0 - Utiliser les nouveaux noms de champs + this.userNom = comment.getUser().getLastName(); // Nom de famille de l'utilisateur (v2.0) + this.userPrenoms = comment.getUser().getFirstName(); // Prénom de l'utilisateur (v2.0) } } } diff --git a/src/main/java/com/lions/dev/dto/response/establishment/EstablishmentMediaResponseDTO.java b/src/main/java/com/lions/dev/dto/response/establishment/EstablishmentMediaResponseDTO.java index 1826bdc..b06ca68 100644 --- a/src/main/java/com/lions/dev/dto/response/establishment/EstablishmentMediaResponseDTO.java +++ b/src/main/java/com/lions/dev/dto/response/establishment/EstablishmentMediaResponseDTO.java @@ -36,10 +36,11 @@ public class EstablishmentMediaResponseDTO { this.displayOrder = media.getDisplayOrder(); if (media.getUploadedBy() != null) { + // v2.0 - Utiliser les nouveaux noms de champs this.uploadedBy = new MediaUploaderDTO( media.getUploadedBy().getId().toString(), - media.getUploadedBy().getPrenoms(), - media.getUploadedBy().getNom(), + media.getUploadedBy().getFirstName(), + media.getUploadedBy().getLastName(), media.getUploadedBy().getProfileImageUrl() ); } diff --git a/src/main/java/com/lions/dev/dto/response/establishment/EstablishmentResponseDTO.java b/src/main/java/com/lions/dev/dto/response/establishment/EstablishmentResponseDTO.java index 65b9dad..4179a31 100644 --- a/src/main/java/com/lions/dev/dto/response/establishment/EstablishmentResponseDTO.java +++ b/src/main/java/com/lions/dev/dto/response/establishment/EstablishmentResponseDTO.java @@ -1,12 +1,17 @@ package com.lions.dev.dto.response.establishment; import com.lions.dev.entity.establishment.Establishment; +import com.lions.dev.entity.establishment.MediaType; import lombok.Getter; import java.time.LocalDateTime; import java.util.UUID; /** * DTO pour renvoyer les informations d'un établissement. + * + * Version 2.0 - Architecture refactorée avec nommage standardisé. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * * Ce DTO est utilisé pour structurer les données retournées dans les réponses * après les opérations sur les établissements (création, récupération, mise à jour). */ @@ -21,27 +26,66 @@ public class EstablishmentResponseDTO { private String postalCode; private String description; private String phoneNumber; - private String email; private String website; - private String imageUrl; - private Double rating; // Déprécié, utiliser averageRating private Double averageRating; // Note moyenne calculée - private Integer totalRatingsCount; // Nombre total de notes + private Integer totalReviewsCount; // v2.0 - renommé depuis totalRatingsCount private String priceRange; - private Integer capacity; - private String amenities; - private String openingHours; + private String verificationStatus; // v2.0 - PENDING, VERIFIED, REJECTED private Double latitude; private Double longitude; private String managerId; private String managerEmail; - private String managerFirstName; - private String managerLastName; + private String managerFirstName; // v2.0 + private String managerLastName; // v2.0 + private String mainImageUrl; // v2.0 - URL de l'image principale (premier média avec displayOrder 0) private LocalDateTime createdAt; private LocalDateTime updatedAt; + // Champs dépréciés (v1.0) - conservés pour compatibilité /** - * Constructeur qui transforme une entité Establishment en DTO. + * @deprecated Utiliser {@link #averageRating} à la place. + */ + @Deprecated + private Double rating; + + /** + * @deprecated Supprimé en v2.0 (utiliser manager.email à la place). + */ + @Deprecated + private String email; + + /** + * @deprecated Supprimé en v2.0 (utiliser establishment_media à la place). + */ + @Deprecated + private String imageUrl; + + /** + * @deprecated Supprimé en v2.0. + */ + @Deprecated + private Integer capacity; + + /** + * @deprecated Supprimé en v2.0 (utiliser establishment_amenities à la place). + */ + @Deprecated + private String amenities; + + /** + * @deprecated Supprimé en v2.0 (utiliser business_hours à la place). + */ + @Deprecated + private String openingHours; + + /** + * @deprecated Utiliser {@link #totalReviewsCount} à la place. + */ + @Deprecated + private Integer totalRatingsCount; + + /** + * Constructeur qui transforme une entité Establishment en DTO (v2.0). * * @param establishment L'établissement à convertir en DTO. */ @@ -54,28 +98,46 @@ public class EstablishmentResponseDTO { this.postalCode = establishment.getPostalCode(); this.description = establishment.getDescription(); this.phoneNumber = establishment.getPhoneNumber(); - this.email = establishment.getEmail(); this.website = establishment.getWebsite(); - this.imageUrl = establishment.getImageUrl(); - this.rating = establishment.getRating(); // Déprécié this.averageRating = establishment.getAverageRating(); - this.totalRatingsCount = establishment.getTotalRatingsCount(); + this.totalReviewsCount = establishment.getTotalReviewsCount(); // v2.0 this.priceRange = establishment.getPriceRange(); - this.capacity = establishment.getCapacity(); - this.amenities = establishment.getAmenities(); - this.openingHours = establishment.getOpeningHours(); + this.verificationStatus = establishment.getVerificationStatus(); // v2.0 this.latitude = establishment.getLatitude(); this.longitude = establishment.getLongitude(); if (establishment.getManager() != null) { this.managerId = establishment.getManager().getId().toString(); this.managerEmail = establishment.getManager().getEmail(); - this.managerFirstName = establishment.getManager().getPrenoms(); - this.managerLastName = establishment.getManager().getNom(); + this.managerFirstName = establishment.getManager().getFirstName(); // v2.0 + this.managerLastName = establishment.getManager().getLastName(); // v2.0 + } + + // Récupérer l'image principale (premier média photo avec displayOrder 0 ou le premier disponible) + if (establishment.getMedias() != null && !establishment.getMedias().isEmpty()) { + this.mainImageUrl = establishment.getMedias().stream() + .filter(media -> media.getMediaType() == MediaType.PHOTO) + .sorted((a, b) -> Integer.compare( + a.getDisplayOrder() != null ? a.getDisplayOrder() : Integer.MAX_VALUE, + b.getDisplayOrder() != null ? b.getDisplayOrder() : Integer.MAX_VALUE)) + .map(media -> media.getMediaUrl()) + .findFirst() + .orElse(null); + } else { + this.mainImageUrl = null; } this.createdAt = establishment.getCreatedAt(); this.updatedAt = establishment.getUpdatedAt(); + + // Compatibilité v1.0 - valeurs null pour les champs dépréciés + this.rating = null; + this.email = null; + this.imageUrl = null; + this.capacity = null; + this.amenities = null; + this.openingHours = null; + this.totalRatingsCount = this.totalReviewsCount; // Alias pour compatibilité } } diff --git a/src/main/java/com/lions/dev/dto/response/events/EventReadManyByIdResponseDTO.java b/src/main/java/com/lions/dev/dto/response/events/EventReadManyByIdResponseDTO.java index 1795430..3c30669 100644 --- a/src/main/java/com/lions/dev/dto/response/events/EventReadManyByIdResponseDTO.java +++ b/src/main/java/com/lions/dev/dto/response/events/EventReadManyByIdResponseDTO.java @@ -27,7 +27,7 @@ public class EventReadManyByIdResponseDTO { private String profileImageUrl; // URL de l'image de profil de l'utilisateur qui a criané l'événement /** - * Constructeur qui transforme une entité Events en DTO de réponse. + * Constructeur qui transforme une entité Events en DTO de réponse (v2.0). * * @param event L'événement à convertir en DTO. */ @@ -37,14 +37,16 @@ public class EventReadManyByIdResponseDTO { this.description = event.getDescription(); this.startDate = event.getStartDate(); this.endDate = event.getEndDate(); + // v2.0 - Utiliser getLocation() qui retourne l'adresse de l'établissement this.location = event.getLocation(); this.category = event.getCategory(); this.link = event.getLink(); this.imageUrl = event.getImageUrl(); this.status = event.getStatus(); this.creatorEmail = event.getCreator().getEmail(); - this.creatorFirstName = event.getCreator().getPrenoms(); - this.creatorLastName = event.getCreator().getNom(); + // v2.0 - Utiliser les nouveaux noms de champs + this.creatorFirstName = event.getCreator().getFirstName(); + this.creatorLastName = event.getCreator().getLastName(); this.profileImageUrl = event.getCreator().getProfileImageUrl(); } } diff --git a/src/main/java/com/lions/dev/dto/response/friends/FriendshipReadStatusResponseDTO.java b/src/main/java/com/lions/dev/dto/response/friends/FriendshipReadStatusResponseDTO.java index 5f9fb63..b8a1dbd 100644 --- a/src/main/java/com/lions/dev/dto/response/friends/FriendshipReadStatusResponseDTO.java +++ b/src/main/java/com/lions/dev/dto/response/friends/FriendshipReadStatusResponseDTO.java @@ -36,11 +36,12 @@ public class FriendshipReadStatusResponseDTO { public FriendshipReadStatusResponseDTO(Friendship friendship) { this.friendshipId = friendship.getId(); this.userId = friendship.getUser().getId(); - this.userNom = friendship.getUser().getNom(); - this.userPrenoms = friendship.getUser().getPrenoms(); + // v2.0 - Utiliser les nouveaux noms de champs + this.userNom = friendship.getUser().getLastName(); + this.userPrenoms = friendship.getUser().getFirstName(); this.friendId = friendship.getFriend().getId(); - this.friendNom = friendship.getFriend().getNom(); - this.friendPrenoms = friendship.getFriend().getPrenoms(); + this.friendNom = friendship.getFriend().getLastName(); + this.friendPrenoms = friendship.getFriend().getFirstName(); this.status = friendship.getStatus(); this.createdAt = friendship.getCreatedAt(); } diff --git a/src/main/java/com/lions/dev/dto/response/social/SocialPostResponseDTO.java b/src/main/java/com/lions/dev/dto/response/social/SocialPostResponseDTO.java index df7d5eb..734371e 100644 --- a/src/main/java/com/lions/dev/dto/response/social/SocialPostResponseDTO.java +++ b/src/main/java/com/lions/dev/dto/response/social/SocialPostResponseDTO.java @@ -33,7 +33,7 @@ public class SocialPostResponseDTO { private int sharesCount; /** - * Constructeur à partir d'une entité SocialPost. + * Constructeur à partir d'une entité SocialPost (v2.0). * * @param post L'entité SocialPost */ @@ -42,8 +42,9 @@ public class SocialPostResponseDTO { this.id = post.getId(); this.content = post.getContent(); this.userId = post.getUser() != null ? post.getUser().getId() : null; - this.userFirstName = post.getUser() != null ? post.getUser().getPrenoms() : null; - this.userLastName = post.getUser() != null ? post.getUser().getNom() : null; + // v2.0 - Utiliser les nouveaux noms de champs + this.userFirstName = post.getUser() != null ? post.getUser().getFirstName() : null; + this.userLastName = post.getUser() != null ? post.getUser().getLastName() : null; this.userProfileImageUrl = post.getUser() != null ? post.getUser().getProfileImageUrl() : null; this.timestamp = post.getCreatedAt(); this.imageUrl = post.getImageUrl(); diff --git a/src/main/java/com/lions/dev/dto/response/story/StoryResponseDTO.java b/src/main/java/com/lions/dev/dto/response/story/StoryResponseDTO.java index 8cceacc..34d5b37 100644 --- a/src/main/java/com/lions/dev/dto/response/story/StoryResponseDTO.java +++ b/src/main/java/com/lions/dev/dto/response/story/StoryResponseDTO.java @@ -46,8 +46,9 @@ public class StoryResponseDTO { if (story != null) { this.id = story.getId(); this.userId = story.getUser() != null ? story.getUser().getId() : null; - this.userFirstName = story.getUser() != null ? story.getUser().getPrenoms() : null; - this.userLastName = story.getUser() != null ? story.getUser().getNom() : null; + // v2.0 - Utiliser les nouveaux noms de champs + this.userFirstName = story.getUser() != null ? story.getUser().getFirstName() : null; + this.userLastName = story.getUser() != null ? story.getUser().getLastName() : null; this.userProfileImageUrl = story.getUser() != null ? story.getUser().getProfileImageUrl() : null; this.userIsVerified = story.getUser() != null && story.getUser().isVerified(); this.mediaType = story.getMediaType(); diff --git a/src/main/java/com/lions/dev/dto/response/users/FriendSuggestionResponseDTO.java b/src/main/java/com/lions/dev/dto/response/users/FriendSuggestionResponseDTO.java index 828b824..22f895c 100644 --- a/src/main/java/com/lions/dev/dto/response/users/FriendSuggestionResponseDTO.java +++ b/src/main/java/com/lions/dev/dto/response/users/FriendSuggestionResponseDTO.java @@ -26,7 +26,7 @@ public class FriendSuggestionResponseDTO { } /** - * Constructeur à partir d'un utilisateur. + * Constructeur à partir d'un utilisateur (v2.0). * * @param user L'utilisateur suggéré * @param mutualFriendsCount Le nombre d'amis en commun @@ -34,8 +34,9 @@ public class FriendSuggestionResponseDTO { */ public FriendSuggestionResponseDTO(Users user, int mutualFriendsCount, String reason) { this.userId = user.getId(); - this.nom = user.getNom(); - this.prenoms = user.getPrenoms(); + // v2.0 - Utiliser les nouveaux noms de champs + this.nom = user.getLastName(); // Compatibilité v1.0 + this.prenoms = user.getFirstName(); // Compatibilité v1.0 this.email = user.getEmail(); this.profileImageUrl = user.getProfileImageUrl(); this.mutualFriendsCount = mutualFriendsCount; diff --git a/src/main/java/com/lions/dev/dto/response/users/UserAuthenticateResponseDTO.java b/src/main/java/com/lions/dev/dto/response/users/UserAuthenticateResponseDTO.java index 3443103..6fd8771 100644 --- a/src/main/java/com/lions/dev/dto/response/users/UserAuthenticateResponseDTO.java +++ b/src/main/java/com/lions/dev/dto/response/users/UserAuthenticateResponseDTO.java @@ -10,30 +10,33 @@ import org.slf4j.LoggerFactory; /** * DTO pour la réponse d'authentification de l'utilisateur. + * + * Version 2.0 - Architecture refactorée avec nommage standardisé. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * * Utilisé pour renvoyer les informations nécessaires après l'authentification réussie d'un utilisateur. */ @Getter @Setter -@AllArgsConstructor @NoArgsConstructor public class UserAuthenticateResponseDTO { private static final Logger logger = LoggerFactory.getLogger(UserAuthenticateResponseDTO.class); /** - * Identifiant unique de l'utilisateur authentifié. + * Identifiant unique de l'utilisateur authentifié (v2.0). */ - private UUID userId; + private UUID id; // v2.0 /** - * Nom de l'utilisateur. + * Prénom de l'utilisateur (v2.0). */ - private String nom; + private String firstName; // v2.0 /** - * Prénom de l'utilisateur. + * Nom de famille de l'utilisateur (v2.0). */ - private String prenoms; + private String lastName; // v2.0 /** * Adresse email de l'utilisateur. @@ -45,6 +48,49 @@ public class UserAuthenticateResponseDTO { */ private String role; + // Champs de compatibilité v1.0 (dépréciés) + /** + * @deprecated Utiliser {@link #id} à la place. + */ + @Deprecated + private UUID userId; + + /** + * @deprecated Utiliser {@link #lastName} à la place. + */ + @Deprecated + private String nom; + + /** + * @deprecated Utiliser {@link #firstName} à la place. + */ + @Deprecated + private String prenoms; + + /** + * Constructeur v2.0. + */ + public UserAuthenticateResponseDTO(UUID id, String firstName, String lastName, String email, String role) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.role = role; + // Compatibilité v1.0 + this.userId = id; + this.nom = lastName; + this.prenoms = firstName; + } + + /** + * Constructeur de compatibilité v1.0 (déprécié). + * @deprecated Utiliser le constructeur avec firstName et lastName à la place. + */ + @Deprecated + public UserAuthenticateResponseDTO(UUID userId, String prenoms, String nom, String email, String role, boolean deprecated) { + this(userId, prenoms, nom, email, role); + } + /** * Log de création de l'objet DTO. */ @@ -53,9 +99,10 @@ public class UserAuthenticateResponseDTO { } /** - * Méthode personnalisée pour loguer les détails de la réponse. + * Méthode personnalisée pour loguer les détails de la réponse (v2.0). */ public void logResponseDetails() { - logger.info("[LOG] Réponse d'authentification - Utilisateur: {}, {}, Email: {}, Rôle: {}, ID: {}", prenoms, nom, email, role, userId); + logger.info("[LOG] Réponse d'authentification - Utilisateur: {} {}, Email: {}, Rôle: {}, ID: {}", + firstName, lastName, email, role, id); } } 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 7d3cf5f..0266ea5 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 @@ -6,30 +6,66 @@ import lombok.Getter; /** * DTO pour renvoyer les informations d'un utilisateur. + * + * Version 2.0 - Architecture refactorée avec nommage standardisé. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * * Ce DTO est utilisé pour structurer les données retournées dans les réponses * après les opérations sur les utilisateurs (création, récupération). */ @Getter public class UserCreateResponseDTO { - private UUID uuid; // Identifiant unique de l'utilisateur - private String nom; // Nom de l'utilisateur - private String prenoms; // Prénoms de l'utilisateur + private UUID id; // v2.0 - Identifiant unique de l'utilisateur + private String firstName; // v2.0 - Prénom de l'utilisateur + private String lastName; // v2.0 - Nom de famille de l'utilisateur private String email; // Email de l'utilisateur - private String role; // Roğe de l'utilisateur - private String profileImageUrl; // Url de l'image de profil de l'utilisateur + private String role; // Rôle de l'utilisateur + 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é + private java.util.Map preferences; // v2.0 - Préférences utilisateur + + // Champs de compatibilité v1.0 (dépréciés) /** - * Constructeur qui transforme une entité Users en DTO. + * @deprecated Utiliser {@link #id} à la place. + */ + @Deprecated + private UUID uuid; + + /** + * @deprecated Utiliser {@link #lastName} à la place. + */ + @Deprecated + private String nom; + + /** + * @deprecated Utiliser {@link #firstName} à la place. + */ + @Deprecated + private String prenoms; + + /** + * Constructeur qui transforme une entité Users en DTO (v2.0). * * @param user L'utilisateur à convertir en DTO. */ public UserCreateResponseDTO(Users user) { - this.uuid = user.getId(); - this.nom = user.getNom(); - this.prenoms = user.getPrenoms(); + this.id = user.getId(); // v2.0 + this.firstName = user.getFirstName(); // v2.0 + this.lastName = user.getLastName(); // v2.0 this.email = user.getEmail(); this.role = user.getRole(); this.profileImageUrl = user.getProfileImageUrl(); + this.bio = user.getBio(); // v2.0 + this.loyaltyPoints = user.getLoyaltyPoints(); // v2.0 + this.preferences = user.getPreferences(); // v2.0 + + // Compatibilité v1.0 + this.uuid = this.id; + this.nom = this.lastName; + this.prenoms = this.firstName; + System.out.println("[LOG] DTO créé pour l'utilisateur : " + this.email); } } diff --git a/src/main/java/com/lions/dev/dto/response/users/UserResponseDTO.java b/src/main/java/com/lions/dev/dto/response/users/UserResponseDTO.java index 5bf2f12..c6d65b3 100644 --- a/src/main/java/com/lions/dev/dto/response/users/UserResponseDTO.java +++ b/src/main/java/com/lions/dev/dto/response/users/UserResponseDTO.java @@ -9,6 +9,10 @@ import lombok.Setter; /** * DTO (Data Transfer Object) pour l'utilisateur. + * + * Version 2.0 - Architecture refactorée avec nommage standardisé. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * *

* Cette classe sert de représentation simplifiée d'un utilisateur, avec un ensemble d'informations nécessaires à * la réponse de l'API. Elle est utilisée pour transférer des données entre le backend (serveur) et le frontend (client) @@ -27,14 +31,14 @@ public class UserResponseDTO { private UUID id; /** - * Nom de famille de l'utilisateur. C'est une donnée importante pour l'affichage du profil. + * Prénom de l'utilisateur (v2.0). */ - private String nom; + private String firstName; /** - * Prénom(s) de l'utilisateur. Représente le ou les prénoms associés à l'utilisateur. + * Nom de famille de l'utilisateur (v2.0). */ - private String prenoms; + private String lastName; /** * Adresse email de l'utilisateur. C'est une donnée souvent utilisée pour les communications. @@ -47,13 +51,48 @@ public class UserResponseDTO { */ private String profileImageUrl; + /** + * Biographie courte de l'utilisateur (v2.0). + */ + private String bio; + + /** + * Points de fidélité accumulés (v2.0). + */ + private Integer loyaltyPoints; + + /** + * Préférences utilisateur en JSON (v2.0). + */ + private java.util.Map preferences; + + /** + * Rôle de l'utilisateur (ADMIN, USER, MANAGER, etc.). + */ + private String role; + /** * Indique si l'utilisateur est vérifié (compte officiel). */ private boolean isVerified; /** - * Constructeur de DTO à partir d'une entité Users. + * Indique si l'utilisateur est actuellement en ligne. + */ + private boolean isOnline; + + /** + * Dernière fois que l'utilisateur était en ligne. + */ + private java.time.LocalDateTime lastSeen; + + /** + * Date de création du compte. + */ + private java.time.LocalDateTime createdAt; + + /** + * Constructeur de DTO à partir d'une entité Users (v2.0). *

* Ce constructeur prend une entité {@link Users} et extrait les données nécessaires pour * peupler les champs du DTO. Cette transformation permet de transférer des données sans exposer @@ -64,12 +103,28 @@ public class UserResponseDTO { */ public UserResponseDTO(Users user) { if (user != null) { - this.id = user.getId(); // Identifiant unique de l'utilisateur - this.nom = user.getNom(); // Nom de famille - this.prenoms = user.getPrenoms(); // Prénom(s) - this.email = user.getEmail(); // Email - this.profileImageUrl = user.getProfileImageUrl(); // URL de l'image de profil - this.isVerified = user.isVerified(); // Statut de vérification + this.id = user.getId(); + this.firstName = user.getFirstName(); // v2.0 + this.lastName = user.getLastName(); // v2.0 + this.email = user.getEmail(); + this.profileImageUrl = user.getProfileImageUrl(); + this.bio = user.getBio(); // v2.0 + this.loyaltyPoints = user.getLoyaltyPoints(); // v2.0 + this.preferences = user.getPreferences(); // v2.0 + this.role = user.getRole(); + this.isVerified = user.isVerified(); + this.isOnline = user.isOnline(); + this.lastSeen = user.getLastSeen(); + this.createdAt = user.getCreatedAt(); } } + + /** + * Retourne le nom complet de l'utilisateur (v2.0). + * + * @return Le nom complet (firstName + lastName). + */ + public String getFullName() { + return (firstName != null ? firstName : "") + " " + (lastName != null ? lastName : "").trim(); + } } diff --git a/src/main/java/com/lions/dev/entity/booking/Booking.java b/src/main/java/com/lions/dev/entity/booking/Booking.java new file mode 100644 index 0000000..a66bce3 --- /dev/null +++ b/src/main/java/com/lions/dev/entity/booking/Booking.java @@ -0,0 +1,105 @@ +package com.lions.dev.entity.booking; + +import com.lions.dev.entity.BaseEntity; +import com.lions.dev.entity.establishment.Establishment; +import com.lions.dev.entity.users.Users; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * Entité représentant une réservation d'établissement. + * + * Version 2.0 - Architecture refactorée. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * + * Cette entité remplace/étend Reservation avec plus de détails et de flexibilité. + */ +@Entity +@Table(name = "bookings") +@Getter +@Setter +@NoArgsConstructor +@ToString +public class Booking extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "establishment_id", nullable = false) + private Establishment establishment; // L'établissement concerné par la réservation + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; // L'utilisateur qui a fait la réservation + + @Column(name = "reservation_time", nullable = false) + private LocalDateTime reservationTime; // Date et heure de la réservation + + @Column(name = "guest_count", nullable = false) + private Integer guestCount = 1; // Nombre de convives + + @Column(name = "status", nullable = false, length = 20) + private String status = "PENDING"; // Statut: PENDING, CONFIRMED, CANCELLED, COMPLETED + + @Column(name = "special_requests", columnDefinition = "TEXT") + private String specialRequests; // Demandes spéciales (allergies, préférences, etc.) + + @Column(name = "table_number", length = 20) + private String tableNumber; // Numéro de table assigné (si applicable) + + /** + * Constructeur pour créer une réservation. + * + * @param establishment L'établissement concerné + * @param user L'utilisateur qui fait la réservation + * @param reservationTime La date et heure de la réservation + * @param guestCount Le nombre de convives + */ + public Booking(Establishment establishment, Users user, LocalDateTime reservationTime, Integer guestCount) { + this.establishment = establishment; + this.user = user; + this.reservationTime = reservationTime; + this.guestCount = guestCount; + this.status = "PENDING"; + } + + /** + * Vérifie si la réservation est confirmée. + * + * @return true si la réservation est confirmée, false sinon. + */ + public boolean isConfirmed() { + return "CONFIRMED".equalsIgnoreCase(this.status); + } + + /** + * Vérifie si la réservation est en attente. + * + * @return true si la réservation est en attente, false sinon. + */ + public boolean isPending() { + return "PENDING".equalsIgnoreCase(this.status); + } + + /** + * Vérifie si la réservation est annulée. + * + * @return true si la réservation est annulée, false sinon. + */ + public boolean isCancelled() { + return "CANCELLED".equalsIgnoreCase(this.status); + } + + /** + * Vérifie si la réservation est complétée. + * + * @return true si la réservation est complétée, false sinon. + */ + public boolean isCompleted() { + return "COMPLETED".equalsIgnoreCase(this.status); + } +} + diff --git a/src/main/java/com/lions/dev/entity/establishment/BusinessHours.java b/src/main/java/com/lions/dev/entity/establishment/BusinessHours.java new file mode 100644 index 0000000..71914f9 --- /dev/null +++ b/src/main/java/com/lions/dev/entity/establishment/BusinessHours.java @@ -0,0 +1,90 @@ +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 lombok.ToString; + +import java.time.LocalDateTime; + +/** + * Entité représentant les horaires d'ouverture d'un établissement. + * + * Version 2.0 - Architecture refactorée. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * + * Cette entité permet de gérer les horaires d'ouverture par jour de la semaine, + * ainsi que les exceptions (fermetures temporaires, jours fériés, etc.). + */ +@Entity +@Table(name = "business_hours") +@Getter +@Setter +@NoArgsConstructor +@ToString +public class BusinessHours extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "establishment_id", nullable = false) + private Establishment establishment; // L'établissement concerné + + @Column(name = "day_of_week", nullable = false, length = 20) + private String dayOfWeek; // Jour de la semaine: MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY + + @Column(name = "open_time", nullable = false, length = 5) + private String openTime; // Heure d'ouverture (format HH:MM, ex: "09:00") + + @Column(name = "close_time", nullable = false, length = 5) + private String closeTime; // Heure de fermeture (format HH:MM, ex: "18:00") + + @Column(name = "is_closed", nullable = false) + private Boolean isClosed = false; // Indique si l'établissement est fermé ce jour + + @Column(name = "is_exception", nullable = false) + private Boolean isException = false; // Indique si c'est un jour exceptionnel (fermeture temporaire, jour férié, etc.) + + @Column(name = "exception_date") + private LocalDateTime exceptionDate; // Date de l'exception (si is_exception = true) + + /** + * Constructeur pour créer des horaires d'ouverture standard. + * + * @param establishment L'établissement concerné + * @param dayOfWeek Le jour de la semaine + * @param openTime L'heure d'ouverture (format HH:MM) + * @param closeTime L'heure de fermeture (format HH:MM) + */ + public BusinessHours(Establishment establishment, String dayOfWeek, String openTime, String closeTime) { + this.establishment = establishment; + this.dayOfWeek = dayOfWeek; + this.openTime = openTime; + this.closeTime = closeTime; + this.isClosed = false; + this.isException = false; + } + + /** + * Constructeur pour créer un jour de fermeture. + * + * @param establishment L'établissement concerné + * @param dayOfWeek Le jour de la semaine + */ + public BusinessHours(Establishment establishment, String dayOfWeek) { + this.establishment = establishment; + this.dayOfWeek = dayOfWeek; + this.isClosed = true; + this.isException = false; + } + + /** + * Vérifie si l'établissement est ouvert ce jour. + * + * @return true si l'établissement est ouvert, false sinon. + */ + public boolean isOpen() { + return !isClosed; + } +} + 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 8485169..6ab022d 100644 --- a/src/main/java/com/lions/dev/entity/establishment/Establishment.java +++ b/src/main/java/com/lions/dev/entity/establishment/Establishment.java @@ -10,6 +10,10 @@ import lombok.ToString; /** * Entité représentant un établissement dans le système AfterWork. + * + * Version 2.0 - Architecture refactorée avec nommage standardisé. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * * Un établissement est un lieu physique (bar, restaurant, club, etc.) * où peuvent se dérouler des événements Afterwork. * Seuls les responsables d'établissement peuvent créer et gérer des établissements. @@ -43,35 +47,20 @@ public class Establishment extends BaseEntity { @Column(name = "phone_number") private String phoneNumber; // Numéro de téléphone - @Column(name = "email") - private String email; // Email de contact - @Column(name = "website") private String website; // Site web - @Column(name = "image_url") - private String imageUrl; // URL de l'image de l'établissement - - @Column(name = "rating") - private Double rating; // Note moyenne sur 5 (déprécié, utiliser averageRating) - @Column(name = "average_rating") private Double averageRating; // Note moyenne calculée (0.0 à 5.0) - @Column(name = "total_ratings_count") - private Integer totalRatingsCount; // Nombre total de notes + @Column(name = "total_reviews_count") + private Integer totalReviewsCount; // Nombre total d'avis (v2.0 - renommé depuis total_ratings_count) @Column(name = "price_range") - private String priceRange; // Fourchette de prix (cheap, moderate, expensive, luxury) + private String priceRange; // Fourchette de prix (LOW, MEDIUM, HIGH, PREMIUM) - @Column(name = "capacity") - private Integer capacity; // Capacité maximale - - @Column(name = "amenities", length = 1000) - private String amenities; // Équipements (WiFi, Terrasse, Parking, etc.) - JSON ou séparés par virgule - - @Column(name = "opening_hours") - private String openingHours; // Heures d'ouverture + @Column(name = "verification_status", nullable = false) + private String verificationStatus = "PENDING"; // Statut de vérification: PENDING, VERIFIED, REJECTED (v2.0) @Column(name = "latitude") private Double latitude; // Latitude pour la géolocalisation @@ -88,7 +77,25 @@ public class Establishment extends BaseEntity { private java.util.List medias = new java.util.ArrayList<>(); // Liste des médias de l'établissement @OneToMany(mappedBy = "establishment", cascade = CascadeType.ALL, orphanRemoval = true) - private java.util.List ratings = new java.util.ArrayList<>(); // Liste des notes de l'établissement + private java.util.List ratings = new java.util.ArrayList<>(); // Liste des notes de l'établissement (v1.0 - à migrer vers Review) + + /** + * Vérifie si l'établissement est vérifié. + * + * @return true si l'établissement est vérifié, false sinon. + */ + public boolean isVerified() { + return "VERIFIED".equalsIgnoreCase(this.verificationStatus); + } + + /** + * Vérifie si l'établissement est en attente de vérification. + * + * @return true si l'établissement est en attente, false sinon. + */ + public boolean isPending() { + return "PENDING".equalsIgnoreCase(this.verificationStatus); + } /** * Constructeur pour créer un établissement avec les informations de base. diff --git a/src/main/java/com/lions/dev/entity/establishment/EstablishmentAmenity.java b/src/main/java/com/lions/dev/entity/establishment/EstablishmentAmenity.java new file mode 100644 index 0000000..5dd7d8a --- /dev/null +++ b/src/main/java/com/lions/dev/entity/establishment/EstablishmentAmenity.java @@ -0,0 +1,98 @@ +package com.lions.dev.entity.establishment; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Entité représentant un équipement d'un établissement. + * + * Version 2.0 - Architecture refactorée. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * + * Cette entité fait la liaison entre un établissement et un type d'équipement, + * avec la possibilité d'ajouter des détails supplémentaires. + */ +@Entity +@Table(name = "establishment_amenities") +@Getter +@Setter +@NoArgsConstructor +@ToString +@IdClass(EstablishmentAmenityId.class) +public class EstablishmentAmenity { + + @Id + @Column(name = "establishment_id", nullable = false) + private UUID establishmentId; // ID de l'établissement + + @Id + @Column(name = "amenity_id", nullable = false) + private UUID amenityId; // ID du type d'équipement (référence à amenity_types) + + @Column(name = "details", length = 500) + private String details; // Détails supplémentaires (ex: "Parking gratuit pour 20 voitures") + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt = LocalDateTime.now(); // Date de création + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "establishment_id", insertable = false, updatable = false) + private Establishment establishment; // L'établissement concerné + + /** + * Constructeur pour créer une liaison établissement-équipement. + * + * @param establishmentId L'ID de l'établissement + * @param amenityId L'ID du type d'équipement + * @param details Les détails supplémentaires (optionnel) + */ + public EstablishmentAmenity(UUID establishmentId, UUID amenityId, String details) { + this.establishmentId = establishmentId; + this.amenityId = amenityId; + this.details = details; + this.createdAt = LocalDateTime.now(); + } + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} + +/** + * Classe composite pour la clé primaire de EstablishmentAmenity. + */ +@Getter +@Setter +@NoArgsConstructor +class EstablishmentAmenityId implements java.io.Serializable { + private UUID establishmentId; + private UUID amenityId; + + public EstablishmentAmenityId(UUID establishmentId, UUID amenityId) { + this.establishmentId = establishmentId; + this.amenityId = amenityId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + EstablishmentAmenityId that = (EstablishmentAmenityId) o; + return establishmentId.equals(that.establishmentId) && amenityId.equals(that.amenityId); + } + + @Override + public int hashCode() { + return establishmentId.hashCode() + amenityId.hashCode(); + } +} + diff --git a/src/main/java/com/lions/dev/entity/establishment/Review.java b/src/main/java/com/lions/dev/entity/establishment/Review.java new file mode 100644 index 0000000..d088a52 --- /dev/null +++ b/src/main/java/com/lions/dev/entity/establishment/Review.java @@ -0,0 +1,92 @@ +package com.lions.dev.entity.establishment; + +import com.lions.dev.entity.BaseEntity; +import com.lions.dev.entity.users.Users; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.util.HashMap; +import java.util.Map; + +/** + * Entité représentant un avis (review) sur un établissement. + * + * Version 2.0 - Architecture refactorée. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * + * Cette entité remplace EstablishmentRating avec des fonctionnalités plus avancées, + * incluant des notes par critères et la vérification des visites. + */ +@Entity +@Table(name = "reviews", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "establishment_id"})) +@Getter +@Setter +@NoArgsConstructor +@ToString +public class Review extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; // L'utilisateur qui a écrit l'avis + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "establishment_id", nullable = false) + private Establishment establishment; // L'établissement concerné + + @Column(name = "overall_rating", nullable = false) + private Integer overallRating; // Note globale (1-5) + + @Column(name = "comment", columnDefinition = "TEXT") + private String comment; // Commentaire libre de l'utilisateur + + @Column(name = "criteria_ratings", nullable = false) + @org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON) + private Map criteriaRatings = new HashMap<>(); // Notes par critères (ex: {"ambiance": 4, "service": 5}) + + @Column(name = "is_verified_visit", nullable = false) + private Boolean isVerifiedVisit = false; // Indique si l'avis est lié à une visite vérifiée (réservation complétée) + + /** + * Constructeur pour créer un avis. + * + * @param user L'utilisateur qui écrit l'avis + * @param establishment L'établissement concerné + * @param overallRating La note globale (1-5) + * @param comment Le commentaire (optionnel) + */ + public Review(Users user, Establishment establishment, Integer overallRating, String comment) { + this.user = user; + this.establishment = establishment; + this.overallRating = overallRating; + this.comment = comment; + this.criteriaRatings = new HashMap<>(); + this.isVerifiedVisit = false; + } + + /** + * Ajoute une note pour un critère spécifique. + * + * @param criteria Le nom du critère (ex: "ambiance", "service", "qualité") + * @param rating La note (1-5) + */ + public void addCriteriaRating(String criteria, Integer rating) { + if (rating >= 1 && rating <= 5) { + this.criteriaRatings.put(criteria, rating); + } + } + + /** + * Retourne la note pour un critère spécifique. + * + * @param criteria Le nom du critère + * @return La note, ou null si le critère n'existe pas + */ + public Integer getCriteriaRating(String criteria) { + return this.criteriaRatings.get(criteria); + } +} + diff --git a/src/main/java/com/lions/dev/entity/events/Events.java b/src/main/java/com/lions/dev/entity/events/Events.java index fa7d719..bbd37a6 100644 --- a/src/main/java/com/lions/dev/entity/events/Events.java +++ b/src/main/java/com/lions/dev/entity/events/Events.java @@ -16,8 +16,12 @@ import java.util.Set; /** * Entité représentant un événement dans le système AfterWork. + * + * Version 2.0 - Architecture refactorée avec nommage standardisé. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * * Chaque événement possède un titre, une description, une date de début, une date de fin, - * un créateur, une catégorie, un lieu, une URL d'image, et des participants. + * un créateur, une catégorie, un établissement, une URL d'image, et des participants. * Tous les logs et commentaires nécessaires pour la traçabilité et la documentation sont inclus. */ @Entity @@ -40,8 +44,9 @@ public class Events extends BaseEntity { @Column(name = "end_date", nullable = false) private LocalDateTime endDate; // La date de fin de l'événement - @Column(name = "location") - private String location; // Le lieu de l'événement + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "establishment_id") + private com.lions.dev.entity.establishment.Establishment establishment; // L'établissement où se déroule l'événement (v2.0) @Column(name = "category") private String category; // La catégorie de l'événement @@ -53,7 +58,7 @@ public class Events extends BaseEntity { private String imageUrl; // URL d'une image associée à l'événement @Column(name = "status", nullable = false) - private String status = "ouvert"; // Le statut de l'événement (en cours, terminé, annulé, etc.) + private String status = "OPEN"; // Le statut de l'événement (OPEN, CLOSED, CANCELLED, COMPLETED) (v2.0) @Column(name = "max_participants") private Integer maxParticipants; // Nombre maximum de participants autorisés @@ -67,6 +72,12 @@ public class Events extends BaseEntity { @Column(name = "participation_fee") private Integer participationFee; // Frais de participation en centimes + @Column(name = "is_private", nullable = false) + private Boolean isPrivate = false; // Indique si l'événement est privé (v2.0) + + @Column(name = "waitlist_enabled", nullable = false) + private Boolean waitlistEnabled = false; // Indique si la liste d'attente est activée (v2.0) + @Column(name = "privacy_rules", length = 1000) private String privacyRules; // Règles de confidentialité de l'événement @@ -131,13 +142,44 @@ public class Events extends BaseEntity { } /** - * Ferme l'événement en changeant son statut. + * Ferme l'événement en changeant son statut (v2.0). */ public void setClosed(boolean closed) { - this.status = closed ? "fermé" : "ouvert"; + this.status = closed ? "CLOSED" : "OPEN"; System.out.println("[LOG] Statut de l'événement mis à jour : " + this.title + " - " + this.status); } + /** + * Vérifie si l'événement est ouvert (v2.0). + * + * @return true si l'événement est ouvert, false sinon. + */ + public boolean isOpen() { + return "OPEN".equalsIgnoreCase(this.status); + } + + /** + * Vérifie si l'événement est fermé (v2.0). + * + * @return true si l'événement est fermé, false sinon. + */ + public boolean isClosed() { + return "CLOSED".equalsIgnoreCase(this.status); + } + + /** + * Retourne le lieu de l'événement depuis l'établissement associé (v2.0). + * Méthode de compatibilité pour remplacer l'ancien champ location. + * + * @return L'adresse de l'établissement, ou null si aucun établissement n'est associé. + */ + public String getLocation() { + if (establishment != null) { + return establishment.getAddress() + ", " + establishment.getCity(); + } + return null; + } + @OneToMany(fetch = FetchType.LAZY, mappedBy = "event") private List comments; // Liste des commentaires associés à l'événement diff --git a/src/main/java/com/lions/dev/entity/promotion/Promotion.java b/src/main/java/com/lions/dev/entity/promotion/Promotion.java new file mode 100644 index 0000000..d7168f9 --- /dev/null +++ b/src/main/java/com/lions/dev/entity/promotion/Promotion.java @@ -0,0 +1,119 @@ +package com.lions.dev.entity.promotion; + +import com.lions.dev.entity.BaseEntity; +import com.lions.dev.entity.establishment.Establishment; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * Entité représentant une promotion ou une offre spéciale d'un établissement. + * + * Version 2.0 - Architecture refactorée. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * + * Cette entité permet de gérer les promotions, happy hours, et offres spéciales + * avec différents types de réductions et codes promo. + */ +@Entity +@Table(name = "promotions") +@Getter +@Setter +@NoArgsConstructor +@ToString +public class Promotion extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "establishment_id", nullable = false) + private Establishment establishment; // L'établissement qui propose la promotion + + @Column(name = "title", nullable = false, length = 200) + private String title; // Titre de la promotion (ex: "Happy Hour", "Menu du jour") + + @Column(name = "description", columnDefinition = "TEXT") + private String description; // Description détaillée de la promotion + + @Column(name = "promo_code", length = 50, unique = true) + private String promoCode; // Code promo optionnel (ex: "HAPPY2024") + + @Column(name = "discount_type", nullable = false, length = 20) + private String discountType; // Type de réduction: PERCENTAGE, FIXED_AMOUNT, FREE_ITEM + + @Column(name = "discount_value", nullable = false) + private java.math.BigDecimal discountValue; // Valeur de la réduction (pourcentage, montant fixe, etc.) + + @Column(name = "valid_from", nullable = false) + private LocalDateTime validFrom; // Date de début de validité + + @Column(name = "valid_until", nullable = false) + private LocalDateTime validUntil; // Date de fin de validité + + @Column(name = "is_active", nullable = false) + private Boolean isActive = true; // Indique si la promotion est active + + /** + * Constructeur pour créer une promotion. + * + * @param establishment L'établissement qui propose la promotion + * @param title Le titre de la promotion + * @param description La description + * @param discountType Le type de réduction + * @param discountValue La valeur de la réduction + * @param validFrom La date de début + * @param validUntil La date de fin + */ + public Promotion(Establishment establishment, String title, String description, + String discountType, java.math.BigDecimal discountValue, + LocalDateTime validFrom, LocalDateTime validUntil) { + this.establishment = establishment; + this.title = title; + this.description = description; + this.discountType = discountType; + this.discountValue = discountValue; + this.validFrom = validFrom; + this.validUntil = validUntil; + this.isActive = true; + } + + /** + * Vérifie si la promotion est actuellement valide. + * + * @return true si la promotion est active et dans sa période de validité, false sinon. + */ + public boolean isValid() { + LocalDateTime now = LocalDateTime.now(); + return isActive && now.isAfter(validFrom) && now.isBefore(validUntil); + } + + /** + * Vérifie si la promotion est expirée. + * + * @return true si la promotion est expirée, false sinon. + */ + public boolean isExpired() { + return LocalDateTime.now().isAfter(validUntil); + } + + /** + * Vérifie si la promotion est une réduction en pourcentage. + * + * @return true si c'est un pourcentage, false sinon. + */ + public boolean isPercentage() { + return "PERCENTAGE".equalsIgnoreCase(this.discountType); + } + + /** + * Vérifie si la promotion est un montant fixe. + * + * @return true si c'est un montant fixe, false sinon. + */ + public boolean isFixedAmount() { + return "FIXED_AMOUNT".equalsIgnoreCase(this.discountType); + } +} + diff --git a/src/main/java/com/lions/dev/entity/reaction/Reaction.java b/src/main/java/com/lions/dev/entity/reaction/Reaction.java index 92176f2..0621186 100644 --- a/src/main/java/com/lions/dev/entity/reaction/Reaction.java +++ b/src/main/java/com/lions/dev/entity/reaction/Reaction.java @@ -2,7 +2,6 @@ package com.lions.dev.entity.reaction; import com.lions.dev.entity.BaseEntity; import com.lions.dev.entity.users.Users; -import com.lions.dev.entity.events.Events; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; @@ -10,58 +9,66 @@ import lombok.Setter; import lombok.ToString; /** - * Entité représentant une réaction d'un utilisateur à un événement dans le système AfterWork. - * Une réaction peut être un "like", un "dislike" ou toute autre forme de réaction définie. - * - * Cette entité est liée à l'utilisateur qui réagit et à l'événement auquel la réaction est associée. - * Tous les logs et commentaires sont inclus pour la traçabilité. + * Entité représentant une réaction d'un utilisateur à un contenu. + * + * Version 2.0 - Architecture refactorée. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * + * Cette entité remplace les compteurs de réactions simples avec un système + * plus flexible permettant différents types de réactions sur différents contenus. */ @Entity -@Table(name = "reactions") +@Table(name = "reactions", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "target_type", "target_id"})) @Getter @Setter @NoArgsConstructor @ToString public class Reaction extends BaseEntity { - // Types de réaction possibles - public enum ReactionType { - LIKE, DISLIKE, LOVE, ANGRY, SAD - } - - @Enumerated(EnumType.STRING) - @Column(name = "reaction_type", nullable = false) - private ReactionType reactionType; // Le type de réaction (LIKE, DISLIKE, etc.) - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) - private Users user; // L'utilisateur qui a effectué la réaction + private Users user; // L'utilisateur qui a réagi - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "event_id", nullable = false) - private Events event; // L'événement auquel la réaction est associée + @Column(name = "reaction_type", nullable = false, length = 20) + private String reactionType; // Type de réaction: LIKE, LOVE, HAHA, WOW, SAD, ANGRY + + @Column(name = "target_type", nullable = false, length = 20) + private String targetType; // Type de contenu ciblé: POST, EVENT, COMMENT, REVIEW + + @Column(name = "target_id", nullable = false) + private java.util.UUID targetId; // ID du contenu ciblé (post_id, event_id, comment_id, review_id) /** - * Associe une réaction à un utilisateur et un événement. + * Constructeur pour créer une réaction. * - * @param user L'utilisateur qui réagit. - * @param event L'événement auquel la réaction est liée. - * @param reactionType Le type de réaction. + * @param user L'utilisateur qui réagit + * @param reactionType Le type de réaction (LIKE, LOVE, HAHA, WOW, SAD, ANGRY) + * @param targetType Le type de contenu (POST, EVENT, COMMENT, REVIEW) + * @param targetId L'ID du contenu ciblé */ - public Reaction(Users user, Events event, ReactionType reactionType) { + public Reaction(Users user, String reactionType, String targetType, java.util.UUID targetId) { this.user = user; - this.event = event; this.reactionType = reactionType; - System.out.println("[LOG] Nouvelle réaction ajoutée : " + reactionType + " par l'utilisateur : " + user.getEmail() + " à l'événement : " + event.getTitle()); + this.targetType = targetType; + this.targetId = targetId; } /** - * Modifie le type de réaction de l'utilisateur pour cet événement. + * Vérifie si la réaction est un "like". * - * @param newReactionType Le nouveau type de réaction. + * @return true si c'est un like, false sinon. */ - public void updateReaction(ReactionType newReactionType) { - System.out.println("[LOG] Changement de la réaction de " + this.reactionType + " à " + newReactionType + " pour l'utilisateur : " + user.getEmail() + " à l'événement : " + event.getTitle()); - this.reactionType = newReactionType; + public boolean isLike() { + return "LIKE".equalsIgnoreCase(this.reactionType); + } + + /** + * Vérifie si la réaction est un "love". + * + * @return true si c'est un love, false sinon. + */ + public boolean isLove() { + return "LOVE".equalsIgnoreCase(this.reactionType); } } diff --git a/src/main/java/com/lions/dev/repository/EstablishmentRatingRepository.java b/src/main/java/com/lions/dev/repository/EstablishmentRatingRepository.java index ed096ef..0034c24 100644 --- a/src/main/java/com/lions/dev/repository/EstablishmentRatingRepository.java +++ b/src/main/java/com/lions/dev/repository/EstablishmentRatingRepository.java @@ -17,13 +17,25 @@ public class EstablishmentRatingRepository implements PanacheRepositoryBase results = getEntityManager() + .createQuery( + "SELECT r FROM EstablishmentRating r " + + "JOIN FETCH r.establishment " + + "JOIN FETCH r.user " + + "WHERE r.establishment.id = :establishmentId AND r.user.id = :userId", + EstablishmentRating.class + ) + .setParameter("establishmentId", establishmentId) + .setParameter("userId", userId) + .getResultList(); + return results.isEmpty() ? null : results.get(0); } /** diff --git a/src/main/java/com/lions/dev/repository/StoryRepository.java b/src/main/java/com/lions/dev/repository/StoryRepository.java index af5a397..5691074 100644 --- a/src/main/java/com/lions/dev/repository/StoryRepository.java +++ b/src/main/java/com/lions/dev/repository/StoryRepository.java @@ -25,10 +25,23 @@ public class StoryRepository implements PanacheRepositoryBase { * @return Liste des stories actives */ public List findAllActive() { - System.out.println("[LOG] Récupération de toutes les stories actives"); + return findAllActive(0, Integer.MAX_VALUE); + } + + /** + * Récupère toutes les stories actives (non expirées) avec pagination, triées par date de création décroissante. + * + * @param page Le numéro de la page (0-indexé) + * @param size La taille de la page + * @return Liste paginée des stories actives + */ + public List findAllActive(int page, int size) { + System.out.println("[LOG] Récupération de toutes les stories actives (page: " + page + ", size: " + size + ")"); List stories = find("isActive = true AND expiresAt > ?1", Sort.by("createdAt", Sort.Direction.Descending), - LocalDateTime.now()).list(); + LocalDateTime.now()) + .page(page, size) + .list(); System.out.println("[LOG] " + stories.size() + " story(ies) active(s) récupérée(s)"); return stories; } @@ -40,11 +53,25 @@ public class StoryRepository implements PanacheRepositoryBase { * @return Liste des stories actives de l'utilisateur */ public List findActiveByUserId(UUID userId) { - System.out.println("[LOG] Récupération des stories actives pour l'utilisateur ID : " + userId); + return findActiveByUserId(userId, 0, Integer.MAX_VALUE); + } + + /** + * Récupère toutes les stories actives d'un utilisateur avec pagination. + * + * @param userId L'ID de l'utilisateur + * @param page Le numéro de la page (0-indexé) + * @param size La taille de la page + * @return Liste paginée des stories actives de l'utilisateur + */ + public List findActiveByUserId(UUID userId, int page, int size) { + System.out.println("[LOG] Récupération des stories actives pour l'utilisateur ID : " + userId + " (page: " + page + ", size: " + size + ")"); List stories = find("user.id = ?1 AND isActive = true AND expiresAt > ?2", Sort.by("createdAt", Sort.Direction.Descending), userId, - LocalDateTime.now()).list(); + LocalDateTime.now()) + .page(page, size) + .list(); System.out.println("[LOG] " + stories.size() + " story(ies) active(s) trouvée(s) pour l'utilisateur ID : " + userId); return stories; } diff --git a/src/main/java/com/lions/dev/resource/EstablishmentMediaResource.java b/src/main/java/com/lions/dev/resource/EstablishmentMediaResource.java index f1bf04b..6edc7ea 100644 --- a/src/main/java/com/lions/dev/resource/EstablishmentMediaResource.java +++ b/src/main/java/com/lions/dev/resource/EstablishmentMediaResource.java @@ -1,11 +1,13 @@ package com.lions.dev.resource; +import com.lions.dev.dto.request.establishment.EstablishmentMediaRequestDTO; import com.lions.dev.dto.response.establishment.EstablishmentMediaResponseDTO; import com.lions.dev.entity.establishment.EstablishmentMedia; import com.lions.dev.entity.establishment.MediaType; import com.lions.dev.service.EstablishmentMediaService; import jakarta.inject.Inject; import jakarta.transaction.Transactional; +import jakarta.validation.Valid; import jakarta.ws.rs.*; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.openapi.annotations.Operation; @@ -62,6 +64,7 @@ public class EstablishmentMediaResource { /** * Upload un nouveau média pour un établissement. + * Accepte un body JSON avec les informations du média. */ @POST @Transactional @@ -69,36 +72,56 @@ public class EstablishmentMediaResource { description = "Upload un nouveau média (photo ou vidéo) pour un établissement") public Response uploadMedia( @PathParam("establishmentId") String establishmentId, - @QueryParam("mediaUrl") String mediaUrl, - @QueryParam("mediaType") String mediaTypeStr, - @QueryParam("uploadedByUserId") String uploadedByUserIdStr, - @QueryParam("thumbnailUrl") String thumbnailUrl) { + @Valid EstablishmentMediaRequestDTO requestDTO, + @QueryParam("uploadedByUserId") String uploadedByUserIdStr) { LOG.info("Upload d'un média pour l'établissement : " + establishmentId); try { UUID id = UUID.fromString(establishmentId); - UUID uploadedByUserId = UUID.fromString(uploadedByUserIdStr); + + // Utiliser uploadedByUserId du query param ou du DTO + String userIdStr = uploadedByUserIdStr != null && !uploadedByUserIdStr.isEmpty() + ? uploadedByUserIdStr + : requestDTO.getUploadedByUserId(); + + if (userIdStr == null || userIdStr.isEmpty()) { + LOG.error("uploadedByUserId est obligatoire"); + return Response.status(Response.Status.BAD_REQUEST) + .entity("L'ID de l'utilisateur (uploadedByUserId) est obligatoire") + .build(); + } + + UUID uploadedByUserId = UUID.fromString(userIdStr); // Valider le type de média MediaType mediaType; try { - mediaType = MediaType.valueOf(mediaTypeStr.toUpperCase()); + mediaType = MediaType.valueOf(requestDTO.getMediaType().toUpperCase()); } catch (IllegalArgumentException e) { return Response.status(Response.Status.BAD_REQUEST) .entity("Type de média invalide. Utilisez PHOTO ou VIDEO") .build(); } - EstablishmentMedia media = mediaService.uploadMedia(id, mediaUrl, mediaType, uploadedByUserId, thumbnailUrl); + // Appeler le service avec displayOrder + EstablishmentMedia media = mediaService.uploadMedia( + id, + requestDTO.getMediaUrl(), + mediaType, + uploadedByUserId, + requestDTO.getThumbnailUrl(), + requestDTO.getDisplayOrder() != null ? requestDTO.getDisplayOrder() : 0 + ); + EstablishmentMediaResponseDTO responseDTO = new EstablishmentMediaResponseDTO(media); return Response.status(Response.Status.CREATED).entity(responseDTO).build(); } catch (IllegalArgumentException e) { - LOG.error("Paramètres invalides : " + e.getMessage()); + LOG.error("Paramètres invalides : " + e.getMessage(), e); return Response.status(Response.Status.BAD_REQUEST) .entity("Paramètres invalides : " + e.getMessage()) .build(); } catch (RuntimeException e) { - LOG.error("Erreur lors de l'upload du média : " + e.getMessage()); + LOG.error("Erreur lors de l'upload du média : " + e.getMessage(), e); return Response.status(Response.Status.BAD_REQUEST) .entity(e.getMessage()) .build(); diff --git a/src/main/java/com/lions/dev/resource/EstablishmentRatingResource.java b/src/main/java/com/lions/dev/resource/EstablishmentRatingResource.java index 9a1b5a0..b4d9677 100644 --- a/src/main/java/com/lions/dev/resource/EstablishmentRatingResource.java +++ b/src/main/java/com/lions/dev/resource/EstablishmentRatingResource.java @@ -109,46 +109,9 @@ public class EstablishmentRatingResource { } } - /** - * Récupère la note d'un utilisateur pour un établissement. - */ - @GET - @Path("/users/{userId}") - @Operation(summary = "Récupérer la note d'un utilisateur", - description = "Récupère la note donnée par un utilisateur spécifique pour un établissement") - public Response getUserRating( - @PathParam("establishmentId") String establishmentId, - @PathParam("userId") String userIdStr) { - LOG.info("Récupération de la note de l'utilisateur " + userIdStr + " pour l'établissement " + establishmentId); - - try { - UUID id = UUID.fromString(establishmentId); - UUID userId = UUID.fromString(userIdStr); - - EstablishmentRating rating = ratingService.getUserRating(id, userId); - if (rating == null) { - return Response.status(Response.Status.NOT_FOUND) - .entity("Note non trouvée") - .build(); - } - - EstablishmentRatingResponseDTO responseDTO = new EstablishmentRatingResponseDTO(rating); - return Response.ok(responseDTO).build(); - } catch (IllegalArgumentException e) { - LOG.error("ID invalide : " + e.getMessage()); - return Response.status(Response.Status.BAD_REQUEST) - .entity("ID invalide : " + e.getMessage()) - .build(); - } catch (Exception e) { - LOG.error("Erreur inattendue lors de la récupération de la note", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity("Erreur lors de la récupération de la note") - .build(); - } - } - /** * Récupère les statistiques de notation d'un établissement. + * Doit être déclaré avant les endpoints génériques GET pour la résolution correcte par JAX-RS. */ @GET @Path("/stats") @@ -179,5 +142,92 @@ public class EstablishmentRatingResource { .build(); } } + + /** + * Récupère la note d'un utilisateur pour un établissement (via path parameter). + * Endpoint alternatif pour compatibilité. + */ + @GET + @Path("/users/{userId}") + @Operation(summary = "Récupérer la note d'un utilisateur (path parameter)", + description = "Récupère la note donnée par un utilisateur spécifique pour un établissement (via path parameter)") + public Response getUserRatingByPath( + @PathParam("establishmentId") String establishmentId, + @PathParam("userId") String userIdStr) { + LOG.info("Récupération de la note de l'utilisateur " + userIdStr + " pour l'établissement " + establishmentId); + + try { + UUID id = UUID.fromString(establishmentId); + UUID userId = UUID.fromString(userIdStr); + + EstablishmentRating rating = ratingService.getUserRating(id, userId); + if (rating == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity("Note non trouvée") + .build(); + } + + EstablishmentRatingResponseDTO responseDTO = new EstablishmentRatingResponseDTO(rating); + return Response.ok(responseDTO).build(); + } catch (IllegalArgumentException e) { + LOG.error("ID invalide : " + e.getMessage(), e); + return Response.status(Response.Status.BAD_REQUEST) + .entity("ID invalide : " + e.getMessage()) + .build(); + } catch (Exception e) { + LOG.error("Erreur inattendue lors de la récupération de la note", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Erreur lors de la récupération de la note") + .build(); + } + } + + /** + * Récupère la note d'un utilisateur pour un établissement (via query parameter). + * Endpoint utilisé par le frontend Flutter. + * Doit être déclaré en dernier car c'est l'endpoint le plus générique. + */ + @GET + @Operation(summary = "Récupérer la note d'un utilisateur", + description = "Récupère la note donnée par un utilisateur spécifique pour un établissement (via query parameter userId)") + public Response getUserRatingByQuery( + @PathParam("establishmentId") String establishmentId, + @QueryParam("userId") String userIdStr) { + LOG.info("Récupération de la note de l'utilisateur " + userIdStr + " pour l'établissement " + establishmentId); + + // Si userId n'est pas fourni, retourner une erreur + if (userIdStr == null || userIdStr.isEmpty()) { + LOG.warn("userId manquant dans la requête"); + return Response.status(Response.Status.BAD_REQUEST) + .entity("Le paramètre userId est requis") + .build(); + } + + try { + UUID id = UUID.fromString(establishmentId); + UUID userId = UUID.fromString(userIdStr); + + EstablishmentRating rating = ratingService.getUserRating(id, userId); + if (rating == null) { + // Retourner 404 si la note n'existe pas + return Response.status(Response.Status.NOT_FOUND) + .entity("Note non trouvée") + .build(); + } + + EstablishmentRatingResponseDTO responseDTO = new EstablishmentRatingResponseDTO(rating); + return Response.ok(responseDTO).build(); + } catch (IllegalArgumentException e) { + LOG.error("ID invalide : " + e.getMessage(), e); + return Response.status(Response.Status.BAD_REQUEST) + .entity("ID invalide : " + e.getMessage()) + .build(); + } catch (Exception e) { + LOG.error("Erreur inattendue lors de la récupération de la note", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Erreur lors de la récupération de la note") + .build(); + } + } } diff --git a/src/main/java/com/lions/dev/resource/EstablishmentResource.java b/src/main/java/com/lions/dev/resource/EstablishmentResource.java index dd1204d..f98333b 100644 --- a/src/main/java/com/lions/dev/resource/EstablishmentResource.java +++ b/src/main/java/com/lions/dev/resource/EstablishmentResource.java @@ -76,14 +76,8 @@ public class EstablishmentResource { establishment.setPostalCode(requestDTO.getPostalCode()); establishment.setDescription(requestDTO.getDescription()); establishment.setPhoneNumber(requestDTO.getPhoneNumber()); - establishment.setEmail(requestDTO.getEmail()); establishment.setWebsite(requestDTO.getWebsite()); - establishment.setImageUrl(requestDTO.getImageUrl()); - establishment.setRating(requestDTO.getRating()); establishment.setPriceRange(requestDTO.getPriceRange()); - establishment.setCapacity(requestDTO.getCapacity()); - establishment.setAmenities(requestDTO.getAmenities()); - establishment.setOpeningHours(requestDTO.getOpeningHours()); establishment.setLatitude(requestDTO.getLatitude()); establishment.setLongitude(requestDTO.getLongitude()); @@ -245,14 +239,8 @@ public class EstablishmentResource { establishment.setPostalCode(requestDTO.getPostalCode()); establishment.setDescription(requestDTO.getDescription()); establishment.setPhoneNumber(requestDTO.getPhoneNumber()); - establishment.setEmail(requestDTO.getEmail()); establishment.setWebsite(requestDTO.getWebsite()); - establishment.setImageUrl(requestDTO.getImageUrl()); - establishment.setRating(requestDTO.getRating()); establishment.setPriceRange(requestDTO.getPriceRange()); - establishment.setCapacity(requestDTO.getCapacity()); - establishment.setAmenities(requestDTO.getAmenities()); - establishment.setOpeningHours(requestDTO.getOpeningHours()); establishment.setLatitude(requestDTO.getLatitude()); establishment.setLongitude(requestDTO.getLongitude()); diff --git a/src/main/java/com/lions/dev/resource/EventsResource.java b/src/main/java/com/lions/dev/resource/EventsResource.java index e0739ca..d2496a1 100644 --- a/src/main/java/com/lions/dev/resource/EventsResource.java +++ b/src/main/java/com/lions/dev/resource/EventsResource.java @@ -229,10 +229,12 @@ public class EventsResource { @Path("/created-by-user-and-friends") @Consumes("application/json") @Produces("application/json") - @Operation(summary = "Récupérer les événements créés par un utilisateur et ses amis", description = "Retourne la liste des événements créés par un utilisateur spécifique et ses amis") + @Operation(summary = "Récupérer les événements créés par un utilisateur et ses amis", description = "Retourne la liste paginée des événements créés par un utilisateur spécifique et ses amis") public Response getEventsCreatedByUserAndFriends(EventReadManyByIdRequestDTO requestDTO) { - UUID userId = requestDTO.getUserId(); - LOG.info("[LOG] Récupération des événements pour l'utilisateur avec l'ID : " + userId + " et ses amis"); + UUID userId = requestDTO.getId(); + int page = requestDTO.getPage() != null ? requestDTO.getPage() : 0; + int size = requestDTO.getSize() != null ? requestDTO.getSize() : 10; + LOG.info("[LOG] Récupération des événements pour l'utilisateur avec l'ID : " + userId + " et ses amis (page: " + page + ", size: " + size + ")"); Users user = usersRepository.findById(userId); if (user == null) { LOG.warn("[LOG] Utilisateur non trouvé avec l'ID : " + userId); @@ -247,8 +249,10 @@ public class EventsResource { LOG.info("[LOG] IDs d'amis + utilisateur (taille: " + friendIds.size() + ") : " + friendIds); - // Rechercher les événements créés par l'utilisateur et ses amis - List events = eventsRepository.find("creator.id IN ?1", friendIds).list(); + // Rechercher les événements créés par l'utilisateur et ses amis avec pagination + List events = eventsRepository.find("creator.id IN ?1 ORDER BY startDate DESC", friendIds) + .page(page, size) + .list(); LOG.info("[LOG] Nombre d'événements récupérés dans la requête : " + events.size()); // ✅ Retourner avec reactionsCount et isFavorite pour l'utilisateur actuel @@ -443,14 +447,17 @@ public class EventsResource { @Operation( summary = "Récupérer les événements auxquels un utilisateur est inscrit", description = "Retourne la liste des événements auxquels un utilisateur spécifique est inscrit") - public Response getEventsByUser(@PathParam("userId") UUID userId) { - LOG.info("[LOG] Récupération des événements pour l'utilisateur avec l'ID : " + userId); + public Response getEventsByUser( + @PathParam("userId") UUID userId, + @QueryParam("page") @DefaultValue("0") int page, + @QueryParam("size") @DefaultValue("10") int size) { + LOG.info("[LOG] Récupération des événements pour l'utilisateur avec l'ID : " + userId + " (page: " + page + ", size: " + size + ")"); Users user = usersRepository.findById(userId); if (user == null) { LOG.warn("[LOG] Utilisateur non trouvé avec l'ID : " + userId); return Response.status(Response.Status.NOT_FOUND).entity("Utilisateur non trouvé.").build(); } - List events = eventService.findEventsByUser(user); + List events = eventService.findEventsByUser(user, page, size); if (events.isEmpty()) { LOG.warn("[LOG] Aucun événement trouvé pour l'utilisateur avec l'ID : " + userId); return Response.status(Response.Status.NOT_FOUND).entity("Aucun événement trouvé.").build(); @@ -1011,7 +1018,7 @@ public class EventsResource { public Response getEventsByFriends( @PathParam("userId") UUID userId, @QueryParam("page") @DefaultValue("0") int page, - @QueryParam("size") @DefaultValue("20") int size) { + @QueryParam("size") @DefaultValue("10") int size) { LOG.info("[LOG] Récupération des événements des amis pour l'utilisateur ID : " + userId); try { diff --git a/src/main/java/com/lions/dev/resource/FileUploadResource.java b/src/main/java/com/lions/dev/resource/FileUploadResource.java index 6f3c8d8..6f58233 100644 --- a/src/main/java/com/lions/dev/resource/FileUploadResource.java +++ b/src/main/java/com/lions/dev/resource/FileUploadResource.java @@ -91,6 +91,7 @@ public class FileUploadResource { // Construire la réponse JSON Map response = new HashMap<>(); response.put("url", fileUrl); + response.put("fileName", finalFileName); // ✅ Ajout du fileName dans la réponse (DRY/WOU) if (thumbnailUrl != null) { response.put("thumbnailUrl", thumbnailUrl); } @@ -105,7 +106,7 @@ public class FileUploadResource { } } - LOG.infof("Upload réussi, URL: %s", fileUrl); + LOG.infof("Upload réussi, URL: %s, FileName: %s", fileUrl, finalFileName); return Response.status(Response.Status.CREATED) .entity(response) diff --git a/src/main/java/com/lions/dev/resource/NotificationResource.java b/src/main/java/com/lions/dev/resource/NotificationResource.java index afb7422..5f42e9f 100644 --- a/src/main/java/com/lions/dev/resource/NotificationResource.java +++ b/src/main/java/com/lions/dev/resource/NotificationResource.java @@ -80,7 +80,7 @@ public class NotificationResource { public Response getNotificationsByUserIdWithPagination( @PathParam("userId") UUID userId, @QueryParam("page") @DefaultValue("0") int page, - @QueryParam("size") @DefaultValue("20") int size) { + @QueryParam("size") @DefaultValue("10") int size) { LOG.info("[LOG] Récupération paginée des notifications pour l'utilisateur ID : " + userId); try { diff --git a/src/main/java/com/lions/dev/resource/SocialPostResource.java b/src/main/java/com/lions/dev/resource/SocialPostResource.java index 8c719a9..63f6ea1 100644 --- a/src/main/java/com/lions/dev/resource/SocialPostResource.java +++ b/src/main/java/com/lions/dev/resource/SocialPostResource.java @@ -11,6 +11,7 @@ import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; import org.eclipse.microprofile.openapi.annotations.Operation; @@ -49,7 +50,7 @@ public class SocialPostResource { description = "Retourne une liste paginée de tous les posts sociaux, triés par date de création décroissante") public Response getAllPosts( @QueryParam("page") @DefaultValue("0") int page, - @QueryParam("size") @DefaultValue("20") int size) { + @QueryParam("size") @DefaultValue("10") int size) { LOG.info("[LOG] Récupération de tous les posts (page: " + page + ", size: " + size + ")"); try { @@ -110,12 +111,12 @@ public class SocialPostResource { summary = "Créer un nouveau post", description = "Crée un nouveau post social et retourne ses détails") public Response createPost(@Valid SocialPostCreateRequestDTO requestDTO) { - LOG.info("[LOG] Création d'un nouveau post par l'utilisateur ID : " + requestDTO.getUserId()); + LOG.info("[LOG] Création d'un nouveau post par l'utilisateur ID : " + requestDTO.getCreatorId()); try { SocialPost post = socialPostService.createPost( requestDTO.getContent(), - requestDTO.getUserId(), + requestDTO.getCreatorId(), requestDTO.getImageUrl() ); SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post); @@ -244,11 +245,19 @@ public class SocialPostResource { @Operation( summary = "Liker un post", description = "Incrémente le compteur de likes d'un post") - public Response likePost(@PathParam("id") UUID postId) { - LOG.info("[LOG] Like du post ID : " + postId); + public Response likePost( + @PathParam("id") UUID postId, + @QueryParam("userId") UUID userId) { + LOG.info("[LOG] Like du post ID : " + postId + " par utilisateur : " + userId); + + if (userId == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"message\": \"userId est requis.\"}") + .build(); + } try { - SocialPost post = socialPostService.likePost(postId); + SocialPost post = socialPostService.likePost(postId, userId); SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post); return Response.ok(responseDTO).build(); } catch (IllegalArgumentException e) { @@ -273,14 +282,30 @@ public class SocialPostResource { @POST @Path("/{id}/comment") @Transactional + @Consumes(MediaType.APPLICATION_JSON) @Operation( summary = "Commenter un post", - description = "Incrémente le compteur de commentaires d'un post") - public Response addComment(@PathParam("id") UUID postId) { - LOG.info("[LOG] Ajout de commentaire au post ID : " + postId); + description = "Ajoute un commentaire à un post") + public Response addComment( + @PathParam("id") UUID postId, + @QueryParam("userId") UUID userId, + String requestBody) { + LOG.info("[LOG] Ajout de commentaire au post ID : " + postId + " par utilisateur : " + userId); + + if (userId == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"message\": \"userId est requis.\"}") + .build(); + } try { - SocialPost post = socialPostService.addComment(postId); + // Parser le body pour obtenir le contenu du commentaire + com.fasterxml.jackson.databind.ObjectMapper mapper = + new com.fasterxml.jackson.databind.ObjectMapper(); + Map body = mapper.readValue(requestBody, Map.class); + String commentContent = (String) body.getOrDefault("content", ""); + + SocialPost post = socialPostService.addComment(postId, userId, commentContent); SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post); return Response.ok(responseDTO).build(); } catch (IllegalArgumentException e) { @@ -308,11 +333,19 @@ public class SocialPostResource { @Operation( summary = "Partager un post", description = "Incrémente le compteur de partages d'un post") - public Response sharePost(@PathParam("id") UUID postId) { - LOG.info("[LOG] Partage du post ID : " + postId); + public Response sharePost( + @PathParam("id") UUID postId, + @QueryParam("userId") UUID userId) { + LOG.info("[LOG] Partage du post ID : " + postId + " par utilisateur : " + userId); + + if (userId == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"message\": \"userId est requis.\"}") + .build(); + } try { - SocialPost post = socialPostService.sharePost(postId); + SocialPost post = socialPostService.sharePost(postId, userId); SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post); return Response.ok(responseDTO).build(); } catch (IllegalArgumentException e) { @@ -373,7 +406,7 @@ public class SocialPostResource { public Response getPostsByFriends( @PathParam("userId") UUID userId, @QueryParam("page") @DefaultValue("0") int page, - @QueryParam("size") @DefaultValue("20") int size) { + @QueryParam("size") @DefaultValue("10") int size) { LOG.info("[LOG] Récupération des posts des amis pour l'utilisateur ID : " + userId); try { diff --git a/src/main/java/com/lions/dev/resource/StoryResource.java b/src/main/java/com/lions/dev/resource/StoryResource.java index 8750d34..de0d56d 100644 --- a/src/main/java/com/lions/dev/resource/StoryResource.java +++ b/src/main/java/com/lions/dev/resource/StoryResource.java @@ -45,12 +45,15 @@ public class StoryResource { @GET @Operation( summary = "Récupérer toutes les stories actives", - description = "Retourne une liste de toutes les stories actives (non expirées), triées par date de création décroissante") - public Response getAllActiveStories(@QueryParam("viewerId") UUID viewerId) { - LOG.info("[LOG] Récupération de toutes les stories actives"); + description = "Retourne une liste paginée de toutes les stories actives (non expirées), triées par date de création décroissante") + public Response getAllActiveStories( + @QueryParam("viewerId") UUID viewerId, + @QueryParam("page") @DefaultValue("0") int page, + @QueryParam("size") @DefaultValue("10") int size) { + LOG.info("[LOG] Récupération de toutes les stories actives (page: " + page + ", size: " + size + ")"); try { - List stories = storyService.getAllActiveStories(); + List stories = storyService.getAllActiveStories(page, size); List responseDTOs = stories.stream() .map(story -> viewerId != null ? new StoryResponseDTO(story, viewerId) : new StoryResponseDTO(story)) .collect(Collectors.toList()); @@ -109,12 +112,16 @@ public class StoryResource { @Path("/user/{userId}") @Operation( summary = "Récupérer les stories d'un utilisateur", - description = "Retourne toutes les stories actives d'un utilisateur spécifique") - public Response getStoriesByUserId(@PathParam("userId") UUID userId, @QueryParam("viewerId") UUID viewerId) { - LOG.info("[LOG] Récupération des stories pour l'utilisateur ID : " + userId); + description = "Retourne une liste paginée des stories actives d'un utilisateur spécifique") + public Response getStoriesByUserId( + @PathParam("userId") UUID userId, + @QueryParam("viewerId") UUID viewerId, + @QueryParam("page") @DefaultValue("0") int page, + @QueryParam("size") @DefaultValue("10") int size) { + LOG.info("[LOG] Récupération des stories pour l'utilisateur ID : " + userId + " (page: " + page + ", size: " + size + ")"); try { - List stories = storyService.getActiveStoriesByUserId(userId); + List stories = storyService.getActiveStoriesByUserId(userId, page, size); List responseDTOs = stories.stream() .map(story -> viewerId != null ? new StoryResponseDTO(story, viewerId) : new StoryResponseDTO(story)) .collect(Collectors.toList()); @@ -140,11 +147,11 @@ public class StoryResource { summary = "Créer une nouvelle story", description = "Crée une nouvelle story et retourne ses détails") public Response createStory(@Valid StoryCreateRequestDTO requestDTO) { - LOG.info("[LOG] Création d'une nouvelle story par l'utilisateur ID : " + requestDTO.getUserId()); + LOG.info("[LOG] Création d'une nouvelle story par l'utilisateur ID : " + requestDTO.getCreatorId()); try { Story story = storyService.createStory( - requestDTO.getUserId(), + requestDTO.getCreatorId(), requestDTO.getMediaType(), requestDTO.getMediaUrl(), requestDTO.getThumbnailUrl(), diff --git a/src/main/java/com/lions/dev/resource/UsersResource.java b/src/main/java/com/lions/dev/resource/UsersResource.java index ce567ad..877f66a 100644 --- a/src/main/java/com/lions/dev/resource/UsersResource.java +++ b/src/main/java/com/lions/dev/resource/UsersResource.java @@ -60,7 +60,7 @@ public class UsersResource { } /** - * Endpoint pour authentifier un utilisateur. + * Endpoint pour authentifier un utilisateur (v2.0). * * @param userAuthenticateRequestDTO Le DTO contenant les informations d'authentification. * @return Une réponse HTTP indiquant si l'authentification a réussi ou échoué. @@ -73,10 +73,18 @@ public class UsersResource { public Response authenticateUser(@Valid @NotNull UserAuthenticateRequestDTO userAuthenticateRequestDTO) { LOG.info("Tentative d'authentification pour l'utilisateur avec l'email : " + userAuthenticateRequestDTO.getEmail()); - Users user = userService.authenticateUser(userAuthenticateRequestDTO.getEmail(), userAuthenticateRequestDTO.getMotDePasse()); + // v2.0 - Utiliser getPassword() qui gère la compatibilité v1.0 et v2.0 + Users user = userService.authenticateUser(userAuthenticateRequestDTO.getEmail(), userAuthenticateRequestDTO.getPassword()); LOG.info("Authentification réussie pour l'utilisateur : " + user.getEmail()); - UserAuthenticateResponseDTO responseDTO = new UserAuthenticateResponseDTO(user.getId(), user.getPrenoms(), user.getNom(), user.getEmail(), user.getRole()); + // v2.0 - Utiliser les nouveaux noms de champs + UserAuthenticateResponseDTO responseDTO = new UserAuthenticateResponseDTO( + user.getId(), + user.getFirstName(), // v2.0 + user.getLastName(), // v2.0 + user.getEmail(), + user.getRole() + ); responseDTO.logResponseDetails(); return Response.ok(responseDTO).build(); } diff --git a/src/main/java/com/lions/dev/service/EstablishmentMediaService.java b/src/main/java/com/lions/dev/service/EstablishmentMediaService.java index e01995f..8baf0f3 100644 --- a/src/main/java/com/lions/dev/service/EstablishmentMediaService.java +++ b/src/main/java/com/lions/dev/service/EstablishmentMediaService.java @@ -52,10 +52,11 @@ public class EstablishmentMediaService { * @param mediaType Le type de média (PHOTO ou VIDEO) * @param uploadedByUserId L'ID de l'utilisateur qui upload * @param thumbnailUrl L'URL de la miniature (optionnel, pour les vidéos) + * @param displayOrder L'ordre d'affichage (optionnel, par défaut calculé automatiquement) * @return Le média créé */ @Transactional - public EstablishmentMedia uploadMedia(UUID establishmentId, String mediaUrl, MediaType mediaType, UUID uploadedByUserId, String thumbnailUrl) { + public EstablishmentMedia uploadMedia(UUID establishmentId, String mediaUrl, MediaType mediaType, UUID uploadedByUserId, String thumbnailUrl, Integer displayOrder) { LOG.info("Upload d'un média pour l'établissement : " + establishmentId); // Vérifier que l'établissement existe @@ -76,13 +77,18 @@ public class EstablishmentMediaService { EstablishmentMedia media = new EstablishmentMedia(establishment, mediaUrl, mediaType, uploadedBy); media.setThumbnailUrl(thumbnailUrl); - // Déterminer l'ordre d'affichage (dernier média = ordre le plus élevé) - List existingMedia = mediaRepository.findByEstablishmentId(establishmentId); - int maxOrder = existingMedia.stream() - .mapToInt(EstablishmentMedia::getDisplayOrder) - .max() - .orElse(-1); - media.setDisplayOrder(maxOrder + 1); + // Utiliser le displayOrder fourni, ou calculer automatiquement si non fourni + if (displayOrder != null) { + media.setDisplayOrder(displayOrder); + } else { + // Déterminer l'ordre d'affichage (dernier média = ordre le plus élevé) + List existingMedia = mediaRepository.findByEstablishmentId(establishmentId); + int maxOrder = existingMedia.stream() + .mapToInt(EstablishmentMedia::getDisplayOrder) + .max() + .orElse(-1); + media.setDisplayOrder(maxOrder + 1); + } mediaRepository.persist(media); LOG.info("Média uploadé avec succès : " + media.getId()); diff --git a/src/main/java/com/lions/dev/service/EstablishmentRatingService.java b/src/main/java/com/lions/dev/service/EstablishmentRatingService.java index b12120e..2c2d903 100644 --- a/src/main/java/com/lions/dev/service/EstablishmentRatingService.java +++ b/src/main/java/com/lions/dev/service/EstablishmentRatingService.java @@ -158,7 +158,8 @@ public class EstablishmentRatingService { Long totalRatings = ratingRepository.countByEstablishmentId(establishmentId); establishment.setAverageRating(averageRating); - establishment.setTotalRatingsCount(totalRatings.intValue()); + // v2.0 - Renommé depuis setTotalRatingsCount + establishment.setTotalReviewsCount(totalRatings.intValue()); establishmentRepository.persist(establishment); LOG.info("Statistiques mises à jour pour l'établissement " + establishmentId + " : moyenne = " + averageRating + ", total = " + totalRatings); diff --git a/src/main/java/com/lions/dev/service/EstablishmentService.java b/src/main/java/com/lions/dev/service/EstablishmentService.java index 653acc6..5410674 100644 --- a/src/main/java/com/lions/dev/service/EstablishmentService.java +++ b/src/main/java/com/lions/dev/service/EstablishmentService.java @@ -63,12 +63,22 @@ public class EstablishmentService { /** * Récupère tous les établissements. + * Charge également les médias pour chaque établissement pour inclure l'image principale. * * @return Une liste de tous les établissements. */ public List getAllEstablishments() { LOG.info("[LOG] Récupération de tous les établissements"); - List establishments = establishmentRepository.listAll(); + // Utiliser une requête avec fetch join pour charger les médias en une seule requête + List establishments = establishmentRepository.getEntityManager() + .createQuery( + "SELECT DISTINCT e FROM Establishment e " + + "LEFT JOIN FETCH e.medias m " + + "LEFT JOIN FETCH e.manager " + + "ORDER BY e.name ASC", + Establishment.class + ) + .getResultList(); LOG.info("[LOG] Nombre d'établissements trouvés : " + establishments.size()); return establishments; } @@ -105,7 +115,7 @@ public class EstablishmentService { throw new RuntimeException("Établissement non trouvé avec l'ID : " + id); } - // Mettre à jour les champs + // v2.0 - Mettre à jour les champs establishment.setName(updatedEstablishment.getName()); establishment.setType(updatedEstablishment.getType()); establishment.setAddress(updatedEstablishment.getAddress()); @@ -113,16 +123,19 @@ public class EstablishmentService { establishment.setPostalCode(updatedEstablishment.getPostalCode()); establishment.setDescription(updatedEstablishment.getDescription()); establishment.setPhoneNumber(updatedEstablishment.getPhoneNumber()); - establishment.setEmail(updatedEstablishment.getEmail()); establishment.setWebsite(updatedEstablishment.getWebsite()); - establishment.setImageUrl(updatedEstablishment.getImageUrl()); - establishment.setRating(updatedEstablishment.getRating()); establishment.setPriceRange(updatedEstablishment.getPriceRange()); - establishment.setCapacity(updatedEstablishment.getCapacity()); - establishment.setAmenities(updatedEstablishment.getAmenities()); - establishment.setOpeningHours(updatedEstablishment.getOpeningHours()); + + // v2.0 - Nouveau champ + if (updatedEstablishment.getVerificationStatus() != null) { + establishment.setVerificationStatus(updatedEstablishment.getVerificationStatus()); + } + establishment.setLatitude(updatedEstablishment.getLatitude()); establishment.setLongitude(updatedEstablishment.getLongitude()); + + // v2.0 - Champs supprimés (email, imageUrl, rating, capacity, amenities, openingHours) + // Ces champs ne sont plus mis à jour car ils sont dépréciés establishmentRepository.persist(establishment); LOG.info("[LOG] Établissement mis à jour avec succès : " + establishment.getName()); diff --git a/src/main/java/com/lions/dev/service/EventService.java b/src/main/java/com/lions/dev/service/EventService.java index 4b8cb9b..0c11e43 100644 --- a/src/main/java/com/lions/dev/service/EventService.java +++ b/src/main/java/com/lions/dev/service/EventService.java @@ -12,6 +12,10 @@ import com.lions.dev.exception.UserNotFoundException; import com.lions.dev.repository.EventsRepository; import com.lions.dev.repository.FriendshipRepository; import com.lions.dev.repository.UsersRepository; +import com.lions.dev.repository.EstablishmentRepository; +import com.lions.dev.dto.events.NotificationEvent; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import java.time.LocalDateTime; @@ -21,6 +25,10 @@ import java.util.stream.Collectors; /** * Service de gestion des événements. + * + * Version 2.0 - Architecture refactorée avec nommage standardisé. + * Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive). + * * Ce service contient la logique métier pour la création, récupération, mise à jour et suppression des événements. * Chaque méthode est loguée pour assurer une traçabilité exhaustive des actions effectuées. */ @@ -36,13 +44,20 @@ public class EventService { @Inject UsersRepository usersRepository; + @Inject + EstablishmentRepository establishmentRepository; // v2.0 + @Inject NotificationService notificationService; + @Inject + @Channel("notifications") + Emitter notificationEmitter; // v2.0 - Publie dans Kafka + private static final Logger logger = LoggerFactory.getLogger(EventService.class); /** - * Crée un nouvel événement dans le système. + * Crée un nouvel événement dans le système (v2.0). * * @param eventCreateRequestDTO Le DTO contenant les informations de l'événement à créer. * @param creator L'utilisateur créateur de l'événement. @@ -55,7 +70,18 @@ public class EventService { event.setDescription(eventCreateRequestDTO.getDescription()); event.setStartDate(eventCreateRequestDTO.getStartDate()); event.setEndDate(eventCreateRequestDTO.getEndDate()); - event.setLocation(eventCreateRequestDTO.getLocation()); + + // v2.0 - Établissement au lieu de location + if (eventCreateRequestDTO.getEstablishmentId() != null) { + com.lions.dev.entity.establishment.Establishment establishment = + establishmentRepository.findById(eventCreateRequestDTO.getEstablishmentId()); + if (establishment != null) { + event.setEstablishment(establishment); + } else { + logger.warn("[WARN] Établissement non trouvé avec l'ID : {}", eventCreateRequestDTO.getEstablishmentId()); + } + } + event.setCategory(eventCreateRequestDTO.getCategory()); event.setLink(eventCreateRequestDTO.getLink()); event.setImageUrl(eventCreateRequestDTO.getImageUrl()); @@ -63,6 +89,15 @@ public class EventService { event.setTags(eventCreateRequestDTO.getTags()); event.setOrganizer(eventCreateRequestDTO.getOrganizer()); event.setParticipationFee(eventCreateRequestDTO.getParticipationFee()); + + // v2.0 - Nouveaux champs + if (eventCreateRequestDTO.getIsPrivate() != null) { + event.setIsPrivate(eventCreateRequestDTO.getIsPrivate()); + } + if (eventCreateRequestDTO.getWaitlistEnabled() != null) { + event.setWaitlistEnabled(eventCreateRequestDTO.getWaitlistEnabled()); + } + event.setPrivacyRules(eventCreateRequestDTO.getPrivacyRules()); event.setTransportInfo(eventCreateRequestDTO.getTransportInfo()); event.setAccommodationInfo(eventCreateRequestDTO.getAccommodationInfo()); @@ -70,16 +105,17 @@ public class EventService { event.setParkingInfo(eventCreateRequestDTO.getParkingInfo()); event.setSecurityProtocol(eventCreateRequestDTO.getSecurityProtocol()); event.setCreator(creator); - event.setStatus("ouvert"); + event.setStatus("OPEN"); // v2.0 - Statut standardisé // Persiste l'événement dans la base de données eventsRepository.persist(event); logger.info("[logger] Événement créé avec succès : {}", event.getTitle()); - // Créer des notifications pour tous les amis + // Créer des notifications pour tous les amis (v2.0 - avec Kafka) try { List friendships = friendshipRepository.findFriendsByUser(creator, 0, Integer.MAX_VALUE); - String creatorName = creator.getPrenoms() + " " + creator.getNom(); + // v2.0 - Utiliser les nouveaux noms de champs + String creatorName = creator.getFirstName() + " " + creator.getLastName(); for (Friendship friendship : friendships) { Users friend = friendship.getUser().equals(creator) @@ -89,6 +125,7 @@ public class EventService { String notificationTitle = "Nouvel événement de " + creatorName; String notificationMessage = creatorName + " a créé un nouvel événement : " + event.getTitle(); + // Créer notification en base notificationService.createNotification( notificationTitle, notificationMessage, @@ -96,6 +133,28 @@ public class EventService { friend.getId(), event.getId() ); + + // TEMPS RÉEL: Publier dans Kafka (v2.0) + try { + java.util.Map notificationData = new java.util.HashMap<>(); + notificationData.put("eventId", event.getId().toString()); + notificationData.put("eventTitle", event.getTitle()); + notificationData.put("creatorId", creator.getId().toString()); + notificationData.put("creatorName", creatorName); + notificationData.put("startDate", event.getStartDate().toString()); + + NotificationEvent kafkaEvent = new NotificationEvent( + friend.getId().toString(), // userId destinataire + "event_created", + notificationData + ); + + 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()); + // Ne pas bloquer si Kafka échoue + } } logger.info("[logger] Notifications créées pour {} ami(s)", friendships.size()); } catch (Exception e) { @@ -209,12 +268,17 @@ public class EventService { throw new SecurityException("Vous n'avez pas les permissions pour modifier cet événement"); } - // Mettre à jour les détails de l'événement + // v2.0 - Mettre à jour les détails de l'événement existingEvent.setTitle(event.getTitle()); existingEvent.setDescription(event.getDescription()); existingEvent.setStartDate(event.getStartDate()); existingEvent.setEndDate(event.getEndDate()); - existingEvent.setLocation(event.getLocation()); + + // v2.0 - Établissement au lieu de location + if (event.getEstablishment() != null) { + existingEvent.setEstablishment(event.getEstablishment()); + } + existingEvent.setCategory(event.getCategory()); existingEvent.setLink(event.getLink()); existingEvent.setImageUrl(event.getImageUrl()); @@ -222,6 +286,15 @@ public class EventService { existingEvent.setTags(event.getTags()); existingEvent.setOrganizer(event.getOrganizer()); existingEvent.setParticipationFee(event.getParticipationFee()); + + // v2.0 - Nouveaux champs + if (event.getIsPrivate() != null) { + existingEvent.setIsPrivate(event.getIsPrivate()); + } + if (event.getWaitlistEnabled() != null) { + existingEvent.setWaitlistEnabled(event.getWaitlistEnabled()); + } + existingEvent.setPrivacyRules(event.getPrivacyRules()); existingEvent.setTransportInfo(event.getTransportInfo()); existingEvent.setAccommodationInfo(event.getAccommodationInfo()); @@ -269,8 +342,22 @@ public class EventService { * @return La liste des événements auxquels l'utilisateur participe. */ public List findEventsByUser(Users user) { - logger.info("[logger] Récupération des événements pour l'utilisateur avec l'ID : {}", user.getId()); - List events = eventsRepository.find("participants", user).list(); + return findEventsByUser(user, 0, Integer.MAX_VALUE); + } + + /** + * Récupère les événements auxquels un utilisateur participe avec pagination. + * + * @param user L'utilisateur pour lequel récupérer les événements. + * @param page Le numéro de la page (0-indexé) + * @param size La taille de la page + * @return La liste paginée des événements auxquels l'utilisateur participe. + */ + public List findEventsByUser(Users user, int page, int size) { + logger.info("[logger] Récupération des événements pour l'utilisateur avec l'ID : {} (page: {}, size: {})", user.getId(), page, size); + List events = eventsRepository.find("participants", user) + .page(page, size) + .list(); logger.info("[logger] Nombre d'événements pour l'utilisateur avec l'ID {} : {}", user.getId(), events.size()); return events; } @@ -372,7 +459,11 @@ public class EventService { */ public List recommendEventsForUser(Users user) { logger.info("[logger] Recommandation d'événements pour l'utilisateur : " + user.getEmail()); - List events = eventsRepository.find("category", user.getPreferredCategory()).list(); + // v2.0 - Utiliser preferences pour preferredCategory + String preferredCategory = user.getPreferredCategory(); // Méthode qui utilise preferences JSONB + List events = preferredCategory != null + ? eventsRepository.find("category", preferredCategory).list() + : eventsRepository.findAll().list(); logger.info("[logger] Nombre d'événements recommandés pour l'utilisateur : " + 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 9265e58..fda3ed4 100644 --- a/src/main/java/com/lions/dev/service/FriendshipService.java +++ b/src/main/java/com/lions/dev/service/FriendshipService.java @@ -13,7 +13,9 @@ import com.lions.dev.exception.FriendshipNotFoundException; import com.lions.dev.exception.UserNotFoundException; import com.lions.dev.repository.FriendshipRepository; import com.lions.dev.repository.UsersRepository; -import com.lions.dev.websocket.NotificationWebSocket; +import com.lions.dev.dto.events.NotificationEvent; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; @@ -40,6 +42,10 @@ public class FriendshipService { @Inject NotificationService notificationService; // Injecte le service de notifications + @Inject + @Channel("notifications") + Emitter notificationEmitter; // v2.0 - Publie dans Kafka + private static final Logger logger = Logger.getLogger(FriendshipService.class); /** @@ -79,24 +85,26 @@ public class FriendshipService { Friendship friendship = new Friendship(user, friend, FriendshipStatus.PENDING); friendshipRepository.persist(friendship); - // TEMPS RÉEL: Notifier le destinataire via WebSocket + // TEMPS RÉEL: Publier dans Kafka (v2.0) try { Map notificationData = new HashMap<>(); notificationData.put("requestId", friendship.getId().toString()); notificationData.put("senderId", user.getId().toString()); - notificationData.put("senderName", user.getPrenoms() + " " + user.getNom()); + // v2.0 - Utiliser les nouveaux noms de champs + notificationData.put("senderName", user.getFirstName() + " " + user.getLastName()); notificationData.put("senderProfileImage", user.getProfileImageUrl() != null ? user.getProfileImageUrl() : ""); - NotificationWebSocket.sendNotificationToUser( - friend.getId(), + NotificationEvent event = new NotificationEvent( + friend.getId().toString(), // userId destinataire (clé Kafka) "friend_request_received", notificationData ); - logger.info("[LOG] Notification WebSocket envoyée au destinataire : " + friend.getId()); + notificationEmitter.send(event); + logger.info("[LOG] Événement friend_request_received publié dans Kafka pour : " + friend.getId()); } catch (Exception e) { - logger.error("[ERROR] Erreur lors de l'envoi de la notification WebSocket : " + e.getMessage(), e); - // Ne pas bloquer la demande d'amitié si le WebSocket échoue + logger.error("[ERROR] Erreur lors de la publication dans Kafka : " + e.getMessage(), e); + // Ne pas bloquer la demande d'amitié si Kafka échoue } logger.info("[LOG] Demande d'amitié envoyée avec succès."); @@ -139,11 +147,12 @@ public class FriendshipService { // Log de succès logger.info(String.format("[LOG] Demande d'amitié acceptée avec succès pour l'ID: %s", friendshipId)); // Correctement formaté - // TEMPS RÉEL: Notifier l'émetteur de la demande via WebSocket + // TEMPS RÉEL: Publier dans Kafka (v2.0) try { Users user = friendship.getUser(); Users friend = friendship.getFriend(); - String friendName = friend.getPrenoms() + " " + friend.getNom(); + // v2.0 - Utiliser les nouveaux noms de champs + String friendName = friend.getFirstName() + " " + friend.getLastName(); Map notificationData = new HashMap<>(); notificationData.put("acceptedBy", friendName); @@ -151,24 +160,26 @@ public class FriendshipService { notificationData.put("accepterId", friend.getId().toString()); notificationData.put("accepterProfileImage", friend.getProfileImageUrl() != null ? friend.getProfileImageUrl() : ""); - NotificationWebSocket.sendNotificationToUser( - user.getId(), + NotificationEvent event = new NotificationEvent( + user.getId().toString(), // userId émetteur (destinataire de la notification) "friend_request_accepted", notificationData ); - logger.info("[LOG] Notification WebSocket d'acceptation envoyée à : " + user.getId()); + notificationEmitter.send(event); + logger.info("[LOG] Événement friend_request_accepted publié dans Kafka pour : " + user.getId()); } catch (Exception e) { - logger.error("[ERROR] Erreur lors de l'envoi de la notification WebSocket d'acceptation : " + e.getMessage(), e); - // Ne pas bloquer l'acceptation si le WebSocket échoue + logger.error("[ERROR] Erreur lors de la publication dans Kafka : " + e.getMessage(), e); + // Ne pas bloquer l'acceptation si Kafka échoue } // Créer des notifications pour les deux utilisateurs try { Users user = friendship.getUser(); Users friend = friendship.getFriend(); - String userName = user.getPrenoms() + " " + user.getNom(); - String friendName = friend.getPrenoms() + " " + friend.getNom(); + // v2.0 - Utiliser les nouveaux noms de champs + String userName = user.getFirstName() + " " + user.getLastName(); + String friendName = friend.getFirstName() + " " + friend.getLastName(); // Notification pour l'utilisateur qui a envoyé la demande notificationService.createNotification( @@ -212,7 +223,7 @@ public class FriendshipService { friendship.setStatus(FriendshipStatus.REJECTED); friendshipRepository.persist(friendship); - // TEMPS RÉEL: Notifier l'émetteur de la demande via WebSocket (optionnel selon UX) + // TEMPS RÉEL: Publier dans Kafka (v2.0) try { Users user = friendship.getUser(); @@ -220,16 +231,17 @@ public class FriendshipService { notificationData.put("friendshipId", friendshipId.toString()); notificationData.put("rejectedAt", System.currentTimeMillis()); - NotificationWebSocket.sendNotificationToUser( - user.getId(), + NotificationEvent event = new NotificationEvent( + user.getId().toString(), // userId émetteur (destinataire de la notification) "friend_request_rejected", notificationData ); - logger.info("[LOG] Notification WebSocket de rejet envoyée à : " + user.getId()); + notificationEmitter.send(event); + logger.info("[LOG] Événement friend_request_rejected publié dans Kafka pour : " + user.getId()); } catch (Exception e) { - logger.error("[ERROR] Erreur lors de l'envoi de la notification WebSocket de rejet : " + e.getMessage(), e); - // Ne pas bloquer le rejet si le WebSocket échoue + logger.error("[ERROR] Erreur lors de la publication dans Kafka : " + e.getMessage(), e); + // Ne pas bloquer le rejet si Kafka échoue } logger.info("[LOG] Demande d'amitié rejetée."); @@ -288,11 +300,12 @@ public class FriendshipService { + user.getId() + ", ami ID : " + friend.getId()); // Création du DTO de réponse à partir des informations de l'ami et de la relation + // v2.0 - Utiliser les nouveaux noms de champs return new FriendshipReadFriendDetailsResponseDTO( user.getId(), // ID de l'utilisateur friend.getId(), // ID de l'ami - friend.getNom(), // Nom de l'ami - friend.getPrenoms(), + friend.getLastName(), // Nom de famille de l'ami (v2.0) + friend.getFirstName(), // Prénom de l'ami (v2.0) friend.getEmail(), // Email de l'ami friend.getProfileImageUrl(), // URL de l'image de profil de l'ami friendship.getStatus(), // Statut de la relation @@ -325,11 +338,12 @@ public class FriendshipService { return friendships.stream() .map(friendship -> { Users friend = friendship.getUser().equals(user) ? friendship.getFriend() : friendship.getUser(); + // v2.0 - Utiliser les nouveaux noms de champs return new FriendshipReadFriendDetailsResponseDTO( user.getId(), friend.getId(), - friend.getNom(), - friend.getPrenoms(), + friend.getLastName(), // v2.0 + friend.getFirstName(), // v2.0 friend.getEmail(), friend.getProfileImageUrl(), friendship.getStatus(), diff --git a/src/main/java/com/lions/dev/service/MessageService.java b/src/main/java/com/lions/dev/service/MessageService.java index 88ae8bf..6a9347e 100644 --- a/src/main/java/com/lions/dev/service/MessageService.java +++ b/src/main/java/com/lions/dev/service/MessageService.java @@ -7,7 +7,10 @@ import com.lions.dev.exception.UserNotFoundException; import com.lions.dev.repository.ConversationRepository; import com.lions.dev.repository.MessageRepository; import com.lions.dev.repository.UsersRepository; -import com.lions.dev.websocket.ChatWebSocket; +import com.lions.dev.dto.events.ChatMessageEvent; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import io.smallrye.reactive.messaging.kafka.api.OutgoingKafkaRecordMetadata; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; @@ -40,6 +43,10 @@ public class MessageService { @Inject NotificationService notificationService; + @Inject + @Channel("chat-messages") + Emitter chatMessageEmitter; // v2.0 - Publie dans Kafka + /** * Envoie un message d'un utilisateur à un autre. * @@ -92,7 +99,8 @@ public class MessageService { // Créer une notification pour le destinataire try { - String senderName = sender.getPrenoms() + " " + sender.getNom(); + // v2.0 - Utiliser les nouveaux noms de champs + String senderName = sender.getFirstName() + " " + sender.getLastName(); String notificationMessage = content.length() > 50 ? content.substring(0, 50) + "..." : content; @@ -109,43 +117,78 @@ public class MessageService { System.out.println("[ERROR] Erreur lors de la création de la notification : " + e.getMessage()); } - // TEMPS RÉEL : Envoyer le message via WebSocket au destinataire + // TEMPS RÉEL : Publier dans Kafka (v2.0) try { - Map messageData = new HashMap<>(); - messageData.put("id", message.getId().toString()); - messageData.put("conversationId", conversation.getId().toString()); - messageData.put("senderId", senderId.toString()); - messageData.put("senderFirstName", sender.getPrenoms()); - messageData.put("senderLastName", sender.getNom()); - messageData.put("senderProfileImageUrl", sender.getProfileImageUrl() != null ? sender.getProfileImageUrl() : ""); - messageData.put("content", content); - messageData.put("timestamp", message.getCreatedAt().toString()); - messageData.put("isRead", message.isRead()); - messageData.put("attachmentUrl", mediaUrl != null ? mediaUrl : ""); - messageData.put("attachmentType", messageType != null ? messageType : "text"); + // Créer l'événement pour Kafka + ChatMessageEvent event = new ChatMessageEvent(); + event.setConversationId(conversation.getId().toString()); + event.setSenderId(senderId.toString()); + event.setRecipientId(recipientId.toString()); + event.setContent(content); + event.setMessageId(message.getId().toString()); + event.setEventType("message"); + event.setTimestamp(System.currentTimeMillis()); + + // Métadonnées additionnelles + Map eventMetadata = new HashMap<>(); + eventMetadata.put("senderFirstName", sender.getFirstName()); + eventMetadata.put("senderLastName", sender.getLastName()); + eventMetadata.put("senderProfileImageUrl", sender.getProfileImageUrl() != null ? sender.getProfileImageUrl() : ""); + eventMetadata.put("isRead", message.isRead()); + eventMetadata.put("attachmentUrl", mediaUrl != null ? mediaUrl : ""); + eventMetadata.put("attachmentType", messageType != null ? messageType : "text"); + event.setMetadata(eventMetadata); - // Envoyer au destinataire via ChatWebSocket - ChatWebSocket.sendMessageToUser(recipientId, messageData); + // Publier dans Kafka (utiliser conversationId comme clé pour garantir l'ordre) + OutgoingKafkaRecordMetadata kafkaMetadata = + OutgoingKafkaRecordMetadata.builder() + .withKey(conversation.getId().toString()) + .build(); + + chatMessageEmitter.send(org.eclipse.microprofile.reactive.messaging.Message.of( + event, + () -> java.util.concurrent.CompletableFuture.completedFuture(null), // ack + throwable -> { + System.out.println("[ERROR] Erreur envoi Kafka: " + throwable.getMessage()); + return java.util.concurrent.CompletableFuture.completedFuture(null); // nack + } + ).addMetadata(kafkaMetadata)); - System.out.println("[LOG] Message envoyé via WebSocket au destinataire : " + recipientId); + System.out.println("[LOG] Message publié dans Kafka: " + message.getId()); - // Envoyer confirmation de délivrance à l'expéditeur + // Envoyer confirmation de délivrance à l'expéditeur (via Kafka aussi) try { - Map deliveryConfirmation = new HashMap<>(); - deliveryConfirmation.put("messageId", message.getId().toString()); - deliveryConfirmation.put("isDelivered", true); - deliveryConfirmation.put("timestamp", System.currentTimeMillis()); + ChatMessageEvent deliveryEvent = new ChatMessageEvent(); + deliveryEvent.setConversationId(conversation.getId().toString()); + deliveryEvent.setSenderId(recipientId.toString()); // Le destinataire confirme + deliveryEvent.setRecipientId(senderId.toString()); // À l'expéditeur + deliveryEvent.setMessageId(message.getId().toString()); + deliveryEvent.setEventType("delivery_confirmation"); + deliveryEvent.setTimestamp(System.currentTimeMillis()); + + Map deliveryEventMetadata = new HashMap<>(); + deliveryEventMetadata.put("isDelivered", true); + deliveryEvent.setMetadata(deliveryEventMetadata); - ChatWebSocket.sendDeliveryConfirmation(senderId, deliveryConfirmation); + OutgoingKafkaRecordMetadata deliveryKafkaMetadata = + OutgoingKafkaRecordMetadata.builder() + .withKey(conversation.getId().toString()) + .build(); + + chatMessageEmitter.send(org.eclipse.microprofile.reactive.messaging.Message.of( + deliveryEvent, + () -> java.util.concurrent.CompletableFuture.completedFuture(null), + throwable -> java.util.concurrent.CompletableFuture.completedFuture(null) + ).addMetadata(deliveryKafkaMetadata)); - System.out.println("[LOG] Confirmation de délivrance envoyée à l'expéditeur : " + senderId); + System.out.println("[LOG] Confirmation de délivrance publiée dans Kafka pour : " + senderId); } catch (Exception deliveryEx) { - System.out.println("[ERROR] Erreur envoi confirmation délivrance : " + deliveryEx.getMessage()); + System.out.println("[ERROR] Erreur publication confirmation délivrance : " + deliveryEx.getMessage()); // Ne pas bloquer si la confirmation échoue } } catch (Exception e) { - System.out.println("[ERROR] Erreur lors de l'envoi du message via WebSocket : " + e.getMessage()); - // Ne pas bloquer l'envoi du message si WebSocket échoue + System.out.println("[ERROR] Erreur lors de la publication dans Kafka : " + e.getMessage()); + // Ne pas bloquer l'envoi du message si Kafka échoue } return message; @@ -240,18 +283,36 @@ public class MessageService { ? conversation.getUser2().getId() : conversation.getUser1().getId(); - Map readConfirmation = new HashMap<>(); - readConfirmation.put("messageId", message.getId().toString()); - readConfirmation.put("userId", recipientId.toString()); - readConfirmation.put("timestamp", java.time.LocalDateTime.now().toString()); + // Publier confirmation de lecture dans Kafka (v2.0) + try { + ChatMessageEvent readEvent = new ChatMessageEvent(); + readEvent.setConversationId(conversation.getId().toString()); + readEvent.setSenderId(recipientId.toString()); // Celui qui a lu + readEvent.setRecipientId(message.getSender().getId().toString()); // L'expéditeur + readEvent.setMessageId(message.getId().toString()); + readEvent.setEventType("read_confirmation"); + readEvent.setTimestamp(System.currentTimeMillis()); + + Map readEventMetadata = new HashMap<>(); + readEventMetadata.put("readBy", recipientId.toString()); + readEventMetadata.put("readAt", System.currentTimeMillis()); + readEvent.setMetadata(readEventMetadata); - // Envoyer via ChatWebSocket avec type "read" - com.lions.dev.websocket.ChatWebSocket.sendReadConfirmation( - message.getSender().getId(), - readConfirmation - ); + OutgoingKafkaRecordMetadata readKafkaMetadata = + OutgoingKafkaRecordMetadata.builder() + .withKey(conversation.getId().toString()) + .build(); + + chatMessageEmitter.send(org.eclipse.microprofile.reactive.messaging.Message.of( + readEvent, + () -> java.util.concurrent.CompletableFuture.completedFuture(null), + throwable -> java.util.concurrent.CompletableFuture.completedFuture(null) + ).addMetadata(readKafkaMetadata)); - System.out.println("[LOG] Confirmation de lecture envoyée à l'expéditeur : " + message.getSender().getId()); + System.out.println("[LOG] Confirmation de lecture publiée dans Kafka pour : " + message.getSender().getId()); + } catch (Exception e) { + System.out.println("[ERROR] Erreur publication confirmation lecture : " + e.getMessage()); + } } catch (Exception e) { System.out.println("[ERROR] Erreur envoi confirmation lecture : " + e.getMessage()); } diff --git a/src/main/java/com/lions/dev/service/PresenceService.java b/src/main/java/com/lions/dev/service/PresenceService.java index 94a84a3..53969e9 100644 --- a/src/main/java/com/lions/dev/service/PresenceService.java +++ b/src/main/java/com/lions/dev/service/PresenceService.java @@ -2,7 +2,7 @@ package com.lions.dev.service; import com.lions.dev.entity.users.Users; import com.lions.dev.repository.UsersRepository; -import com.lions.dev.websocket.NotificationWebSocket; +import com.lions.dev.websocket.NotificationWebSocketNext; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.control.ActivateRequestContext; import jakarta.inject.Inject; @@ -95,8 +95,8 @@ public class PresenceService { presenceData.put("lastSeen", lastSeen != null ? lastSeen.toString() : null); presenceData.put("timestamp", System.currentTimeMillis()); - // Envoyer via NotificationWebSocket - NotificationWebSocket.broadcastPresenceUpdate(presenceData); + // Envoyer via NotificationWebSocketNext (v2.0) + NotificationWebSocketNext.broadcastPresenceUpdate(presenceData); System.out.println("[PRESENCE] Broadcast de la présence de " + userId + " : " + isOnline); } catch (Exception e) { diff --git a/src/main/java/com/lions/dev/service/SocialPostService.java b/src/main/java/com/lions/dev/service/SocialPostService.java index f788ba7..7688855 100644 --- a/src/main/java/com/lions/dev/service/SocialPostService.java +++ b/src/main/java/com/lions/dev/service/SocialPostService.java @@ -8,10 +8,15 @@ import com.lions.dev.exception.UserNotFoundException; import com.lions.dev.repository.FriendshipRepository; import com.lions.dev.repository.SocialPostRepository; import com.lions.dev.repository.UsersRepository; +import com.lions.dev.dto.events.ReactionEvent; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; @@ -38,6 +43,10 @@ public class SocialPostService { @Inject NotificationService notificationService; + @Inject + @Channel("reactions") + Emitter reactionEmitter; // v2.0 - Publie dans Kafka + /** * Récupère tous les posts avec pagination. * @@ -99,7 +108,8 @@ public class SocialPostService { // Créer des notifications pour tous les amis try { List friendships = friendshipRepository.findFriendsByUser(user, 0, Integer.MAX_VALUE); - String userName = user.getPrenoms() + " " + user.getNom(); + // v2.0 - Utiliser les nouveaux noms de champs + String userName = user.getFirstName() + " " + user.getLastName(); for (Friendship friendship : friendships) { Users friend = friendship.getUser().equals(user) @@ -208,11 +218,12 @@ public class SocialPostService { * Like un post (incrémente le compteur de likes). * * @param postId L'ID du post + * @param userId L'ID de l'utilisateur qui like (v2.0) * @return Le post mis à jour */ @Transactional - public SocialPost likePost(UUID postId) { - System.out.println("[LOG] Like du post ID : " + postId); + public SocialPost likePost(UUID postId, UUID userId) { + System.out.println("[LOG] Like du post ID : " + postId + " par utilisateur : " + userId); SocialPost post = socialPostRepository.findById(postId); if (post == null) { @@ -222,6 +233,31 @@ public class SocialPostService { post.incrementLikes(); socialPostRepository.persist(post); + + // TEMPS RÉEL: Publier dans Kafka (v2.0) + try { + Map reactionData = new HashMap<>(); + reactionData.put("ownerId", post.getUser().getId().toString()); // Propriétaire du post + reactionData.put("likesCount", post.getLikesCount()); + reactionData.put("postTitle", post.getContent().length() > 50 + ? post.getContent().substring(0, 50) + "..." + : post.getContent()); + + ReactionEvent event = new ReactionEvent( + postId.toString(), // targetId + "post", // targetType + userId.toString(), // userId qui réagit + "like", // reactionType + reactionData + ); + + reactionEmitter.send(event); + System.out.println("[LOG] Réaction like publiée dans Kafka pour post: " + postId); + } catch (Exception e) { + System.out.println("[ERROR] Erreur publication Kafka: " + e.getMessage()); + // Ne pas bloquer le like si Kafka échoue + } + return post; } @@ -229,11 +265,13 @@ public class SocialPostService { * Ajoute un commentaire à un post (incrémente le compteur de commentaires). * * @param postId L'ID du post + * @param userId L'ID de l'utilisateur qui commente (v2.0) + * @param commentContent Le contenu du commentaire (v2.0) * @return Le post mis à jour */ @Transactional - public SocialPost addComment(UUID postId) { - System.out.println("[LOG] Ajout de commentaire au post ID : " + postId); + public SocialPost addComment(UUID postId, UUID userId, String commentContent) { + System.out.println("[LOG] Ajout de commentaire au post ID : " + postId + " par utilisateur : " + userId); SocialPost post = socialPostRepository.findById(postId); if (post == null) { @@ -243,6 +281,35 @@ public class SocialPostService { post.incrementComments(); socialPostRepository.persist(post); + + // TEMPS RÉEL: Publier dans Kafka (v2.0) + try { + Users commenter = usersRepository.findById(userId); + Map reactionData = new HashMap<>(); + reactionData.put("ownerId", post.getUser().getId().toString()); // Propriétaire du post + reactionData.put("commentsCount", post.getCommentsCount()); + reactionData.put("commentContent", commentContent != null && commentContent.length() > 100 + ? commentContent.substring(0, 100) + "..." + : commentContent); + reactionData.put("commenterName", commenter != null + ? commenter.getFirstName() + " " + commenter.getLastName() + : "Utilisateur"); + + ReactionEvent event = new ReactionEvent( + postId.toString(), // targetId + "post", // targetType + userId.toString(), // userId qui commente + "comment", // reactionType + reactionData + ); + + reactionEmitter.send(event); + System.out.println("[LOG] Réaction comment publiée dans Kafka pour post: " + postId); + } catch (Exception e) { + System.out.println("[ERROR] Erreur publication Kafka: " + e.getMessage()); + // Ne pas bloquer le commentaire si Kafka échoue + } + return post; } @@ -250,11 +317,12 @@ public class SocialPostService { * Partage un post (incrémente le compteur de partages). * * @param postId L'ID du post + * @param userId L'ID de l'utilisateur qui partage (v2.0) * @return Le post mis à jour */ @Transactional - public SocialPost sharePost(UUID postId) { - System.out.println("[LOG] Partage du post ID : " + postId); + public SocialPost sharePost(UUID postId, UUID userId) { + System.out.println("[LOG] Partage du post ID : " + postId + " par utilisateur : " + userId); SocialPost post = socialPostRepository.findById(postId); if (post == null) { @@ -264,6 +332,28 @@ public class SocialPostService { post.incrementShares(); socialPostRepository.persist(post); + + // TEMPS RÉEL: Publier dans Kafka (v2.0) + try { + Map reactionData = new HashMap<>(); + reactionData.put("ownerId", post.getUser().getId().toString()); // Propriétaire du post + reactionData.put("sharesCount", post.getSharesCount()); + + ReactionEvent event = new ReactionEvent( + postId.toString(), // targetId + "post", // targetType + userId.toString(), // userId qui partage + "share", // reactionType + reactionData + ); + + reactionEmitter.send(event); + System.out.println("[LOG] Réaction share publiée dans Kafka pour post: " + postId); + } catch (Exception e) { + System.out.println("[ERROR] Erreur publication Kafka: " + e.getMessage()); + // Ne pas bloquer le partage si Kafka échoue + } + return post; } diff --git a/src/main/java/com/lions/dev/service/StoryService.java b/src/main/java/com/lions/dev/service/StoryService.java index 48f2515..d70fdc6 100644 --- a/src/main/java/com/lions/dev/service/StoryService.java +++ b/src/main/java/com/lions/dev/service/StoryService.java @@ -35,8 +35,19 @@ public class StoryService { * @return Liste des stories actives */ public List getAllActiveStories() { - System.out.println("[LOG] Récupération de toutes les stories actives"); - return storyRepository.findAllActive(); + return getAllActiveStories(0, Integer.MAX_VALUE); + } + + /** + * Récupère toutes les stories actives avec pagination. + * + * @param page Le numéro de la page (0-indexé) + * @param size La taille de la page + * @return Liste paginée des stories actives + */ + public List getAllActiveStories(int page, int size) { + System.out.println("[LOG] Récupération de toutes les stories actives (page: " + page + ", size: " + size + ")"); + return storyRepository.findAllActive(page, size); } /** @@ -47,7 +58,20 @@ public class StoryService { * @throws UserNotFoundException Si l'utilisateur n'existe pas */ public List getActiveStoriesByUserId(UUID userId) { - System.out.println("[LOG] Récupération des stories actives pour l'utilisateur ID : " + userId); + return getActiveStoriesByUserId(userId, 0, Integer.MAX_VALUE); + } + + /** + * Récupère toutes les stories actives d'un utilisateur avec pagination. + * + * @param userId L'ID de l'utilisateur + * @param page Le numéro de la page (0-indexé) + * @param size La taille de la page + * @return Liste paginée des stories actives de l'utilisateur + * @throws UserNotFoundException Si l'utilisateur n'existe pas + */ + public List getActiveStoriesByUserId(UUID userId, int page, int size) { + System.out.println("[LOG] Récupération des stories actives pour l'utilisateur ID : " + userId + " (page: " + page + ", size: " + size + ")"); Users user = usersRepository.findById(userId); if (user == null) { @@ -55,7 +79,7 @@ public class StoryService { throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + userId); } - return storyRepository.findActiveByUserId(userId); + return storyRepository.findActiveByUserId(userId, page, size); } /** diff --git a/src/main/java/com/lions/dev/service/UsersService.java b/src/main/java/com/lions/dev/service/UsersService.java index ac6c55b..8999bc9 100644 --- a/src/main/java/com/lions/dev/service/UsersService.java +++ b/src/main/java/com/lions/dev/service/UsersService.java @@ -23,7 +23,7 @@ public class UsersService { UsersRepository usersRepository; /** - * Crée un nouvel utilisateur dans le système. + * Crée un nouvel utilisateur dans le système (v2.0). * * @param userCreateRequestDTO Le DTO contenant les informations de l'utilisateur à créer. * @return L'utilisateur créé. @@ -36,10 +36,22 @@ public class UsersService { } Users user = new Users(); - user.setNom(userCreateRequestDTO.getNom()); - user.setPrenoms(userCreateRequestDTO.getPrenoms()); + // v2.0 - Utiliser les nouveaux noms de champs avec compatibilité v1.0 + user.setFirstName(userCreateRequestDTO.getFirstName()); // v2.0 + user.setLastName(userCreateRequestDTO.getLastName()); // v2.0 user.setEmail(userCreateRequestDTO.getEmail()); - user.setMotDePasse(userCreateRequestDTO.getMotDePasse()); // Hachage automatique + user.setPassword(userCreateRequestDTO.getPassword()); // v2.0 - Hachage automatique + + // v2.0 - Nouveaux champs + if (userCreateRequestDTO.getBio() != null) { + user.setBio(userCreateRequestDTO.getBio()); + } + if (userCreateRequestDTO.getLoyaltyPoints() != null) { + user.setLoyaltyPoints(userCreateRequestDTO.getLoyaltyPoints()); + } + if (userCreateRequestDTO.getPreferences() != null) { + user.setPreferences(userCreateRequestDTO.getPreferences()); + } // Logique pour l'image et le rôle par défaut. user.setProfileImageUrl( @@ -59,7 +71,7 @@ public class UsersService { } /** - * Met à jour un utilisateur existant dans le système. + * Met à jour un utilisateur existant dans le système (v2.0). * * @param id L'ID de l'utilisateur à mettre à jour. * @param userCreateRequestDTO Les nouvelles informations de l'utilisateur. @@ -74,13 +86,35 @@ public class UsersService { throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + id); } - // Mettre à jour les champs de l'utilisateur existant - existingUser.setNom(userCreateRequestDTO.getNom()); - existingUser.setPrenoms(userCreateRequestDTO.getPrenoms()); - existingUser.setEmail(userCreateRequestDTO.getEmail()); - existingUser.setMotDePasse(userCreateRequestDTO.getMotDePasse()); // Hachage automatique si nécessaire - existingUser.setRole(userCreateRequestDTO.getRole()); - existingUser.setProfileImageUrl(userCreateRequestDTO.getProfileImageUrl()); + // v2.0 - Mettre à jour les champs avec les nouveaux noms + if (userCreateRequestDTO.getFirstName() != null) { + existingUser.setFirstName(userCreateRequestDTO.getFirstName()); + } + if (userCreateRequestDTO.getLastName() != null) { + existingUser.setLastName(userCreateRequestDTO.getLastName()); + } + if (userCreateRequestDTO.getEmail() != null) { + existingUser.setEmail(userCreateRequestDTO.getEmail()); + } + if (userCreateRequestDTO.getPassword() != null) { + existingUser.setPassword(userCreateRequestDTO.getPassword()); // v2.0 - Hachage automatique + } + if (userCreateRequestDTO.getRole() != null) { + existingUser.setRole(userCreateRequestDTO.getRole()); + } + if (userCreateRequestDTO.getProfileImageUrl() != null) { + existingUser.setProfileImageUrl(userCreateRequestDTO.getProfileImageUrl()); + } + // v2.0 - Nouveaux champs + if (userCreateRequestDTO.getBio() != null) { + existingUser.setBio(userCreateRequestDTO.getBio()); + } + if (userCreateRequestDTO.getLoyaltyPoints() != null) { + existingUser.setLoyaltyPoints(userCreateRequestDTO.getLoyaltyPoints()); + } + if (userCreateRequestDTO.getPreferences() != null) { + existingUser.setPreferences(userCreateRequestDTO.getPreferences()); + } usersRepository.persist(existingUser); System.out.println("[LOG] Utilisateur mis à jour avec succès : " + existingUser.getEmail()); @@ -122,16 +156,16 @@ public class UsersService { } /** - * Authentifie un utilisateur avec son email et son mot de passe. + * Authentifie un utilisateur avec son email et son mot de passe (v2.0). * * @param email L'email de l'utilisateur. - * @param motDePasse Le mot de passe de l'utilisateur. + * @param password Le mot de passe de l'utilisateur (v2.0). * @return L'utilisateur authentifié. * @throws UserNotFoundException Si l'utilisateur n'est pas trouvé ou si le mot de passe est incorrect. */ - public Users authenticateUser(String email, String motDePasse) { + public Users authenticateUser(String email, String password) { Optional userOptional = usersRepository.findByEmail(email); - if (userOptional.isEmpty() || !userOptional.get().verifierMotDePasse(motDePasse)) { + if (userOptional.isEmpty() || !userOptional.get().verifyPassword(password)) { // v2.0 System.out.println("[ERROR] Échec de l'authentification pour l'email : " + email); throw new UserNotFoundException("Utilisateur ou mot de passe incorrect."); } @@ -139,6 +173,15 @@ public class UsersService { return userOptional.get(); } + /** + * Méthode de compatibilité v1.0 (dépréciée). + * @deprecated Utiliser {@link #authenticateUser(String, String)} avec password à la place. + */ + @Deprecated + public Users authenticateUser(String email, String motDePasse, boolean deprecated) { + return authenticateUser(email, motDePasse); + } + /** * Récupère un utilisateur par son ID. * @@ -157,7 +200,7 @@ public class UsersService { } /** - * Réinitialise le mot de passe d'un utilisateur. + * Réinitialise le mot de passe d'un utilisateur (v2.0). * * @param id L'ID de l'utilisateur. * @param newPassword Le nouveau mot de passe à définir. @@ -171,7 +214,7 @@ public class UsersService { throw new UserNotFoundException("Utilisateur non trouvé."); } - user.setMotDePasse(newPassword); // Hachage automatique + user.setPassword(newPassword); // v2.0 - Hachage automatique usersRepository.persist(user); System.out.println("[LOG] Mot de passe réinitialisé pour l'utilisateur : " + user.getEmail()); } diff --git a/src/main/java/com/lions/dev/websocket/ChatWebSocket.java b/src/main/java/com/lions/dev/websocket/ChatWebSocket.java deleted file mode 100644 index 2c0cf27..0000000 --- a/src/main/java/com/lions/dev/websocket/ChatWebSocket.java +++ /dev/null @@ -1,361 +0,0 @@ -package com.lions.dev.websocket; - -import com.lions.dev.dto.request.chat.SendMessageRequestDTO; -import com.lions.dev.dto.response.chat.MessageResponseDTO; -import com.lions.dev.entity.chat.Message; -import com.lions.dev.service.MessageService; -import io.quarkus.logging.Log; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; -import jakarta.websocket.*; -import jakarta.websocket.server.PathParam; -import jakarta.websocket.server.ServerEndpoint; - -import java.io.IOException; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; - -/** - * WebSocket endpoint pour le chat en temps réel. - * - * Ce endpoint gère: - * - La connexion/déconnexion des utilisateurs - * - L'envoi et la réception de messages en temps réel - * - Les indicateurs de frappe (typing indicators) - * - Les confirmations de lecture (read receipts) - * - * URL: ws://localhost:8080/chat/ws/{userId} - */ -@ServerEndpoint("/chat/ws/{userId}") -@ApplicationScoped -public class ChatWebSocket { - - @Inject - MessageService messageService; - - // Map pour stocker les sessions WebSocket des utilisateurs connectés - private static final Map sessions = new ConcurrentHashMap<>(); - - /** - * Appelé lorsqu'un utilisateur se connecte. - * - * @param session La session WebSocket - * @param userId L'ID de l'utilisateur - */ - @OnOpen - public void onOpen(Session session, @PathParam("userId") String userId) { - try { - UUID userUUID = UUID.fromString(userId); - sessions.put(userUUID, session); - Log.info("[LOG] WebSocket ouvert pour l'utilisateur ID : " + userId); - - // Envoyer un message de confirmation - sendToUser(userUUID, "{\"type\":\"connected\",\"message\":\"Connecté au chat\"}"); - - } catch (Exception e) { - Log.error("[ERROR] Erreur lors de la connexion WebSocket : " + e.getMessage(), e); - } - } - - /** - * Appelé lorsqu'un message est reçu. - * - * @param message Le message reçu (au format JSON) - * @param userId L'ID de l'utilisateur qui envoie le message - */ - @OnMessage - public void onMessage(String message, @PathParam("userId") String userId) { - try { - Log.info("[LOG] Message reçu de l'utilisateur " + 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"); - - switch (type) { - case "message": - handleChatMessage(messageData, userId); - break; - case "typing": - handleTypingIndicator(messageData, userId); - break; - case "read": - handleReadReceipt(messageData, userId); - break; - default: - Log.warn("[WARN] Type de message inconnu : " + type); - } - - } catch (Exception e) { - Log.error("[ERROR] Erreur lors du traitement du message : " + e.getMessage(), e); - } - } - - /** - * Appelé lorsqu'une erreur se produit. - * - * @param session La session WebSocket - * @param error L'erreur - */ - @OnError - public void onError(Session session, Throwable error) { - Log.error("[ERROR] Erreur WebSocket : " + error.getMessage(), error); - } - - /** - * Appelé lorsqu'un utilisateur se déconnecte. - * - * @param session La session WebSocket - * @param userId L'ID de l'utilisateur - */ - @OnClose - public void onClose(Session session, @PathParam("userId") String userId) { - try { - UUID userUUID = UUID.fromString(userId); - sessions.remove(userUUID); - Log.info("[LOG] WebSocket fermé pour l'utilisateur ID : " + userId); - - } catch (Exception e) { - Log.error("[ERROR] Erreur lors de la fermeture WebSocket : " + e.getMessage(), e); - } - } - - /** - * Gère l'envoi d'un message de chat. - */ - private void handleChatMessage(Map messageData, 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"); - - // Enregistrer le message dans la base de données - Message message = messageService.sendMessage( - senderUUID, - recipientUUID, - content, - messageType, - mediaUrl - ); - - // Créer le DTO de réponse - MessageResponseDTO response = new MessageResponseDTO(message); - com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - String responseJson = mapper.writeValueAsString(Map.of( - "type", "message", - "data", response - )); - - // Envoyer le message au destinataire s'il est connecté - sendToUser(recipientUUID, responseJson); - - // Envoyer une confirmation à l'expéditeur - sendToUser(senderUUID, responseJson); - - Log.info("[LOG] Message envoyé de " + senderId + " à " + recipientUUID); - - } catch (Exception e) { - Log.error("[ERROR] Erreur lors de l'envoi du message : " + e.getMessage(), e); - } - } - - /** - * Gère les indicateurs de frappe. - */ - private void handleTypingIndicator(Map messageData, String userId) { - try { - UUID recipientUUID = UUID.fromString((String) messageData.get("recipientId")); - boolean isTyping = (boolean) messageData.getOrDefault("isTyping", false); - - com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - String response = mapper.writeValueAsString(Map.of( - "type", "typing", - "userId", userId, - "isTyping", isTyping - )); - - sendToUser(recipientUUID, response); - - Log.info("[LOG] Indicateur de frappe envoyé de " + userId + " à " + recipientUUID); - - } catch (Exception e) { - Log.error("[ERROR] Erreur lors de l'envoi de l'indicateur de frappe : " + e.getMessage(), e); - } - } - - /** - * Gère les confirmations de lecture. - */ - private void handleReadReceipt(Map messageData, String userId) { - try { - UUID messageUUID = UUID.fromString((String) messageData.get("messageId")); - - // Marquer le message comme lu - Message message = messageService.markMessageAsRead(messageUUID); - - // Notifier l'expéditeur que le message a été lu - UUID senderUUID = message.getSender().getId(); - - com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - String response = mapper.writeValueAsString(Map.of( - "type", "read", - "messageId", messageUUID.toString(), - "readBy", userId - )); - - sendToUser(senderUUID, response); - - Log.info("[LOG] Confirmation de lecture envoyée pour le message " + messageUUID); - - } catch (Exception e) { - Log.error("[ERROR] Erreur lors de l'envoi de la confirmation de lecture : " + e.getMessage(), e); - } - } - - /** - * Envoie un message à un utilisateur spécifique. - * - * @param userId L'ID de l'utilisateur - * @param message Le message à envoyer - */ - private void sendToUser(UUID userId, String message) { - Session session = sessions.get(userId); - if (session != null && session.isOpen()) { - try { - session.getAsyncRemote().sendText(message); - } catch (Exception e) { - Log.error("[ERROR] Erreur lors de l'envoi du message à l'utilisateur " + userId + " : " + e.getMessage(), e); - } - } else { - Log.warn("[WARN] Utilisateur " + userId + " non connecté ou session fermée"); - } - } - - /** - * Diffuse un message à tous les utilisateurs connectés. - * - * @param message Le message à diffuser - */ - public void broadcast(String message) { - sessions.values().forEach(session -> { - if (session.isOpen()) { - try { - session.getAsyncRemote().sendText(message); - } catch (Exception e) { - Log.error("[ERROR] Erreur lors de la diffusion : " + e.getMessage(), e); - } - } - }); - } - - /** - * Envoie un message chat à un utilisateur spécifique via WebSocket. - * - * Cette méthode est statique pour permettre son appel depuis les services - * (comme MessageService) sans nécessiter une instance de ChatWebSocket. - * - * @param userId L'ID de l'utilisateur destinataire - * @param messageData Les données du message (id, conversationId, content, etc.) - */ - public static void sendMessageToUser(UUID userId, Map messageData) { - Session session = sessions.get(userId); - - if (session == null || !session.isOpen()) { - Log.warn("[CHAT-WS] Utilisateur " + userId + " non connecté, message non envoyé"); - return; - } - - try { - // Construire le message JSON au format attendu par le frontend - com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - Map envelope = Map.of( - "type", "message", - "data", messageData - ); - String json = mapper.writeValueAsString(envelope); - - session.getAsyncRemote().sendText(json); - - Log.info("[CHAT-WS] Message envoyé à l'utilisateur " + userId); - } catch (Exception e) { - Log.error("[CHAT-WS] Erreur lors de l'envoi du message à " + userId + " : " + e.getMessage(), e); - } - } - - /** - * Envoie une confirmation de délivrance à l'expéditeur via WebSocket. - * - * Cette méthode est appelée lorsqu'un message est délivré au destinataire - * pour notifier l'expéditeur que le message a bien été reçu. - * - * @param userId L'ID de l'utilisateur (expéditeur) à notifier - * @param deliveryData Les données de confirmation (messageId, isDelivered, timestamp) - */ - public static void sendDeliveryConfirmation(UUID userId, Map deliveryData) { - Session session = sessions.get(userId); - - if (session == null || !session.isOpen()) { - Log.warn("[CHAT-WS] Utilisateur " + userId + " non connecté, confirmation de délivrance non envoyée"); - return; - } - - try { - com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - Map envelope = Map.of( - "type", "delivered", - "data", deliveryData - ); - String json = mapper.writeValueAsString(envelope); - - session.getAsyncRemote().sendText(json); - - Log.info("[CHAT-WS] Confirmation de délivrance envoyée à l'utilisateur " + userId); - } catch (Exception e) { - Log.error("[CHAT-WS] Erreur lors de l'envoi de la confirmation de délivrance : " + e.getMessage(), e); - } - } - - /** - * Envoie une confirmation de lecture à l'expéditeur via WebSocket. - * - * @param userId L'ID de l'utilisateur expéditeur - * @param readData Les données de confirmation de lecture - */ - public static void sendReadConfirmation(UUID userId, Map readData) { - Session session = sessions.get(userId); - - if (session == null || !session.isOpen()) { - Log.warn("[CHAT-WS] Utilisateur " + userId + " non connecté, confirmation de lecture non envoyée"); - return; - } - - try { - com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - Map envelope = Map.of( - "type", "read", - "data", readData - ); - String json = mapper.writeValueAsString(envelope); - - session.getAsyncRemote().sendText(json); - - Log.info("[CHAT-WS] Confirmation de lecture envoyée à l'utilisateur " + userId); - } catch (Exception e) { - Log.error("[CHAT-WS] Erreur lors de l'envoi de la confirmation de lecture : " + e.getMessage(), e); - } - } - - /** - * Récupère le nombre d'utilisateurs connectés. - * - * @return Le nombre d'utilisateurs connectés - */ - public static int getConnectedUsersCount() { - return sessions.size(); - } -} diff --git a/src/main/java/com/lions/dev/websocket/ChatWebSocketNext.java b/src/main/java/com/lions/dev/websocket/ChatWebSocketNext.java new file mode 100644 index 0000000..06e6ae8 --- /dev/null +++ b/src/main/java/com/lions/dev/websocket/ChatWebSocketNext.java @@ -0,0 +1,301 @@ +package com.lions.dev.websocket; + +import com.lions.dev.dto.response.chat.MessageResponseDTO; +import com.lions.dev.entity.chat.Message; +import com.lions.dev.service.MessageService; +import io.quarkus.logging.Log; +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * WebSocket endpoint pour le chat en temps réel (WebSockets Next). + * + * Architecture v2.0: + * Client → WebSocket → MessageService → Kafka → Bridge → WebSocket → Destinataire + * + * Gère: + * - La connexion/déconnexion des utilisateurs + * - L'envoi et la réception de messages en temps réel + * - Les indicateurs de frappe (typing indicators) + * - Les confirmations de lecture (read receipts) + * - Les confirmations de délivrance + * + * URL: ws://localhost:8080/chat/{userId} + */ +@WebSocket(path = "/chat/{userId}") +@ApplicationScoped +public class ChatWebSocketNext { + + @Inject + MessageService messageService; + + // Map pour stocker les sessions WebSocket des utilisateurs connectés + private static final Map sessions = new ConcurrentHashMap<>(); + + @OnOpen + public void onOpen(WebSocketConnection connection) { + String userId = connection.pathParam("userId"); + try { + UUID userUUID = UUID.fromString(userId); + sessions.put(userUUID, connection); + Log.info("[CHAT-WS-NEXT] WebSocket ouvert pour l'utilisateur ID : " + userId); + + // Envoyer un message de confirmation + String confirmation = buildJsonMessage("connected", + Map.of("message", "Connecté au chat")); + connection.sendText(confirmation); + + } catch (IllegalArgumentException e) { + Log.error("[CHAT-WS-NEXT] UUID invalide: " + userId, e); + connection.close(); + } catch (Exception e) { + Log.error("[CHAT-WS-NEXT] Erreur lors de la connexion", e); + connection.close(); + } + } + + @OnClose + public void onClose(WebSocketConnection connection) { + try { + String userId = connection.pathParam("userId"); + UUID userUUID = UUID.fromString(userId); + sessions.remove(userUUID); + Log.info("[CHAT-WS-NEXT] WebSocket fermé pour l'utilisateur ID : " + userId); + } catch (Exception e) { + Log.error("[CHAT-WS-NEXT] Erreur lors de la fermeture", e); + } + } + + @OnTextMessage + public void onMessage(String message, WebSocketConnection connection) { + try { + 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"); + + switch (type) { + case "message": + handleChatMessage(messageData, userId); + break; + case "typing": + handleTypingIndicator(messageData, userId); + break; + case "read": + handleReadReceipt(messageData, userId); + break; + default: + Log.warn("[CHAT-WS-NEXT] Type de message inconnu: " + type); + } + + } catch (Exception e) { + Log.error("[CHAT-WS-NEXT] Erreur lors du traitement du message", e); + } + } + + /** + * Gère l'envoi d'un message de chat. + * Le message est traité par MessageService qui publiera dans Kafka. + */ + private void handleChatMessage(Map messageData, 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"); + + // Enregistrer le message dans la base de données + // MessageService publiera automatiquement dans Kafka + Message message = messageService.sendMessage( + senderUUID, + recipientUUID, + content, + messageType, + mediaUrl + ); + + // Créer le DTO de réponse + MessageResponseDTO response = new MessageResponseDTO(message); + String responseJson = buildJsonMessage("message", + Map.of("message", response)); + + // Envoyer confirmation à l'expéditeur + sendToUser(senderUUID, responseJson); + + Log.info("[CHAT-WS-NEXT] Message traité de " + senderId + " à " + recipientUUID); + + } catch (Exception e) { + Log.error("[CHAT-WS-NEXT] Erreur lors de l'envoi du message", e); + } + } + + /** + * Gère les indicateurs de frappe. + */ + private void handleTypingIndicator(Map messageData, String userId) { + try { + UUID recipientUUID = UUID.fromString((String) messageData.get("recipientId")); + boolean isTyping = (boolean) messageData.getOrDefault("isTyping", false); + + String response = buildJsonMessage("typing", Map.of( + "userId", userId, + "isTyping", isTyping + )); + + sendToUser(recipientUUID, response); + + Log.debug("[CHAT-WS-NEXT] Indicateur de frappe envoyé de " + userId + " à " + recipientUUID); + + } catch (Exception e) { + Log.error("[CHAT-WS-NEXT] Erreur lors de l'envoi de l'indicateur de frappe", e); + } + } + + /** + * Gère les confirmations de lecture. + */ + private void handleReadReceipt(Map messageData, String userId) { + try { + UUID messageUUID = UUID.fromString((String) messageData.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( + "messageId", messageUUID.toString(), + "readBy", userId, + "readAt", System.currentTimeMillis() + )); + + sendToUser(senderUUID, response); + Log.info("[CHAT-WS-NEXT] Confirmation de lecture envoyée pour message " + messageUUID); + } + + } catch (Exception e) { + Log.error("[CHAT-WS-NEXT] Erreur lors du traitement de la confirmation de lecture", e); + } + } + + /** + * Envoie un message chat à un utilisateur spécifique via WebSocket. + * Appelé par le bridge Kafka → WebSocket. + * + * @param userId ID de l'utilisateur destinataire + * @param message Message JSON à envoyer + */ + public static void sendMessageToUser(UUID userId, String message) { + WebSocketConnection connection = sessions.get(userId); + + if (connection == null || !connection.isOpen()) { + Log.debug("[CHAT-WS-NEXT] Utilisateur " + userId + " non connecté"); + return; + } + + try { + connection.sendText(message); + Log.debug("[CHAT-WS-NEXT] Message envoyé à l'utilisateur: " + userId); + } catch (Exception e) { + Log.error("[CHAT-WS-NEXT] Erreur lors de l'envoi à " + userId, e); + } + } + + /** + * Envoie une confirmation de délivrance à l'expéditeur via WebSocket. + */ + public static void sendDeliveryConfirmation(UUID senderId, Map confirmationData) { + WebSocketConnection connection = sessions.get(senderId); + + if (connection == null || !connection.isOpen()) { + Log.debug("[CHAT-WS-NEXT] Expéditeur " + senderId + " non connecté pour confirmation"); + return; + } + + try { + String response = buildJsonMessage("delivery_confirmation", confirmationData); + connection.sendText(response); + Log.debug("[CHAT-WS-NEXT] Confirmation de délivrance envoyée à: " + senderId); + } catch (Exception e) { + Log.error("[CHAT-WS-NEXT] Erreur envoi confirmation à " + senderId, e); + } + } + + /** + * Envoie une confirmation de lecture à l'expéditeur via WebSocket. + */ + public static void sendReadConfirmation(UUID senderId, Map readData) { + WebSocketConnection connection = sessions.get(senderId); + + if (connection == null || !connection.isOpen()) { + Log.debug("[CHAT-WS-NEXT] Expéditeur " + senderId + " non connecté pour confirmation de lecture"); + return; + } + + try { + String response = buildJsonMessage("read_confirmation", readData); + connection.sendText(response); + Log.debug("[CHAT-WS-NEXT] Confirmation de lecture envoyée à: " + senderId); + } catch (Exception e) { + Log.error("[CHAT-WS-NEXT] Erreur envoi confirmation lecture à " + senderId, e); + } + } + + /** + * Envoie un message à un utilisateur (méthode privée pour usage interne). + */ + private void sendToUser(UUID userId, String message) { + WebSocketConnection connection = sessions.get(userId); + + if (connection != null && connection.isOpen()) { + try { + connection.sendText(message); + } catch (Exception e) { + Log.error("[CHAT-WS-NEXT] Erreur lors de l'envoi à " + userId, e); + } + } + } + + /** + * Construit un message JSON. + */ + private static String buildJsonMessage(String type, Map data) { + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = + new com.fasterxml.jackson.databind.ObjectMapper(); + Map message = Map.of( + "type", type, + "data", data, + "timestamp", System.currentTimeMillis() + ); + return mapper.writeValueAsString(message); + } catch (Exception e) { + Log.error("[CHAT-WS-NEXT] Erreur construction JSON", e); + return "{\"type\":\"error\",\"data\":{\"message\":\"Erreur de construction\"}}"; + } + } + + /** + * Récupère le nombre d'utilisateurs connectés au chat. + */ + public static int getConnectedUsersCount() { + return sessions.size(); + } +} diff --git a/src/main/java/com/lions/dev/websocket/NotificationWebSocket.java b/src/main/java/com/lions/dev/websocket/NotificationWebSocket.java deleted file mode 100644 index 7f8afe8..0000000 --- a/src/main/java/com/lions/dev/websocket/NotificationWebSocket.java +++ /dev/null @@ -1,370 +0,0 @@ -package com.lions.dev.websocket; - -import com.lions.dev.service.NotificationService; -import com.lions.dev.service.FriendshipService; -import io.quarkus.logging.Log; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; -import jakarta.websocket.*; -import jakarta.websocket.server.PathParam; -import jakarta.websocket.server.ServerEndpoint; - -import java.io.IOException; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; - -/** - * WebSocket endpoint pour les notifications en temps réel. - * - * Ce endpoint gère: - * - Les notifications de demandes d'amitié (envoi, acceptation, rejet) - * - Les notifications système (événements, rappels) - * - Les alertes de messages - * - * URL: ws://localhost:8080/notifications/ws/{userId} - */ -@ServerEndpoint("/notifications/ws/{userId}") -@ApplicationScoped -public class NotificationWebSocket { - - @Inject - NotificationService notificationService; - - @Inject - FriendshipService friendshipService; - - @Inject - com.lions.dev.service.PresenceService presenceService; - - // Map pour stocker les sessions WebSocket par utilisateur - // Support de plusieurs sessions par utilisateur (multi-device) - private static final Map> userSessions = new ConcurrentHashMap<>(); - - /** - * Appelé lorsqu'un utilisateur se connecte. - * - * @param session La session WebSocket - * @param userId L'ID de l'utilisateur - */ - @OnOpen - public void onOpen(Session session, @PathParam("userId") String userId) { - try { - UUID userUUID = UUID.fromString(userId); - - // Ajouter la session à l'ensemble des sessions de l'utilisateur - userSessions.computeIfAbsent(userUUID, k -> ConcurrentHashMap.newKeySet()).add(session); - - Log.info("[NOTIFICATION-WS] Connexion ouverte pour l'utilisateur ID : " + userId + - " (Total sessions: " + userSessions.get(userUUID).size() + ")"); - - // Envoyer un message de confirmation - String confirmationMessage = buildNotificationJson("connected", - Map.of("message", "Connecté au service de notifications en temps réel")); - - session.getAsyncRemote().sendText(confirmationMessage); - - // Marquer l'utilisateur comme en ligne - presenceService.setUserOnline(userUUID); - - } catch (IllegalArgumentException e) { - Log.error("[NOTIFICATION-WS] UUID invalide : " + userId, e); - try { - session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, "UUID invalide")); - } catch (IOException ioException) { - Log.error("[NOTIFICATION-WS] Erreur lors de la fermeture de session", ioException); - } - } catch (Exception e) { - Log.error("[NOTIFICATION-WS] Erreur lors de la connexion : " + e.getMessage(), e); - } - } - - /** - * Appelé lorsqu'un message est reçu. - * - * Gère les messages de type ping, ack, etc. - * - * @param message Le message reçu (au format JSON) - * @param userId L'ID de l'utilisateur qui envoie le message - */ - @OnMessage - public void onMessage(String message, @PathParam("userId") String userId) { - try { - Log.info("[NOTIFICATION-WS] Message reçu de l'utilisateur " + 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"); - - switch (type) { - case "ping": - handlePing(userId); - break; - case "ack": - handleAcknowledgement(messageData, userId); - break; - default: - Log.warn("[NOTIFICATION-WS] Type de message inconnu : " + type); - } - - } catch (Exception e) { - Log.error("[NOTIFICATION-WS] Erreur lors du traitement du message : " + e.getMessage(), e); - } - } - - /** - * Appelé lorsqu'une erreur se produit. - * - * @param session La session WebSocket - * @param error L'erreur - */ - @OnError - public void onError(Session session, Throwable error) { - Log.error("[NOTIFICATION-WS] Erreur WebSocket : " + error.getMessage(), error); - } - - /** - * Appelé lorsqu'un utilisateur se déconnecte. - * - * @param session La session WebSocket - * @param userId L'ID de l'utilisateur - */ - @OnClose - public void onClose(Session session, @PathParam("userId") String userId) { - try { - UUID userUUID = UUID.fromString(userId); - - // Supprimer la session de l'ensemble - Set sessions = userSessions.get(userUUID); - if (sessions != null) { - sessions.remove(session); - - // Si l'utilisateur n'a plus de sessions, supprimer l'entrée et marquer hors ligne - if (sessions.isEmpty()) { - userSessions.remove(userUUID); - presenceService.setUserOffline(userUUID); - Log.info("[NOTIFICATION-WS] Toutes les sessions fermées pour l'utilisateur ID : " + userId); - } else { - Log.info("[NOTIFICATION-WS] Session fermée pour l'utilisateur ID : " + userId + - " (Sessions restantes: " + sessions.size() + ")"); - } - } - - } catch (Exception e) { - Log.error("[NOTIFICATION-WS] Erreur lors de la fermeture : " + e.getMessage(), e); - } - } - - /** - * Gère les messages de type ping (keep-alive). - * Exécuté de manière asynchrone sur un thread worker pour permettre les transactions JTA. - */ - private void handlePing(String userId) { - // Exécuter le heartbeat de manière asynchrone sur un thread worker - java.util.concurrent.CompletableFuture.runAsync(() -> { - try { - UUID userUUID = UUID.fromString(userId); - - // Mettre à jour le heartbeat de présence (exécuté sur thread worker) - presenceService.heartbeat(userUUID); - - // Envoyer le pong depuis le thread worker - String pongMessage = buildNotificationJson("pong", Map.of("timestamp", System.currentTimeMillis())); - sendToUser(userUUID, pongMessage); - - Log.debug("[NOTIFICATION-WS] Pong envoyé à l'utilisateur : " + userId); - } catch (Exception e) { - Log.error("[NOTIFICATION-WS] Erreur lors de l'envoi du pong : " + e.getMessage(), e); - } - }); - } - - /** - * Gère les accusés de réception des notifications. - */ - private void handleAcknowledgement(Map messageData, String userId) { - try { - String notificationId = (String) messageData.get("notificationId"); - Log.info("[NOTIFICATION-WS] ACK reçu pour la notification " + notificationId + " de l'utilisateur " + userId); - - // Optionnel: marquer la notification comme délivrée en base de données - - } catch (Exception e) { - Log.error("[NOTIFICATION-WS] Erreur lors du traitement de l'ACK : " + e.getMessage(), e); - } - } - - /** - * Envoie une notification à toutes les sessions d'un utilisateur spécifique. - * - * Cette méthode est statique pour permettre son appel depuis les services - * sans nécessiter une instance de NotificationWebSocket. - * - * @param userId L'ID de l'utilisateur - * @param notificationType Le type de notification - * @param data Les données de la notification - */ - public static void sendNotificationToUser(UUID userId, String notificationType, Map data) { - Set sessions = userSessions.get(userId); - - if (sessions == null || sessions.isEmpty()) { - Log.warn("[NOTIFICATION-WS] Utilisateur " + userId + " non connecté ou aucune session active"); - return; - } - - String json = buildNotificationJson(notificationType, data); - - int successCount = 0; - int failCount = 0; - - for (Session session : sessions) { - if (session.isOpen()) { - try { - session.getAsyncRemote().sendText(json); - successCount++; - } catch (Exception e) { - failCount++; - Log.error("[NOTIFICATION-WS] Erreur lors de l'envoi à une session de l'utilisateur " + userId + " : " + e.getMessage(), e); - } - } else { - failCount++; - } - } - - Log.info("[NOTIFICATION-WS] Notification " + notificationType + " envoyée à l'utilisateur " + userId + - " (Succès: " + successCount + ", Échec: " + failCount + ")"); - } - - /** - * Envoie un message à toutes les sessions d'un utilisateur. - * - * Version privée pour usage interne (ping/pong, etc.) - * - * @param userId L'ID de l'utilisateur - * @param message Le message à envoyer - */ - private void sendToUser(UUID userId, String message) { - Set sessions = userSessions.get(userId); - - if (sessions != null) { - for (Session session : sessions) { - if (session.isOpen()) { - try { - session.getAsyncRemote().sendText(message); - } catch (Exception e) { - Log.error("[NOTIFICATION-WS] Erreur lors de l'envoi à l'utilisateur " + userId + " : " + e.getMessage(), e); - } - } - } - } - } - - /** - * Diffuse une notification à tous les utilisateurs connectés. - * - * @param notificationType Le type de notification - * @param data Les données de la notification - */ - public static void broadcastNotification(String notificationType, Map data) { - String json = buildNotificationJson(notificationType, data); - - int totalSessions = 0; - int successCount = 0; - - for (Set sessions : userSessions.values()) { - for (Session session : sessions) { - totalSessions++; - if (session.isOpen()) { - try { - session.getAsyncRemote().sendText(json); - successCount++; - } catch (Exception e) { - Log.error("[NOTIFICATION-WS] Erreur lors de la diffusion : " + e.getMessage(), e); - } - } - } - } - - Log.info("[NOTIFICATION-WS] Notification diffusée à " + successCount + " sessions sur " + totalSessions); - } - - /** - * Construit un message JSON pour les notifications. - * - * Format: {"type": "notification_type", "data": {...}} - * - * @param type Le type de notification - * @param data Les données de la notification - * @return Le JSON sous forme de String - */ - private static String buildNotificationJson(String type, Map data) { - try { - com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - Map message = Map.of( - "type", type, - "data", data, - "timestamp", System.currentTimeMillis() - ); - return mapper.writeValueAsString(message); - } catch (Exception e) { - Log.error("[NOTIFICATION-WS] Erreur lors de la construction du JSON : " + e.getMessage(), e); - return "{\"type\":\"error\",\"data\":{\"message\":\"Erreur de construction du message\"}}"; - } - } - - /** - * Récupère le nombre total d'utilisateurs connectés. - * - * @return Le nombre d'utilisateurs connectés - */ - public static int getConnectedUsersCount() { - return userSessions.size(); - } - - /** - * Récupère le nombre total de sessions actives. - * - * @return Le nombre total de sessions - */ - public static int getTotalSessionsCount() { - return userSessions.values().stream() - .mapToInt(Set::size) - .sum(); - } - - /** - * Broadcast une mise à jour de présence à tous les utilisateurs connectés. - * - * @param presenceData Les données de présence (userId, isOnline, lastSeen) - */ - public static void broadcastPresenceUpdate(Map presenceData) { - try { - com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - Map envelope = Map.of( - "type", "presence", - "data", presenceData - ); - String json = mapper.writeValueAsString(envelope); - - // Envoyer à tous les utilisateurs connectés - for (Set sessions : userSessions.values()) { - for (Session session : sessions) { - if (session.isOpen()) { - try { - session.getAsyncRemote().sendText(json); - } catch (Exception e) { - Log.error("[NOTIFICATION-WS] Erreur broadcast présence : " + e.getMessage()); - } - } - } - } - - Log.debug("[NOTIFICATION-WS] Présence broadcastée : " + presenceData.get("userId")); - } catch (Exception e) { - Log.error("[NOTIFICATION-WS] Erreur lors du broadcast de présence : " + e.getMessage(), e); - } - } -} diff --git a/src/main/java/com/lions/dev/websocket/NotificationWebSocketNext.java b/src/main/java/com/lions/dev/websocket/NotificationWebSocketNext.java new file mode 100644 index 0000000..abcb69e --- /dev/null +++ b/src/main/java/com/lions/dev/websocket/NotificationWebSocketNext.java @@ -0,0 +1,282 @@ +package com.lions.dev.websocket; + +import io.quarkus.logging.Log; +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import com.lions.dev.service.PresenceService; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * WebSocket endpoint pour les notifications en temps réel (WebSockets Next). + * + * Architecture v2.0: + * Services métier → Kafka Topic → Kafka Bridge → WebSocket → Client + * + * Avantages: + * - Scalabilité horizontale (plusieurs instances Quarkus) + * - Durabilité (événements persistés dans Kafka) + * - Découplage (services indépendants des WebSockets) + * + * URL: ws://localhost:8080/notifications/{userId} + */ +@WebSocket(path = "/notifications/{userId}") +@ApplicationScoped +public class NotificationWebSocketNext { + + @Inject + PresenceService presenceService; + + // Stockage des connexions actives par utilisateur (multi-device support) + private static final Map> userConnections = new ConcurrentHashMap<>(); + + @OnOpen + public void onOpen(WebSocketConnection connection) { + String userId = connection.pathParam("userId"); + try { + UUID userUUID = UUID.fromString(userId); + + // Ajouter la connexion à l'ensemble des connexions de l'utilisateur + userConnections.computeIfAbsent(userUUID, k -> ConcurrentHashMap.newKeySet()) + .add(connection); + + Log.info("[WS-NEXT] Connexion ouverte pour l'utilisateur: " + userId + + " (Total: " + userConnections.get(userUUID).size() + ")"); + + // Envoyer confirmation + String confirmation = buildJsonMessage("connected", + Map.of("message", "Connecté au service de notifications en temps réel")); + connection.sendText(confirmation); + + // Marquer l'utilisateur comme en ligne + presenceService.setUserOnline(userUUID); + + } catch (IllegalArgumentException e) { + Log.error("[WS-NEXT] UUID invalide: " + userId, e); + connection.close(); + } catch (Exception e) { + Log.error("[WS-NEXT] Erreur lors de la connexion", e); + connection.close(); + } + } + + @OnClose + public void onClose(WebSocketConnection connection) { + try { + String userId = connection.pathParam("userId"); + UUID userUUID = UUID.fromString(userId); + Set connections = userConnections.get(userUUID); + + if (connections != null) { + connections.removeIf(conn -> !conn.isOpen()); + + if (connections.isEmpty()) { + userConnections.remove(userUUID); + presenceService.setUserOffline(userUUID); + Log.info("[WS-NEXT] Toutes les connexions fermées pour: " + userId); + } else { + Log.info("[WS-NEXT] Connexion fermée pour: " + userId + + " (Restantes: " + connections.size() + ")"); + } + } + } catch (Exception e) { + Log.error("[WS-NEXT] Erreur lors de la fermeture", e); + } + } + + @OnTextMessage + public void onMessage(String message, WebSocketConnection connection) { + try { + String userId = connection.pathParam("userId"); + Log.debug("[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"); + + switch (type) { + case "ping": + handlePing(userId); + break; + case "ack": + handleAck(messageData, userId); + break; + default: + Log.warn("[WS-NEXT] Type de message inconnu: " + type); + } + } catch (Exception e) { + Log.error("[WS-NEXT] Erreur traitement message", e); + } + } + + private void handlePing(String userId) { + try { + UUID userUUID = UUID.fromString(userId); + + // Mettre à jour le heartbeat de présence + presenceService.heartbeat(userUUID); + + // Envoyer pong + String pong = buildJsonMessage("pong", + Map.of("timestamp", System.currentTimeMillis())); + sendToUser(userUUID, pong); + + Log.debug("[WS-NEXT] Pong envoyé à: " + userId); + } catch (Exception e) { + Log.error("[WS-NEXT] Erreur lors de l'envoi du pong", e); + } + } + + private void handleAck(Map messageData, String userId) { + try { + String notificationId = (String) messageData.get("notificationId"); + Log.debug("[WS-NEXT] ACK reçu pour notification " + notificationId + + " de " + userId); + // Optionnel: marquer la notification comme délivrée en base + } catch (Exception e) { + Log.error("[WS-NEXT] Erreur lors du traitement de l'ACK", e); + } + } + + /** + * Envoie une notification à un utilisateur spécifique. + * Appelé par le bridge Kafka → WebSocket. + * + * @param userId ID de l'utilisateur destinataire + * @param message Message JSON à envoyer + */ + public static void sendToUser(UUID userId, String message) { + Set connections = userConnections.get(userId); + + if (connections == null || connections.isEmpty()) { + Log.debug("[WS-NEXT] Utilisateur " + userId + " non connecté"); + return; + } + + int success = 0; + int failed = 0; + + for (WebSocketConnection conn : connections) { + if (conn.isOpen()) { + try { + conn.sendText(message); + success++; + } catch (Exception e) { + failed++; + Log.error("[WS-NEXT] Erreur envoi à " + userId, e); + } + } else { + failed++; + } + } + + Log.info("[WS-NEXT] Notification envoyée à " + userId + + " (Succès: " + success + ", Échec: " + failed + ")"); + } + + /** + * Diffuse une notification à tous les utilisateurs connectés. + * + * @param notificationType Type de notification + * @param data Données de la notification + */ + public static void broadcastNotification(String notificationType, Map data) { + String json = buildJsonMessage(notificationType, data); + + int totalSessions = 0; + int successCount = 0; + + for (Set sessions : userConnections.values()) { + for (WebSocketConnection session : sessions) { + totalSessions++; + if (session.isOpen()) { + try { + session.sendText(json); + successCount++; + } catch (Exception e) { + Log.error("[WS-NEXT] Erreur lors de la diffusion", e); + } + } + } + } + + Log.info("[WS-NEXT] Notification diffusée à " + successCount + + " sessions sur " + totalSessions); + } + + /** + * Broadcast une mise à jour de présence à tous les utilisateurs connectés. + * + * @param presenceData Données de présence (userId, isOnline, lastSeen) + */ + public static void broadcastPresenceUpdate(Map presenceData) { + try { + String json = buildJsonMessage("presence", presenceData); + + for (Set sessions : userConnections.values()) { + for (WebSocketConnection session : sessions) { + if (session.isOpen()) { + try { + session.sendText(json); + } catch (Exception e) { + Log.error("[WS-NEXT] Erreur broadcast présence", e); + } + } + } + } + + Log.debug("[WS-NEXT] Présence broadcastée: " + presenceData.get("userId")); + } catch (Exception e) { + Log.error("[WS-NEXT] Erreur lors du broadcast de présence", e); + } + } + + /** + * Construit un message JSON pour les notifications. + * + * Format: {"type": "notification_type", "data": {...}, "timestamp": ...} + */ + private static String buildJsonMessage(String type, Map data) { + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = + new com.fasterxml.jackson.databind.ObjectMapper(); + Map message = Map.of( + "type", type, + "data", data, + "timestamp", System.currentTimeMillis() + ); + return mapper.writeValueAsString(message); + } catch (Exception e) { + Log.error("[WS-NEXT] Erreur construction JSON", e); + return "{\"type\":\"error\",\"data\":{\"message\":\"Erreur de construction du message\"}}"; + } + } + + /** + * Récupère le nombre total d'utilisateurs connectés. + */ + public static int getConnectedUsersCount() { + return userConnections.size(); + } + + /** + * Récupère le nombre total de sessions actives. + */ + public static int getTotalSessionsCount() { + return userConnections.values().stream() + .mapToInt(Set::size) + .sum(); + } +} diff --git a/src/main/java/com/lions/dev/websocket/bridge/ChatKafkaBridge.java b/src/main/java/com/lions/dev/websocket/bridge/ChatKafkaBridge.java new file mode 100644 index 0000000..be16a52 --- /dev/null +++ b/src/main/java/com/lions/dev/websocket/bridge/ChatKafkaBridge.java @@ -0,0 +1,86 @@ +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.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Message; + +import java.util.UUID; +import java.util.Map; + +/** + * Bridge qui consomme depuis Kafka et envoie via WebSocket pour le chat. + * + * Architecture: + * MessageService → Kafka Topic (chat.messages) → Bridge → WebSocket → Client + */ +@ApplicationScoped +public class ChatKafkaBridge { + + /** + * Consomme les messages chat depuis Kafka et les route vers WebSocket. + */ + @Incoming("kafka-chat") + public void processChatMessage(Message message) { + try { + ChatMessageEvent event = message.getPayload(); + + Log.debug("[CHAT-BRIDGE] Message 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); + + // Acknowledger le message Kafka + message.ack(); + + Log.debug("[CHAT-BRIDGE] Message routé vers WebSocket pour: " + event.getRecipientId()); + + } catch (IllegalArgumentException e) { + Log.error("[CHAT-BRIDGE] UUID invalide dans l'événement", e); + message.nack(e); + } catch (Exception e) { + Log.error("[CHAT-BRIDGE] Erreur traitement événement", e); + message.nack(e); + } + } + + /** + * Construit le message JSON pour WebSocket à partir de l'événement Kafka. + */ + 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()); + } + + 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() + ); + + return mapper.writeValueAsString(wsMessage); + } catch (Exception e) { + Log.error("[CHAT-BRIDGE] Erreur construction message WebSocket", e); + return "{\"type\":\"error\",\"data\":{\"message\":\"Erreur de traitement\"}}"; + } + } +} diff --git a/src/main/java/com/lions/dev/websocket/bridge/NotificationKafkaBridge.java b/src/main/java/com/lions/dev/websocket/bridge/NotificationKafkaBridge.java new file mode 100644 index 0000000..77e07ff --- /dev/null +++ b/src/main/java/com/lions/dev/websocket/bridge/NotificationKafkaBridge.java @@ -0,0 +1,81 @@ +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.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Message; + +import java.util.UUID; + +/** + * Bridge qui consomme depuis Kafka et envoie via WebSocket pour les notifications. + * + * Architecture: + * Services métier → Kafka Topic (notifications) → Bridge → WebSocket → Client + * + * Avantages: + * - Découplage: Services ne connaissent pas les WebSockets + * - Scalabilité: Plusieurs instances peuvent consommer depuis Kafka + * - Durabilité: Messages persistés dans Kafka même si client déconnecté + */ +@ApplicationScoped +public class NotificationKafkaBridge { + + /** + * Consomme les événements depuis Kafka et les route vers WebSocket. + * + * @param message Message Kafka contenant un NotificationEvent + */ + @Incoming("kafka-notifications") + public void processNotification(Message message) { + try { + NotificationEvent event = message.getPayload(); + + Log.debug("[KAFKA-BRIDGE] Événement reçu: " + event.getType() + + " pour utilisateur: " + event.getUserId()); + + UUID userId = UUID.fromString(event.getUserId()); + + // Construire le message JSON pour WebSocket + String wsMessage = buildWebSocketMessage(event); + + // Envoyer via WebSocket + NotificationWebSocketNext.sendToUser(userId, wsMessage); + + // Acknowledger le message Kafka + message.ack(); + + Log.debug("[KAFKA-BRIDGE] Notification routée vers WebSocket pour: " + event.getUserId()); + + } catch (IllegalArgumentException e) { + Log.error("[KAFKA-BRIDGE] UUID invalide dans l'événement", e); + message.nack(e); + } catch (Exception e) { + Log.error("[KAFKA-BRIDGE] Erreur traitement événement", e); + message.nack(e); + } + } + + /** + * Construit le message JSON pour WebSocket à partir de l'événement Kafka. + */ + private String buildWebSocketMessage(NotificationEvent event) { + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = + new com.fasterxml.jackson.databind.ObjectMapper(); + + java.util.Map wsMessage = java.util.Map.of( + "type", event.getType(), + "data", event.getData() != null ? event.getData() : java.util.Map.of(), + "timestamp", event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis() + ); + + return mapper.writeValueAsString(wsMessage); + } catch (Exception e) { + Log.error("[KAFKA-BRIDGE] Erreur construction message WebSocket", e); + return "{\"type\":\"error\",\"data\":{\"message\":\"Erreur de traitement\"}}"; + } + } +} diff --git a/src/main/java/com/lions/dev/websocket/bridge/ReactionKafkaBridge.java b/src/main/java/com/lions/dev/websocket/bridge/ReactionKafkaBridge.java new file mode 100644 index 0000000..89e4af1 --- /dev/null +++ b/src/main/java/com/lions/dev/websocket/bridge/ReactionKafkaBridge.java @@ -0,0 +1,93 @@ +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.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Message; + +import java.util.UUID; + +/** + * Bridge qui consomme depuis Kafka et envoie via WebSocket pour les réactions. + * + * Architecture: + * SocialPostService/EventService → Kafka Topic (reactions) → Bridge → WebSocket → Client + * + * Les réactions (likes, comments) sont notifiées en temps réel aux propriétaires + * des posts/stories/événements. + */ +@ApplicationScoped +public class ReactionKafkaBridge { + + /** + * Consomme les réactions depuis Kafka et les route vers WebSocket. + */ + @Incoming("kafka-reactions") + public void processReaction(Message message) { + try { + ReactionEvent event = message.getPayload(); + + Log.debug("[REACTION-BRIDGE] Réaction reçue: " + event.getReactionType() + + " sur " + event.getTargetType() + ":" + event.getTargetId()); + + // Pour les réactions, on doit notifier le propriétaire du post/story/event + // L'ID du propriétaire doit être dans event.getData() sous la clé "ownerId" + if (event.getData() != null && event.getData().containsKey("ownerId")) { + String ownerId = event.getData().get("ownerId").toString(); + UUID ownerUUID = UUID.fromString(ownerId); + + // Construire le message JSON pour WebSocket + String wsMessage = buildWebSocketMessage(event); + + // Envoyer via WebSocket au propriétaire + NotificationWebSocketNext.sendToUser(ownerUUID, wsMessage); + + Log.debug("[REACTION-BRIDGE] Réaction routée vers WebSocket pour propriétaire: " + ownerId); + } else { + Log.warn("[REACTION-BRIDGE] ownerId manquant dans les données de l'événement"); + } + + // Acknowledger le message Kafka + message.ack(); + + } catch (IllegalArgumentException e) { + Log.error("[REACTION-BRIDGE] UUID invalide dans l'événement", e); + message.nack(e); + } catch (Exception e) { + Log.error("[REACTION-BRIDGE] Erreur traitement événement", e); + message.nack(e); + } + } + + /** + * Construit le message JSON pour WebSocket à partir de l'événement Kafka. + */ + private String buildWebSocketMessage(ReactionEvent event) { + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = + new com.fasterxml.jackson.databind.ObjectMapper(); + + java.util.Map reactionData = new java.util.HashMap<>(); + reactionData.put("targetId", event.getTargetId()); + reactionData.put("targetType", event.getTargetType()); + reactionData.put("userId", event.getUserId()); + reactionData.put("reactionType", event.getReactionType()); + if (event.getData() != null) { + reactionData.putAll(event.getData()); + } + + java.util.Map wsMessage = java.util.Map.of( + "type", "reaction", + "data", reactionData, + "timestamp", event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis() + ); + + return mapper.writeValueAsString(wsMessage); + } catch (Exception e) { + Log.error("[REACTION-BRIDGE] Erreur construction message WebSocket", e); + return "{\"type\":\"error\",\"data\":{\"message\":\"Erreur de traitement\"}}"; + } + } +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 8240e41..490eb5c 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -20,6 +20,9 @@ quarkus.datasource.devservices.enabled=false quarkus.hibernate-orm.database.generation=drop-and-create quarkus.hibernate-orm.log.sql=true quarkus.hibernate-orm.format_sql=true +quarkus.hibernate-orm.packages=com.lions.dev.entity +# Forcer la création du schéma au démarrage +quarkus.hibernate-orm.schema-generation.scripts.action=drop-and-create # ==================================================================== # Logging diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b3f066a..07e62d5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -24,3 +24,67 @@ quarkus.http.port=8080 # ==================================================================== quarkus.http.body.uploads-directory=/tmp/uploads quarkus.http.limits.max-body-size=10M + +# ==================================================================== +# WebSockets Next (commun à tous les environnements) +# ==================================================================== +quarkus.websockets-next.server.enabled=true + +# ==================================================================== +# Kafka Configuration (commun à tous les environnements) +# ==================================================================== +kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + +# ==================================================================== +# Kafka Topics - Outgoing (Services → Kafka) +# ==================================================================== +# Topic: Notifications +mp.messaging.outgoing.notifications.connector=smallrye-kafka +mp.messaging.outgoing.notifications.topic=notifications +mp.messaging.outgoing.notifications.key.serializer=org.apache.kafka.common.serialization.StringSerializer +mp.messaging.outgoing.notifications.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer + +# Topic: Chat Messages +mp.messaging.outgoing.chat-messages.connector=smallrye-kafka +mp.messaging.outgoing.chat-messages.topic=chat.messages +mp.messaging.outgoing.chat-messages.key.serializer=org.apache.kafka.common.serialization.StringSerializer +mp.messaging.outgoing.chat-messages.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer + +# Topic: Reactions (likes, comments, shares) +mp.messaging.outgoing.reactions.connector=smallrye-kafka +mp.messaging.outgoing.reactions.topic=reactions +mp.messaging.outgoing.reactions.key.serializer=org.apache.kafka.common.serialization.StringSerializer +mp.messaging.outgoing.reactions.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer + +# Topic: Presence Updates +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 +mp.messaging.outgoing.presence.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer + +# ==================================================================== +# Kafka Topics - Incoming (Kafka → WebSocket Bridge) +# ==================================================================== +# Consommer depuis Kafka et router vers WebSocket pour notifications +mp.messaging.incoming.kafka-notifications.connector=smallrye-kafka +mp.messaging.incoming.kafka-notifications.topic=notifications +mp.messaging.incoming.kafka-notifications.group.id=websocket-notifications-bridge +mp.messaging.incoming.kafka-notifications.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer +mp.messaging.incoming.kafka-notifications.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer +mp.messaging.incoming.kafka-notifications.enable.auto.commit=true + +# Consommer depuis Kafka et router vers WebSocket pour chat +mp.messaging.incoming.kafka-chat.connector=smallrye-kafka +mp.messaging.incoming.kafka-chat.topic=chat.messages +mp.messaging.incoming.kafka-chat.group.id=websocket-chat-bridge +mp.messaging.incoming.kafka-chat.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer +mp.messaging.incoming.kafka-chat.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer +mp.messaging.incoming.kafka-chat.enable.auto.commit=true + +# Consommer depuis Kafka et router vers WebSocket pour réactions +mp.messaging.incoming.kafka-reactions.connector=smallrye-kafka +mp.messaging.incoming.kafka-reactions.topic=reactions +mp.messaging.incoming.kafka-reactions.group.id=websocket-reactions-bridge +mp.messaging.incoming.kafka-reactions.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer +mp.messaging.incoming.kafka-reactions.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer +mp.messaging.incoming.kafka-reactions.enable.auto.commit=true diff --git a/src/main/resources/db/migration/V10__Create_Bookings_Table.sql b/src/main/resources/db/migration/V10__Create_Bookings_Table.sql new file mode 100644 index 0000000..19d91e5 --- /dev/null +++ b/src/main/resources/db/migration/V10__Create_Bookings_Table.sql @@ -0,0 +1,81 @@ +-- Migration V10: Création de la table bookings pour remplacer/étendre reservations +-- Date: 2026-01-15 +-- Description: Nouvelle table pour les réservations avec plus de détails (v2.0) + +-- Note: Cette migration crée une nouvelle table "bookings" qui remplace/étend "reservations" +-- La table reservations peut être conservée pour compatibilité ou migrée progressivement + +CREATE TABLE IF NOT EXISTS bookings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + establishment_id UUID NOT NULL, + user_id UUID NOT NULL, + reservation_time TIMESTAMP NOT NULL, -- Date et heure de la réservation + guest_count INTEGER NOT NULL DEFAULT 1, -- Nombre de convives + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, CONFIRMED, CANCELLED, COMPLETED + special_requests TEXT, -- Demandes spéciales (allergies, préférences, etc.) + table_number VARCHAR(20), -- Numéro de table assigné (si applicable) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT fk_bookings_establishment + FOREIGN KEY (establishment_id) + REFERENCES establishments(id) + ON DELETE CASCADE, + + CONSTRAINT fk_bookings_user + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE CASCADE, + + CONSTRAINT chk_bookings_guest_count + CHECK (guest_count > 0), + + CONSTRAINT chk_bookings_status + CHECK (status IN ('PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED')) +); + +-- Créer les index pour améliorer les performances +CREATE INDEX IF NOT EXISTS idx_bookings_establishment +ON bookings(establishment_id); + +CREATE INDEX IF NOT EXISTS idx_bookings_user +ON bookings(user_id); + +CREATE INDEX IF NOT EXISTS idx_bookings_status +ON bookings(status); + +CREATE INDEX IF NOT EXISTS idx_bookings_reservation_time +ON bookings(reservation_time); + +CREATE INDEX IF NOT EXISTS idx_bookings_establishment_time +ON bookings(establishment_id, reservation_time); + +-- Index pour les requêtes de réservations à venir +CREATE INDEX IF NOT EXISTS idx_bookings_upcoming +ON bookings(establishment_id, reservation_time) +WHERE reservation_time >= CURRENT_TIMESTAMP AND status IN ('PENDING', 'CONFIRMED'); + +-- Créer un trigger pour mettre à jour updated_at automatiquement +CREATE OR REPLACE FUNCTION update_bookings_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_bookings_updated_at + BEFORE UPDATE ON bookings + FOR EACH ROW + EXECUTE FUNCTION update_bookings_updated_at(); + +-- Commentaires pour documentation +COMMENT ON TABLE bookings IS 'Réservations d''établissements (v2.0 - remplace/étend reservations)'; +COMMENT ON COLUMN bookings.establishment_id IS 'Établissement concerné par la réservation'; +COMMENT ON COLUMN bookings.user_id IS 'Utilisateur qui a fait la réservation'; +COMMENT ON COLUMN bookings.reservation_time IS 'Date et heure de la réservation'; +COMMENT ON COLUMN bookings.guest_count IS 'Nombre de convives'; +COMMENT ON COLUMN bookings.status IS 'Statut: PENDING, CONFIRMED, CANCELLED, COMPLETED'; +COMMENT ON COLUMN bookings.special_requests IS 'Demandes spéciales (allergies, préférences, etc.)'; +COMMENT ON COLUMN bookings.table_number IS 'Numéro de table assigné (si applicable)'; + diff --git a/src/main/resources/db/migration/V11__Create_Promotions_Table.sql b/src/main/resources/db/migration/V11__Create_Promotions_Table.sql new file mode 100644 index 0000000..a2c6d96 --- /dev/null +++ b/src/main/resources/db/migration/V11__Create_Promotions_Table.sql @@ -0,0 +1,80 @@ +-- Migration V11: Création de la table promotions pour les offres spéciales +-- Date: 2026-01-15 +-- Description: Table pour gérer les promotions et happy hours des établissements (v2.0) + +CREATE TABLE IF NOT EXISTS promotions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + establishment_id UUID NOT NULL, + title VARCHAR(200) NOT NULL, -- Titre de la promotion (ex: "Happy Hour", "Menu du jour") + description TEXT, -- Description détaillée de la promotion + promo_code VARCHAR(50) UNIQUE, -- Code promo optionnel (ex: "HAPPY2024") + discount_type VARCHAR(20) NOT NULL, -- PERCENTAGE, FIXED_AMOUNT, FREE_ITEM + discount_value NUMERIC(10,2) NOT NULL, -- Valeur de la réduction (pourcentage, montant fixe, etc.) + valid_from TIMESTAMP NOT NULL, -- Date de début de validité + valid_until TIMESTAMP NOT NULL, -- Date de fin de validité + is_active BOOLEAN DEFAULT true NOT NULL, -- Indique si la promotion est active + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT fk_promotions_establishment + FOREIGN KEY (establishment_id) + REFERENCES establishments(id) + ON DELETE CASCADE, + + CONSTRAINT chk_promotions_discount_type + CHECK (discount_type IN ('PERCENTAGE', 'FIXED_AMOUNT', 'FREE_ITEM')), + + CONSTRAINT chk_promotions_discount_value + CHECK (discount_value >= 0), + + CONSTRAINT chk_promotions_valid_dates + CHECK (valid_until > valid_from) +); + +-- Créer les index pour améliorer les performances +CREATE INDEX IF NOT EXISTS idx_promotions_establishment +ON promotions(establishment_id); + +CREATE INDEX IF NOT EXISTS idx_promotions_active +ON promotions(establishment_id, is_active) +WHERE is_active = true; + +CREATE INDEX IF NOT EXISTS idx_promotions_valid_dates +ON promotions(establishment_id, valid_from, valid_until); + +-- Index pour les promotions actives et valides +CREATE INDEX IF NOT EXISTS idx_promotions_active_valid +ON promotions(establishment_id, valid_from, valid_until) +WHERE is_active = true AND valid_from <= CURRENT_TIMESTAMP AND valid_until >= CURRENT_TIMESTAMP; + +-- Index pour les recherches par code promo +CREATE INDEX IF NOT EXISTS idx_promotions_promo_code +ON promotions(promo_code) +WHERE promo_code IS NOT NULL; + +-- Créer un trigger pour mettre à jour updated_at automatiquement +CREATE OR REPLACE FUNCTION update_promotions_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_promotions_updated_at + BEFORE UPDATE ON promotions + FOR EACH ROW + EXECUTE FUNCTION update_promotions_updated_at(); + +-- Commentaires pour documentation +COMMENT ON TABLE promotions IS 'Promotions et offres spéciales des établissements (v2.0)'; +COMMENT ON COLUMN promotions.establishment_id IS 'Établissement qui propose la promotion'; +COMMENT ON COLUMN promotions.title IS 'Titre de la promotion'; +COMMENT ON COLUMN promotions.description IS 'Description détaillée de la promotion'; +COMMENT ON COLUMN promotions.promo_code IS 'Code promo optionnel pour activer la promotion'; +COMMENT ON COLUMN promotions.discount_type IS 'Type de réduction: PERCENTAGE, FIXED_AMOUNT, FREE_ITEM'; +COMMENT ON COLUMN promotions.discount_value IS 'Valeur de la réduction (pourcentage, montant fixe, etc.)'; +COMMENT ON COLUMN promotions.valid_from IS 'Date de début de validité'; +COMMENT ON COLUMN promotions.valid_until IS 'Date de fin de validité'; +COMMENT ON COLUMN promotions.is_active IS 'Indique si la promotion est active'; + 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 new file mode 100644 index 0000000..b8523de --- /dev/null +++ b/src/main/resources/db/migration/V3__Migrate_Users_To_V2.sql @@ -0,0 +1,48 @@ +-- 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 + +-- 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; + +-- Ajouter les nouvelles colonnes +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; + +-- Ajouter la colonne preferences en JSONB (PostgreSQL) +ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences JSONB DEFAULT '{}'::jsonb NOT NULL; + +-- Migrer les données de preferred_category vers preferences si nécessaire +-- Note: Cette migration préserve les données existantes +UPDATE users +SET preferences = jsonb_build_object('preferred_category', preferred_category) +WHERE preferred_category IS NOT NULL + AND (preferences IS NULL OR preferences = '{}'::jsonb); + +-- Supprimer la colonne obsolète preferred_category +-- Note: On vérifie d'abord si la colonne existe pour éviter les erreurs +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'users' + AND column_name = 'preferred_category' + ) THEN + ALTER TABLE users DROP COLUMN preferred_category; + END IF; +END $$; + +-- Créer un index sur preferences si nécessaire (pour les requêtes JSONB) +CREATE INDEX IF NOT EXISTS idx_users_preferences ON users USING GIN (preferences); + +-- Commentaires pour documentation +COMMENT ON COLUMN users.first_name IS 'Prénom de l''utilisateur (v2.0)'; +COMMENT ON COLUMN users.last_name IS 'Nom de famille de l''utilisateur (v2.0)'; +COMMENT ON COLUMN users.password_hash IS 'Mot de passe hashé avec BCrypt (v2.0)'; +COMMENT ON COLUMN users.bio IS 'Biographie courte de l''utilisateur (v2.0)'; +COMMENT ON COLUMN users.loyalty_points IS 'Points de fidélité accumulés (v2.0)'; +COMMENT ON COLUMN users.preferences IS 'Préférences utilisateur en JSONB (v2.0)'; + diff --git a/src/main/resources/db/migration/V4__Migrate_Establishments_To_V2.sql b/src/main/resources/db/migration/V4__Migrate_Establishments_To_V2.sql new file mode 100644 index 0000000..2d64cf9 --- /dev/null +++ b/src/main/resources/db/migration/V4__Migrate_Establishments_To_V2.sql @@ -0,0 +1,74 @@ +-- Migration V4: Migration de la table establishments vers l'architecture v2.0 +-- Date: 2026-01-15 +-- Description: Renommage des colonnes, ajout de verification_status, suppression des colonnes obsolètes + +-- Renommer la colonne total_ratings_count vers total_reviews_count +ALTER TABLE establishments RENAME COLUMN total_ratings_count TO total_reviews_count; + +-- Ajouter la colonne verification_status +ALTER TABLE establishments ADD COLUMN IF NOT EXISTS verification_status VARCHAR(20) DEFAULT 'PENDING' NOT NULL; + +-- Supprimer les colonnes obsolètes (avec vérification pour éviter les erreurs) +DO $$ +BEGIN + -- Supprimer rating (déprécié, utiliser average_rating) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'establishments' AND column_name = 'rating' + ) THEN + ALTER TABLE establishments DROP COLUMN rating; + END IF; + + -- Supprimer email (délégué à la table users via manager_id) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'establishments' AND column_name = 'email' + ) THEN + ALTER TABLE establishments DROP COLUMN email; + END IF; + + -- Supprimer image_url (utiliser establishment_media à la place) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'establishments' AND column_name = 'image_url' + ) THEN + ALTER TABLE establishments DROP COLUMN image_url; + END IF; + + -- Supprimer capacity (non utilisé dans v2.0) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'establishments' AND column_name = 'capacity' + ) THEN + ALTER TABLE establishments DROP COLUMN capacity; + END IF; + + -- Supprimer amenities (délégué à la table establishment_amenities) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'establishments' AND column_name = 'amenities' + ) THEN + -- Optionnel: Migrer les données vers establishment_amenities avant suppression + -- (à implémenter si nécessaire) + ALTER TABLE establishments DROP COLUMN amenities; + END IF; + + -- Supprimer opening_hours (délégué à la table business_hours) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'establishments' AND column_name = 'opening_hours' + ) THEN + -- Optionnel: Migrer les données vers business_hours avant suppression + -- (à implémenter si nécessaire) + ALTER TABLE establishments DROP COLUMN opening_hours; + END IF; +END $$; + +-- Créer un index sur verification_status pour les requêtes de filtrage +CREATE INDEX IF NOT EXISTS idx_establishments_verification_status +ON establishments(verification_status); + +-- Commentaires pour documentation +COMMENT ON COLUMN establishments.total_reviews_count IS 'Nombre total d''avis (v2.0 - renommé depuis total_ratings_count)'; +COMMENT ON COLUMN establishments.verification_status IS 'Statut de vérification: PENDING, VERIFIED, REJECTED (v2.0)'; + 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 new file mode 100644 index 0000000..b9be2bc --- /dev/null +++ b/src/main/resources/db/migration/V5__Create_Business_Hours_Table.sql @@ -0,0 +1,64 @@ +-- Migration V5: Création de la table business_hours pour gérer les horaires d'ouverture +-- Date: 2026-01-15 +-- Description: Table dédiée pour les horaires d'ouverture des établissements (v2.0) + +CREATE TABLE IF NOT EXISTS business_hours ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + establishment_id UUID NOT NULL, + day_of_week VARCHAR(20) NOT NULL, -- MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY + open_time VARCHAR(5) NOT NULL, -- Format HH:MM (ex: "09:00") + close_time VARCHAR(5) NOT NULL, -- Format HH:MM (ex: "18:00") + is_closed BOOLEAN DEFAULT false NOT NULL, + is_exception BOOLEAN DEFAULT false NOT NULL, -- Pour les jours exceptionnels (fermetures temporaires) + exception_date TIMESTAMP, -- Date de l'exception (si is_exception = true) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT fk_business_hours_establishment + FOREIGN KEY (establishment_id) + REFERENCES establishments(id) + ON DELETE CASCADE, + + CONSTRAINT chk_business_hours_time_format + CHECK (open_time ~ '^([0-1][0-9]|2[0-3]):[0-5][0-9]$' + AND close_time ~ '^([0-1][0-9]|2[0-3]):[0-5][0-9]$'), + + CONSTRAINT chk_business_hours_day_of_week + CHECK (day_of_week IN ('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY')) +); + +-- Créer les index pour améliorer les performances +CREATE INDEX IF NOT EXISTS idx_business_hours_establishment +ON business_hours(establishment_id); + +CREATE INDEX IF NOT EXISTS idx_business_hours_day_of_week +ON business_hours(day_of_week); + +CREATE INDEX IF NOT EXISTS idx_business_hours_exception +ON business_hours(establishment_id, exception_date) +WHERE is_exception = true; + +-- Créer un trigger pour mettre à jour updated_at automatiquement +CREATE OR REPLACE FUNCTION update_business_hours_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_business_hours_updated_at + BEFORE UPDATE ON business_hours + FOR EACH ROW + EXECUTE FUNCTION update_business_hours_updated_at(); + +-- Commentaires pour documentation +COMMENT ON TABLE business_hours IS 'Horaires d''ouverture des établissements (v2.0)'; +COMMENT ON COLUMN business_hours.establishment_id IS 'Référence vers l''établissement'; +COMMENT ON COLUMN business_hours.day_of_week IS 'Jour de la semaine (MONDAY à SUNDAY)'; +COMMENT ON COLUMN business_hours.open_time IS 'Heure d''ouverture (format HH:MM)'; +COMMENT ON COLUMN business_hours.close_time IS 'Heure de fermeture (format HH:MM)'; +COMMENT ON COLUMN business_hours.is_closed IS 'Indique si l''établissement est fermé ce jour'; +COMMENT ON COLUMN business_hours.is_exception IS 'Indique si c''est un jour exceptionnel'; +COMMENT ON COLUMN business_hours.exception_date IS 'Date de l''exception (si is_exception = true)'; + diff --git a/src/main/resources/db/migration/V6__Create_Establishment_Amenities_Table.sql b/src/main/resources/db/migration/V6__Create_Establishment_Amenities_Table.sql new file mode 100644 index 0000000..1b80c90 --- /dev/null +++ b/src/main/resources/db/migration/V6__Create_Establishment_Amenities_Table.sql @@ -0,0 +1,65 @@ +-- Migration V6: Création de la table establishment_amenities pour gérer les équipements +-- Date: 2026-01-15 +-- Description: Table de liaison pour les équipements des établissements (v2.0) + +-- Créer d'abord une table de référence pour les types d'équipements (optionnel mais recommandé) +CREATE TABLE IF NOT EXISTS amenity_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, -- Ex: "WiFi", "Parking", "Terrasse", "Climatisation" + category VARCHAR(50), -- Ex: "Comfort", "Accessibility", "Entertainment" + icon VARCHAR(50), -- Nom de l'icône à utiliser dans l'UI + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT chk_amenity_types_name_not_empty CHECK (LENGTH(TRIM(name)) > 0) +); + +-- Créer la table de liaison establishment_amenities +CREATE TABLE IF NOT EXISTS establishment_amenities ( + establishment_id UUID NOT NULL, + amenity_id UUID NOT NULL, + details VARCHAR(500), -- Détails supplémentaires (ex: "Parking gratuit pour 20 voitures") + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + + PRIMARY KEY (establishment_id, amenity_id), + + CONSTRAINT fk_establishment_amenities_establishment + FOREIGN KEY (establishment_id) + REFERENCES establishments(id) + ON DELETE CASCADE, + + CONSTRAINT fk_establishment_amenities_amenity + FOREIGN KEY (amenity_id) + REFERENCES amenity_types(id) + ON DELETE CASCADE +); + +-- Créer les index pour améliorer les performances +CREATE INDEX IF NOT EXISTS idx_establishment_amenities_establishment +ON establishment_amenities(establishment_id); + +CREATE INDEX IF NOT EXISTS idx_establishment_amenities_amenity +ON establishment_amenities(amenity_id); + +-- Insérer quelques types d'équipements courants (optionnel) +INSERT INTO amenity_types (name, category, icon) VALUES + ('WiFi', 'Comfort', 'wifi'), + ('Parking', 'Accessibility', 'parking'), + ('Terrasse', 'Comfort', 'terrace'), + ('Climatisation', 'Comfort', 'ac'), + ('Chauffage', 'Comfort', 'heating'), + ('Accessible PMR', 'Accessibility', 'accessible'), + ('Animaux acceptés', 'Comfort', 'pets'), + ('Réservation en ligne', 'Service', 'online_booking'), + ('Service de livraison', 'Service', 'delivery'), + ('Bar', 'Entertainment', 'bar'), + ('Musique live', 'Entertainment', 'live_music'), + ('Écrans TV', 'Entertainment', 'tv') +ON CONFLICT (name) DO NOTHING; -- Éviter les doublons + +-- Commentaires pour documentation +COMMENT ON TABLE amenity_types IS 'Types d''équipements disponibles pour les établissements (v2.0)'; +COMMENT ON TABLE establishment_amenities IS 'Liaison entre établissements et équipements (v2.0)'; +COMMENT ON COLUMN establishment_amenities.establishment_id IS 'Référence vers l''établissement'; +COMMENT ON COLUMN establishment_amenities.amenity_id IS 'Référence vers le type d''équipement'; +COMMENT ON COLUMN establishment_amenities.details IS 'Détails supplémentaires sur l''équipement'; + diff --git a/src/main/resources/db/migration/V7__Migrate_Events_To_V2.sql b/src/main/resources/db/migration/V7__Migrate_Events_To_V2.sql new file mode 100644 index 0000000..8eeece1 --- /dev/null +++ b/src/main/resources/db/migration/V7__Migrate_Events_To_V2.sql @@ -0,0 +1,55 @@ +-- Migration V7: Migration de la table events vers l'architecture v2.0 +-- Date: 2026-01-15 +-- Description: Ajout de establishment_id, is_private, waitlist_enabled et suppression de location + +-- Ajouter la colonne establishment_id (FK vers establishments) +ALTER TABLE events ADD COLUMN IF NOT EXISTS establishment_id UUID; + +-- Créer la contrainte de clé étrangère pour establishment_id +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_events_establishment' + ) THEN + ALTER TABLE events + ADD CONSTRAINT fk_events_establishment + FOREIGN KEY (establishment_id) + REFERENCES establishments(id) + ON DELETE SET NULL; -- Si l'établissement est supprimé, mettre NULL plutôt que supprimer l'événement + END IF; +END $$; + +-- Ajouter la colonne is_private +ALTER TABLE events ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT false NOT NULL; + +-- Ajouter la colonne waitlist_enabled +ALTER TABLE events ADD COLUMN IF NOT EXISTS waitlist_enabled BOOLEAN DEFAULT false NOT NULL; + +-- Créer un index sur establishment_id pour améliorer les performances +CREATE INDEX IF NOT EXISTS idx_events_establishment +ON events(establishment_id); + +-- Créer un index sur is_private pour les requêtes de filtrage +CREATE INDEX IF NOT EXISTS idx_events_is_private +ON events(is_private); + +-- Supprimer la colonne location (déléguée à Establishment via establishment_id) +DO $$ +BEGIN + -- Optionnel: Migrer les données de location vers establishment_id si possible + -- (nécessite une logique de mapping personnalisée) + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'events' AND column_name = 'location' + ) THEN + ALTER TABLE events DROP COLUMN location; + END IF; +END $$; + +-- Commentaires pour documentation +COMMENT ON COLUMN events.establishment_id IS 'Référence vers l''établissement où se déroule l''événement (v2.0)'; +COMMENT ON COLUMN events.is_private IS 'Indique si l''événement est privé (v2.0)'; +COMMENT ON COLUMN events.waitlist_enabled IS 'Indique si la liste d''attente est activée (v2.0)'; + diff --git a/src/main/resources/db/migration/V8__Create_Reviews_Table.sql b/src/main/resources/db/migration/V8__Create_Reviews_Table.sql new file mode 100644 index 0000000..dbf4100 --- /dev/null +++ b/src/main/resources/db/migration/V8__Create_Reviews_Table.sql @@ -0,0 +1,97 @@ +-- Migration V8: Création de la table reviews pour remplacer establishment_ratings +-- Date: 2026-01-15 +-- Description: Nouvelle table pour les avis détaillés avec critères (v2.0) + +-- Note: Cette migration crée une nouvelle table "reviews" qui remplace "establishment_ratings" +-- La table establishment_ratings peut être conservée pour compatibilité ou migrée progressivement + +CREATE TABLE IF NOT EXISTS reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + establishment_id UUID NOT NULL, + overall_rating INTEGER NOT NULL, -- Note globale (1-5) + comment TEXT, -- Commentaire libre + criteria_ratings JSONB DEFAULT '{}'::jsonb NOT NULL, -- Notes par critères (ex: {"ambiance": 4, "service": 5, "qualite": 4}) + is_verified_visit BOOLEAN DEFAULT false NOT NULL, -- Indique si l'avis est lié à une visite vérifiée (réservation complétée) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT fk_reviews_user + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE CASCADE, + + CONSTRAINT fk_reviews_establishment + FOREIGN KEY (establishment_id) + REFERENCES establishments(id) + ON DELETE CASCADE, + + CONSTRAINT chk_reviews_overall_rating_range + CHECK (overall_rating >= 1 AND overall_rating <= 5), + + -- Un utilisateur ne peut avoir qu'un seul avis par établissement + CONSTRAINT uq_reviews_user_establishment + UNIQUE (user_id, establishment_id) +); + +-- Créer les index pour améliorer les performances +CREATE INDEX IF NOT EXISTS idx_reviews_user +ON reviews(user_id); + +CREATE INDEX IF NOT EXISTS idx_reviews_establishment +ON reviews(establishment_id); + +CREATE INDEX IF NOT EXISTS idx_reviews_overall_rating +ON reviews(establishment_id, overall_rating); + +CREATE INDEX IF NOT EXISTS idx_reviews_created_at +ON reviews(created_at DESC); + +-- Index GIN pour les requêtes JSONB sur criteria_ratings +CREATE INDEX IF NOT EXISTS idx_reviews_criteria_ratings +ON reviews USING GIN (criteria_ratings); + +-- Index pour les avis vérifiés +CREATE INDEX IF NOT EXISTS idx_reviews_verified_visit +ON reviews(establishment_id, is_verified_visit) +WHERE is_verified_visit = true; + +-- Créer un trigger pour mettre à jour updated_at automatiquement +CREATE OR REPLACE FUNCTION update_reviews_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_reviews_updated_at + BEFORE UPDATE ON reviews + FOR EACH ROW + EXECUTE FUNCTION update_reviews_updated_at(); + +-- Créer une vue pour calculer les statistiques d'avis par établissement +CREATE OR REPLACE VIEW establishment_review_stats AS +SELECT + establishment_id, + COUNT(*) as total_reviews, + AVG(overall_rating)::NUMERIC(3,2) as average_rating, + COUNT(*) FILTER (WHERE is_verified_visit = true) as verified_reviews_count, + COUNT(*) FILTER (WHERE overall_rating = 5) as five_star_count, + COUNT(*) FILTER (WHERE overall_rating = 4) as four_star_count, + COUNT(*) FILTER (WHERE overall_rating = 3) as three_star_count, + COUNT(*) FILTER (WHERE overall_rating = 2) as two_star_count, + COUNT(*) FILTER (WHERE overall_rating = 1) as one_star_count +FROM reviews +GROUP BY establishment_id; + +-- Commentaires pour documentation +COMMENT ON TABLE reviews IS 'Avis détaillés sur les établissements (v2.0 - remplace establishment_ratings)'; +COMMENT ON COLUMN reviews.user_id IS 'Utilisateur qui a écrit l''avis'; +COMMENT ON COLUMN reviews.establishment_id IS 'Établissement concerné par l''avis'; +COMMENT ON COLUMN reviews.overall_rating IS 'Note globale sur 5'; +COMMENT ON COLUMN reviews.comment IS 'Commentaire libre de l''utilisateur'; +COMMENT ON COLUMN reviews.criteria_ratings IS 'Notes par critères en JSONB (ex: {"ambiance": 4, "service": 5})'; +COMMENT ON COLUMN reviews.is_verified_visit IS 'Indique si l''avis est lié à une visite vérifiée (réservation complétée)'; +COMMENT ON VIEW establishment_review_stats IS 'Statistiques d''avis par établissement (calculées automatiquement)'; + diff --git a/src/main/resources/db/migration/V9__Create_Reactions_Table.sql b/src/main/resources/db/migration/V9__Create_Reactions_Table.sql new file mode 100644 index 0000000..cc776e8 --- /dev/null +++ b/src/main/resources/db/migration/V9__Create_Reactions_Table.sql @@ -0,0 +1,64 @@ +-- Migration V9: Création de la table reactions pour remplacer les compteurs de réactions +-- Date: 2026-01-15 +-- Description: Table pour gérer les réactions des utilisateurs (like, love, etc.) sur différents contenus (v2.0) + +CREATE TABLE IF NOT EXISTS reactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + reaction_type VARCHAR(20) NOT NULL, -- LIKE, LOVE, HAHA, WOW, SAD, ANGRY + target_type VARCHAR(20) NOT NULL, -- POST, EVENT, COMMENT, REVIEW + target_id UUID NOT NULL, -- ID du contenu ciblé (post_id, event_id, comment_id, review_id) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT fk_reactions_user + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE CASCADE, + + CONSTRAINT chk_reactions_type + CHECK (reaction_type IN ('LIKE', 'LOVE', 'HAHA', 'WOW', 'SAD', 'ANGRY')), + + CONSTRAINT chk_reactions_target_type + CHECK (target_type IN ('POST', 'EVENT', 'COMMENT', 'REVIEW')), + + -- Un utilisateur ne peut avoir qu'une seule réaction par contenu + -- (mais peut changer de type de réaction) + CONSTRAINT uq_reactions_user_target + UNIQUE (user_id, target_type, target_id) +); + +-- Créer les index pour améliorer les performances +CREATE INDEX IF NOT EXISTS idx_reactions_user +ON reactions(user_id); + +CREATE INDEX IF NOT EXISTS idx_reactions_target +ON reactions(target_type, target_id); + +CREATE INDEX IF NOT EXISTS idx_reactions_type +ON reactions(reaction_type); + +CREATE INDEX IF NOT EXISTS idx_reactions_created_at +ON reactions(created_at DESC); + +-- Index composite pour les requêtes fréquentes (compter les réactions par type et cible) +CREATE INDEX IF NOT EXISTS idx_reactions_target_type +ON reactions(target_type, target_id, reaction_type); + +-- Créer une vue pour compter les réactions par type et cible +CREATE OR REPLACE VIEW reaction_counts AS +SELECT + target_type, + target_id, + reaction_type, + COUNT(*) as count +FROM reactions +GROUP BY target_type, target_id, reaction_type; + +-- Commentaires pour documentation +COMMENT ON TABLE reactions IS 'Réactions des utilisateurs sur différents contenus (v2.0 - remplace les compteurs)'; +COMMENT ON COLUMN reactions.user_id IS 'Utilisateur qui a réagi'; +COMMENT ON COLUMN reactions.reaction_type IS 'Type de réaction: LIKE, LOVE, HAHA, WOW, SAD, ANGRY'; +COMMENT ON COLUMN reactions.target_type IS 'Type de contenu ciblé: POST, EVENT, COMMENT, REVIEW'; +COMMENT ON COLUMN reactions.target_id IS 'ID du contenu ciblé'; +COMMENT ON VIEW reaction_counts IS 'Compteurs de réactions par type et cible (calculés automatiquement)'; +