From c31c6174ccaf77d0738527abe9befc4beb96ab57 Mon Sep 17 00:00:00 2001 From: dahoud Date: Wed, 4 Feb 2026 01:06:17 +0000 Subject: [PATCH] Refactoring --- .mvn/jvm.config | 1 + REALTIME_DEV.md | 119 ++++++++++++++++++ .../core/errors/GlobalExceptionHandler.java | 4 + .../chat/ConversationResponseDTO.java | 3 + ...EstablishmentHasDependenciesException.java | 15 +++ .../dev/repository/BookingRepository.java | 11 ++ .../dev/repository/EventsRepository.java | 21 ++++ .../dev/resource/EstablishmentResource.java | 11 +- .../dev/resource/FileUploadResource.java | 31 ++++- .../dev/service/EstablishmentService.java | 22 ++++ .../dev/service/NotificationService.java | 84 ++++++++----- .../com/lions/dev/service/UsersService.java | 12 +- .../dev/websocket/ChatWebSocketNext.java | 6 +- .../websocket/NotificationWebSocketNext.java | 26 ++++ .../dev/websocket/bridge/ChatKafkaBridge.java | 6 +- .../bridge/NotificationKafkaBridge.java | 28 +++-- src/main/resources/application-dev.properties | 26 ++-- src/main/resources/application.properties | 2 +- .../EstablishmentRatingServiceTest.java | 2 +- 19 files changed, 360 insertions(+), 70 deletions(-) create mode 100644 REALTIME_DEV.md create mode 100644 src/main/java/com/lions/dev/exception/EstablishmentHasDependenciesException.java diff --git a/.mvn/jvm.config b/.mvn/jvm.config index 89ad17d..e606fba 100644 --- a/.mvn/jvm.config +++ b/.mvn/jvm.config @@ -1,3 +1,4 @@ -Xmx2048m -Xms1024m -XX:MaxMetaspaceSize=512m +-Dfile.encoding=UTF-8 diff --git a/REALTIME_DEV.md b/REALTIME_DEV.md new file mode 100644 index 0000000..09ee6dc --- /dev/null +++ b/REALTIME_DEV.md @@ -0,0 +1,119 @@ +# Temps réel en développement (Kafka + WebSocket) + +Ce guide permet de faire fonctionner les **notifications / présence / réactions / chat** en temps réel en environnement de développement. + +## Architecture + +``` +Services métier → Kafka (topics) → Bridges → WebSocket → Client Flutter +``` + +- **Topics Kafka** : `notifications`, `chat.messages`, `reactions`, `presence.updates` +- **WebSocket** : `ws:///notifications/` (et `/chat/` pour le chat) + +## 1. Démarrer Kafka en local + +Un conteneur Kafka doit être joignable sur le **port 9092** depuis la machine où tourne Quarkus. + +### Option A : Conteneur existant + +Si vous avez déjà un conteneur Kafka (ex. ID `e100552d0da2...`) : + +- Vérifiez que le port **9092** est exposé vers l’hôte : + ```bash + docker port 9092 + ``` +- Si rien n’est mappé, recréez le conteneur avec `-p 9092:9092` ou dans un `docker-compose` : + ```yaml + kafka: + image: apache/kafka-native:latest # ou quay.io/strimzi/kafka:latest, etc. + ports: + - "9092:9092" + # ... reste de la config (KAFKA_CFG_..., etc.) + ``` + +### Option B : Lancer Kafka avec Docker (exemple minimal) + +```bash +docker run -d --name kafka-dev -p 9092:9092 \ + -e KAFKA_NODE_ID=1 \ + -e KAFKA_PROCESS_ROLES=broker,controller \ + -e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093 \ + apache/kafka-native:latest +``` + +(Adaptez l’image et les variables à votre setup si vous en utilisez un autre.) + +### Depuis une autre machine / Docker + +- **Quarkus sur l’hôte, Kafka dans Docker** : `localhost:9092` suffit si le port est mappé (`-p 9092:9092`). +- **Quarkus dans Docker, Kafka sur l’hôte** : utilisez `host.docker.internal:9092` (Windows/Mac) ou l’IP de l’hôte. +- Définir alors : + ```bash + export KAFKA_BOOTSTRAP_SERVERS=localhost:9092 + ``` + (ou `host.docker.internal:9092` selon le cas). + +## 2. Démarrer le backend Quarkus (profil dev) + +```bash +cd mic-after-work-server-impl-quarkus-main +mvn quarkus:dev +``` + +Le fichier `application-dev.properties` utilise par défaut `localhost:9092`. +En cas d’erreur de connexion Kafka au démarrage, vérifiez que Kafka écoute bien sur 9092 et que `KAFKA_BOOTSTRAP_SERVERS` pointe vers ce broker. + +Logs utiles au démarrage : + +- `[KAFKA-BRIDGE] Bridge démarré pour topic: notifications` +- Pas d’exception type `ConfigException` / « No resolvable bootstrap urls » + +Quand une notification est publiée et consommée : + +- `[KAFKA-BRIDGE] Événement reçu: type=... userId=...` +- `[WS-NEXT] Notification envoyée à (Succès: 1, Échec: 0)` + +## 3. Configurer l’app Flutter (URL du backend) + +Le client doit pouvoir joindre le **HTTP** et le **WebSocket** du même backend. + +- **Émulateur Android** : souvent `http://10.0.2.2:8080` (puis WebSocket `ws://10.0.2.2:8080/notifications/`). +- **Appareil physique / même réseau** : IP de la machine qui fait tourner Quarkus, ex. `http://192.168.1.103:8080`. +- **Chrome / web** : `http://localhost:8080` si Flutter web et Quarkus sont sur la même machine. + +Définir cette URL comme base API (elle est aussi utilisée pour le WebSocket) : + +- Au run : + ```bash + flutter run --dart-define=API_BASE_URL=http://:8080 + ``` +- Ou dans `lib/core/constants/env_config.dart` (valeur par défaut en dev). + +Important : **pas de slash final** dans `API_BASE_URL` (ex. `http://192.168.1.103:8080`). + +## 4. Vérifier que le temps réel fonctionne + +1. **Connexion WebSocket** + - Se connecter dans l’app avec un utilisateur. + - Côté Flutter : log du type « Connecté avec succès au service de notifications ». + - Côté Quarkus : `[WS-NEXT] Connexion ouverte pour l'utilisateur: `. + +2. **Notification (ex. demande d’ami / post)** + - Déclencher une action qui crée une notification (autre compte ou service). + - Côté Quarkus : `[KAFKA-BRIDGE] Événement reçu` puis `[WS-NEXT] Notification envoyée à ...`. + - Côté Flutter : la notification doit apparaître sans recharger (si l’écran écoute le stream temps réel). + +3. **Si rien n’arrive** + - Kafka : le broker est-il bien sur le port 9092 ? `KAFKA_BOOTSTRAP_SERVERS` correct ? + - WebSocket : l’URL dans l’app est-elle exactement celle du backend (même hôte/port) ? + - CORS : pour Flutter web, le backend doit autoriser l’origine de l’app (déjà géré dans la config actuelle si vous n’avez pas changé l’origine). + +## 5. Résumé des variables utiles (dev) + +| Variable | Rôle | Exemple | +|----------|------|--------| +| `KAFKA_BOOTSTRAP_SERVERS` | Broker Kafka pour Quarkus | `localhost:9092` ou `host.docker.internal:9092` | +| `API_BASE_URL` (Flutter) | Base HTTP + WS du backend | `http://192.168.1.103:8080` | + +Aucune régression fonctionnelle n’est introduite par ce guide : seules la configuration dev et le format des messages WebSocket (timestamp/type dans `data`) ont été alignés pour le client. diff --git a/src/main/java/com/lions/dev/core/errors/GlobalExceptionHandler.java b/src/main/java/com/lions/dev/core/errors/GlobalExceptionHandler.java index e0ccaa1..6e27e55 100644 --- a/src/main/java/com/lions/dev/core/errors/GlobalExceptionHandler.java +++ b/src/main/java/com/lions/dev/core/errors/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import com.lions.dev.core.errors.exceptions.EventNotFoundException; import com.lions.dev.core.errors.exceptions.NotFoundException; import com.lions.dev.core.errors.exceptions.ServerException; import com.lions.dev.core.errors.exceptions.UnauthorizedException; +import com.lions.dev.exception.UserNotFoundException; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.ext.ExceptionMapper; import jakarta.ws.rs.ext.Provider; @@ -30,6 +31,9 @@ public class GlobalExceptionHandler implements ExceptionMapper { if (exception instanceof BadRequestException) { logger.warn("BadRequestException intercepted: " + exception.getMessage()); return buildResponse(Response.Status.BAD_REQUEST, exception.getMessage()); + } else if (exception instanceof UserNotFoundException) { + logger.warn("UserNotFoundException (404): " + exception.getMessage()); + return buildResponse(Response.Status.NOT_FOUND, exception.getMessage()); } else if (exception instanceof EventNotFoundException || exception instanceof NotFoundException) { logger.warn("NotFoundException intercepted: " + exception.getMessage()); return buildResponse(Response.Status.NOT_FOUND, exception.getMessage()); 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 2da84ee..1fccb9f 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 @@ -26,6 +26,8 @@ public class ConversationResponseDTO { private LocalDateTime lastMessageTimestamp; private int unreadCount; private boolean isTyping; + /** Indique si le participant (l'autre utilisateur) est actuellement en ligne (WebSocket notifications). */ + private boolean participantIsOnline; /** * Constructeur depuis une entité Conversation. @@ -44,6 +46,7 @@ public class ConversationResponseDTO { this.participantFirstName = otherUser.getFirstName(); this.participantLastName = otherUser.getLastName(); this.participantProfileImageUrl = otherUser.getProfileImageUrl(); + this.participantIsOnline = otherUser.isOnline(); } this.lastMessage = conversation.getLastMessageContent(); diff --git a/src/main/java/com/lions/dev/exception/EstablishmentHasDependenciesException.java b/src/main/java/com/lions/dev/exception/EstablishmentHasDependenciesException.java new file mode 100644 index 0000000..141a417 --- /dev/null +++ b/src/main/java/com/lions/dev/exception/EstablishmentHasDependenciesException.java @@ -0,0 +1,15 @@ +package com.lions.dev.exception; + +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +/** + * Exception levée lorsqu'on tente de supprimer un établissement qui a encore + * des dépendances (événements, réservations). Renvoie une réponse HTTP 409 (Conflict). + */ +public class EstablishmentHasDependenciesException extends WebApplicationException { + + public EstablishmentHasDependenciesException(String message) { + super(message, Response.Status.CONFLICT); + } +} diff --git a/src/main/java/com/lions/dev/repository/BookingRepository.java b/src/main/java/com/lions/dev/repository/BookingRepository.java index 9dbf34e..af6f988 100644 --- a/src/main/java/com/lions/dev/repository/BookingRepository.java +++ b/src/main/java/com/lions/dev/repository/BookingRepository.java @@ -13,4 +13,15 @@ public class BookingRepository implements PanacheRepositoryBase { public List findByUserId(UUID userId) { return list("user.id", userId); } + + /** + * Compte le nombre de réservations d'un établissement. + * Utilisé pour refuser la suppression si des réservations existent (règle métier). + * + * @param establishmentId L'ID de l'établissement + * @return Nombre de réservations + */ + public long countByEstablishmentId(UUID establishmentId) { + return count("establishment.id", establishmentId); + } } diff --git a/src/main/java/com/lions/dev/repository/EventsRepository.java b/src/main/java/com/lions/dev/repository/EventsRepository.java index 77efeb2..b66e56e 100644 --- a/src/main/java/com/lions/dev/repository/EventsRepository.java +++ b/src/main/java/com/lions/dev/repository/EventsRepository.java @@ -109,6 +109,27 @@ public class EventsRepository implements PanacheRepositoryBase { return list("startDate >= ?1 AND startDate <= ?2", from, to); } + /** + * Compte le nombre d'événements d'un établissement. + * Utilisé pour refuser la suppression si des événements existent (règle métier). + * + * @param establishmentId L'ID de l'établissement + * @return Nombre d'événements + */ + public long countByEstablishmentId(UUID establishmentId) { + return count("establishment.id", establishmentId); + } + + /** + * Supprime tous les événements d'un établissement (réservé à un usage interne / migration). + * + * @param establishmentId L'ID de l'établissement + * @return Nombre d'événements supprimés + */ + public long deleteByEstablishmentId(UUID establishmentId) { + return delete("establishment.id = ?1", establishmentId); + } + /** * Compte le nombre total de participants dans les événements ouverts et à venir * d'un établissement (pour calcul des places restantes). diff --git a/src/main/java/com/lions/dev/resource/EstablishmentResource.java b/src/main/java/com/lions/dev/resource/EstablishmentResource.java index 0acc7f1..0aeb91d 100644 --- a/src/main/java/com/lions/dev/resource/EstablishmentResource.java +++ b/src/main/java/com/lions/dev/resource/EstablishmentResource.java @@ -6,6 +6,7 @@ import com.lions.dev.dto.response.establishment.BusinessHoursResponseDTO; import com.lions.dev.dto.response.establishment.EstablishmentAmenityResponseDTO; import com.lions.dev.dto.response.establishment.EstablishmentResponseDTO; import com.lions.dev.entity.establishment.Establishment; +import com.lions.dev.exception.EstablishmentHasDependenciesException; import com.lions.dev.entity.users.Users; import com.lions.dev.repository.BusinessHoursRepository; import com.lions.dev.repository.EstablishmentAmenityRepository; @@ -25,6 +26,7 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.jboss.logging.Logger; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; @@ -367,15 +369,20 @@ public class EstablishmentResource { try { establishmentService.deleteEstablishment(id); return Response.noContent().build(); + } catch (EstablishmentHasDependenciesException e) { + LOG.warn("[WARN] " + e.getMessage()); + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("message", e.getMessage())) + .build(); } catch (RuntimeException e) { LOG.error("[ERROR] " + e.getMessage()); return Response.status(Response.Status.NOT_FOUND) - .entity(e.getMessage()) + .entity(Map.of("message", e.getMessage() != null ? e.getMessage() : "Établissement non trouvé")) .build(); } catch (Exception e) { LOG.error("[ERROR] Erreur lors de la suppression de l'établissement", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity("Erreur lors de la suppression de l'établissement") + .entity(Map.of("message", "Erreur lors de la suppression de l'établissement")) .build(); } } diff --git a/src/main/java/com/lions/dev/resource/FileUploadResource.java b/src/main/java/com/lions/dev/resource/FileUploadResource.java index e2a56f8..a242242 100644 --- a/src/main/java/com/lions/dev/resource/FileUploadResource.java +++ b/src/main/java/com/lions/dev/resource/FileUploadResource.java @@ -16,6 +16,7 @@ import org.jboss.resteasy.reactive.RestForm; import org.jboss.resteasy.reactive.multipart.FileUpload; import jakarta.inject.Inject; import java.io.IOException; +import java.util.Base64; import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -176,28 +177,50 @@ public class FileUploadResource { return baseUri + "/media/files/" + fileName; } + /** PNG 1x1 transparent (placeholder quand le fichier image est introuvable). */ + private static final byte[] PLACEHOLDER_IMAGE_PNG = Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="); + + /** + * Indique si le nom de fichier correspond à une image (extension). + */ + private boolean isImageFileName(String fileName) { + if (fileName == null) return false; + String lower = fileName.toLowerCase(); + return lower.endsWith(".jpg") || lower.endsWith(".jpeg") + || lower.endsWith(".png") || lower.endsWith(".gif") || lower.endsWith(".webp"); + } + @GET @Path("/files/{fileName}") @Produces(MediaType.APPLICATION_OCTET_STREAM) public Response getFile(@PathParam("fileName") String fileName) { try { java.nio.file.Path filePath = java.nio.file.Paths.get("/tmp/uploads/", fileName); - + if (!java.nio.file.Files.exists(filePath)) { LOG.warnf("Fichier non trouvé: %s", fileName); + // Pour les images : retourner un placeholder pour que l'affichage reste correct (pas de 404) + if (isImageFileName(fileName)) { + return Response.ok(PLACEHOLDER_IMAGE_PNG) + .type("image/png") + .header("Content-Disposition", "inline; filename=\"" + fileName + "\"") + .header("X-Placeholder", "true") + .build(); + } return Response.status(Response.Status.NOT_FOUND) .entity(createErrorResponse("Fichier non trouvé")) .build(); } - + // Déterminer le content-type String contentType = determineContentType(fileName); - + return Response.ok(filePath.toFile()) .type(contentType) .header("Content-Disposition", "inline; filename=\"" + fileName + "\"") .build(); - + } catch (Exception e) { LOG.errorf(e, "Erreur lors de la récupération du fichier: %s", fileName); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) diff --git a/src/main/java/com/lions/dev/service/EstablishmentService.java b/src/main/java/com/lions/dev/service/EstablishmentService.java index 50492ec..bada82e 100644 --- a/src/main/java/com/lions/dev/service/EstablishmentService.java +++ b/src/main/java/com/lions/dev/service/EstablishmentService.java @@ -2,7 +2,10 @@ package com.lions.dev.service; import com.lions.dev.entity.establishment.Establishment; import com.lions.dev.entity.users.Users; +import com.lions.dev.exception.EstablishmentHasDependenciesException; +import com.lions.dev.repository.BookingRepository; import com.lions.dev.repository.EstablishmentRepository; +import com.lions.dev.repository.EventsRepository; import com.lions.dev.repository.UsersRepository; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -23,6 +26,12 @@ public class EstablishmentService { @Inject EstablishmentRepository establishmentRepository; + @Inject + EventsRepository eventsRepository; + + @Inject + BookingRepository bookingRepository; + @Inject UsersRepository usersRepository; @@ -154,6 +163,19 @@ public class EstablishmentService { LOG.error("[ERROR] Établissement non trouvé avec l'ID : " + id); throw new RuntimeException("Établissement non trouvé avec l'ID : " + id); } + // Règle métier : refuser la suppression si des événements ou réservations existent + long eventsCount = eventsRepository.countByEstablishmentId(id); + if (eventsCount > 0) { + LOG.warn("[WARN] Impossible de supprimer l'établissement : " + eventsCount + " événement(s) associé(s)"); + throw new EstablishmentHasDependenciesException( + "Impossible de supprimer l'établissement : des événements y sont encore associés. Annulez ou déplacez les événements avant de supprimer l'établissement."); + } + long bookingsCount = bookingRepository.countByEstablishmentId(id); + if (bookingsCount > 0) { + LOG.warn("[WARN] Impossible de supprimer l'établissement : " + bookingsCount + " réservation(s) associée(s)"); + throw new EstablishmentHasDependenciesException( + "Impossible de supprimer l'établissement : des réservations y sont encore associées."); + } establishmentRepository.delete(establishment); LOG.info("[LOG] Établissement supprimé avec succès : " + establishment.getName()); } diff --git a/src/main/java/com/lions/dev/service/NotificationService.java b/src/main/java/com/lions/dev/service/NotificationService.java index 333a4fd..a0922c6 100644 --- a/src/main/java/com/lions/dev/service/NotificationService.java +++ b/src/main/java/com/lions/dev/service/NotificationService.java @@ -10,7 +10,10 @@ import com.lions.dev.repository.NotificationRepository; import com.lions.dev.repository.UsersRepository; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.transaction.Status; +import jakarta.transaction.Synchronization; import jakarta.transaction.Transactional; +import jakarta.transaction.TransactionManager; import org.eclipse.microprofile.reactive.messaging.Channel; import org.eclipse.microprofile.reactive.messaging.Emitter; import org.jboss.logging.Logger; @@ -43,6 +46,36 @@ public class NotificationService { @Channel("notifications") Emitter notificationEmitter; + @Inject + TransactionManager transactionManager; + + /** + * Envoie un événement Kafka après le commit de la transaction courante. + * Évite "Commit invoked while multiple threads active" quand Kafka publie sur un autre thread. + */ + private void publishToKafkaAfterCommit(NotificationEvent event) { + try { + transactionManager.getTransaction().registerSynchronization(new Synchronization() { + @Override + public void beforeCompletion() {} + + @Override + public void afterCompletion(int status) { + if (status == Status.STATUS_COMMITTED) { + try { + notificationEmitter.send(event); + logger.debug("[NotificationService] Événement publié dans Kafka après commit pour : " + event.getUserId()); + } catch (Exception e) { + logger.warn("[NotificationService] Publication Kafka après commit : " + e.getMessage()); + } + } + } + }); + } catch (Exception e) { + logger.warn("[NotificationService] Enregistrement synchronisation JTA : " + e.getMessage()); + } + } + /** * Récupère toutes les notifications d'un utilisateur. * @@ -124,43 +157,28 @@ public class NotificationService { notificationRepository.persist(notification); logger.info("[NotificationService] Notification créée avec succès : " + notification.getId()); - // Publication temps réel via Kafka → WebSocket - publishToKafka(notification, user); + // Publication temps réel via Kafka → WebSocket après commit (évite "multiple threads active" JTA) + Map data = new HashMap<>(); + data.put("notificationId", notification.getId().toString()); + data.put("title", notification.getTitle()); + data.put("message", notification.getMessage()); + data.put("isRead", notification.isRead()); + data.put("createdAt", notification.getCreatedAt() != null + ? notification.getCreatedAt().toString() : null); + if (notification.getEvent() != null) { + data.put("eventId", notification.getEvent().getId().toString()); + data.put("eventTitle", notification.getEvent().getTitle()); + } + NotificationEvent event = new NotificationEvent( + user.getId().toString(), + notification.getType(), + data + ); + publishToKafkaAfterCommit(event); return notification; } - /** - * Publie une notification dans Kafka pour livraison temps réel via WebSocket. - */ - private void publishToKafka(Notification notification, Users user) { - try { - Map data = new HashMap<>(); - data.put("notificationId", notification.getId().toString()); - data.put("title", notification.getTitle()); - data.put("message", notification.getMessage()); - data.put("isRead", notification.isRead()); - data.put("createdAt", notification.getCreatedAt() != null - ? notification.getCreatedAt().toString() : null); - - if (notification.getEvent() != null) { - data.put("eventId", notification.getEvent().getId().toString()); - data.put("eventTitle", notification.getEvent().getTitle()); - } - - NotificationEvent event = new NotificationEvent( - user.getId().toString(), - notification.getType(), - data - ); - - notificationEmitter.send(event); - logger.debug("[NotificationService] Notification publiée dans Kafka pour temps réel : " + notification.getId()); - } catch (Exception e) { - logger.warn("[NotificationService] Échec publication Kafka (non bloquant) : " + e.getMessage()); - } - } - /** * Marque une notification comme lue. * diff --git a/src/main/java/com/lions/dev/service/UsersService.java b/src/main/java/com/lions/dev/service/UsersService.java index 49e5551..dfe0b5d 100644 --- a/src/main/java/com/lions/dev/service/UsersService.java +++ b/src/main/java/com/lions/dev/service/UsersService.java @@ -85,7 +85,7 @@ public class UsersService { public Users updateUser(UUID id, UserCreateRequestDTO userCreateRequestDTO) { Users existingUser = usersRepository.findById(id); if (existingUser == null) { - logger.error("Utilisateur non trouvé avec l'ID : " + id); + logger.warn("Utilisateur non trouvé avec l'ID : " + id); throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + id); } @@ -135,7 +135,7 @@ public class UsersService { public Users updateUserProfileImage(UUID id, String profileImageUrl) { Users existingUser = usersRepository.findById(id); if (existingUser == null) { - logger.error("Utilisateur non trouvé avec l'ID : " + id); + logger.warn("Utilisateur non trouvé avec l'ID : " + id); throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + id); } @@ -195,7 +195,7 @@ public class UsersService { public Users getUserById(UUID id) { Users user = usersRepository.findById(id); if (user == null) { - logger.error("Utilisateur non trouvé avec l'ID : " + id); + logger.warn("Utilisateur non trouvé avec l'ID : " + id); throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + id); } logger.debug("Utilisateur trouvé avec l'ID : " + id); @@ -213,7 +213,7 @@ public class UsersService { public void resetPassword(UUID id, String newPassword) { Users user = usersRepository.findById(id); if (user == null) { - logger.error("Utilisateur non trouvé avec l'ID : " + id); + logger.warn("Utilisateur non trouvé avec l'ID : " + id); throw new UserNotFoundException("Utilisateur non trouvé."); } @@ -248,7 +248,7 @@ public class UsersService { public Users getUserByEmail(String email) { Optional userOptional = usersRepository.findByEmail(email); if (userOptional.isEmpty()) { - logger.error("Utilisateur non trouvé avec l'email : " + email); + logger.warn("Utilisateur non trouvé avec l'email : " + email); throw new UserNotFoundException("Utilisateur non trouvé avec l'email : " + email); } logger.debug("Utilisateur trouvé avec l'email : " + email); @@ -279,6 +279,7 @@ public class UsersService { public Users assignRole(UUID userId, String newRole) { Users user = usersRepository.findById(userId); if (user == null) { + logger.warn("Utilisateur non trouvé avec l'ID : " + userId); throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + userId); } user.setRole(newRole); @@ -300,6 +301,7 @@ public class UsersService { public Users setUserActive(UUID userId, boolean active) { Users user = usersRepository.findById(userId); if (user == null) { + logger.warn("Utilisateur non trouvé avec l'ID : " + userId); throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + userId); } user.setActive(active); diff --git a/src/main/java/com/lions/dev/websocket/ChatWebSocketNext.java b/src/main/java/com/lions/dev/websocket/ChatWebSocketNext.java index 40157c3..23f7d4c 100644 --- a/src/main/java/com/lions/dev/websocket/ChatWebSocketNext.java +++ b/src/main/java/com/lions/dev/websocket/ChatWebSocketNext.java @@ -224,13 +224,15 @@ public class ChatWebSocketNext { sessions.remove(userId); Log.debug("[CHAT-WS-NEXT] Connexion périmée supprimée pour " + userId); } - Log.debug("[CHAT-WS-NEXT] Utilisateur " + userId + " non connecté (sessions actives: " + sessions.size() + ")"); + Log.info("[CHAT-WS-NEXT] Destinataire " + userId + " non connecté, message non délivré (sessions: " + sessions.size() + ")"); return; } try { + String preview = message.length() > 300 ? message.substring(0, 300) + "..." : message; + Log.info("[CHAT-WS-NEXT] Envoi vers " + userId + " (" + message.length() + " car): " + preview); connection.sendText(message); - Log.debug("[CHAT-WS-NEXT] Message envoyé à l'utilisateur: " + userId); + Log.info("[CHAT-WS-NEXT] Message délivré à l'utilisateur: " + userId); } catch (Exception e) { Log.error("[CHAT-WS-NEXT] Erreur lors de l'envoi à " + userId + ", connexion supprimée", e); sessions.remove(userId); diff --git a/src/main/java/com/lions/dev/websocket/NotificationWebSocketNext.java b/src/main/java/com/lions/dev/websocket/NotificationWebSocketNext.java index abcb69e..052acc4 100644 --- a/src/main/java/com/lions/dev/websocket/NotificationWebSocketNext.java +++ b/src/main/java/com/lions/dev/websocket/NotificationWebSocketNext.java @@ -11,6 +11,7 @@ import jakarta.inject.Inject; import com.lions.dev.service.PresenceService; +import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -59,6 +60,10 @@ public class NotificationWebSocketNext { // Marquer l'utilisateur comme en ligne presenceService.setUserOnline(userUUID); + + // Envoyer au client la liste des utilisateurs déjà en ligne (snapshot) + // pour qu'il affiche correctement "En ligne" sans attendre les events Kafka + sendPresenceSnapshotTo(connection); } catch (IllegalArgumentException e) { Log.error("[WS-NEXT] UUID invalide: " + userId, e); @@ -216,6 +221,27 @@ public class NotificationWebSocketNext { " sessions sur " + totalSessions); } + /** + * Envoie à une connexion la liste de tous les utilisateurs actuellement en ligne. + * Appelé à l'onOpen pour que le client affiche tout de suite le bon statut. + */ + private static void sendPresenceSnapshotTo(WebSocketConnection connection) { + if (!connection.isOpen()) return; + try { + for (UUID onlineUserId : userConnections.keySet()) { + Map presenceData = new HashMap<>(); + presenceData.put("userId", onlineUserId.toString()); + presenceData.put("isOnline", true); + presenceData.put("timestamp", System.currentTimeMillis()); + String json = buildJsonMessage("presence", presenceData); + connection.sendText(json); + } + Log.debug("[WS-NEXT] Snapshot présence envoyé (" + userConnections.size() + " utilisateur(s) en ligne)"); + } catch (Exception e) { + Log.error("[WS-NEXT] Erreur envoi snapshot présence", e); + } + } + /** * Broadcast une mise à jour de présence à tous les utilisateurs connectés. * diff --git a/src/main/java/com/lions/dev/websocket/bridge/ChatKafkaBridge.java b/src/main/java/com/lions/dev/websocket/bridge/ChatKafkaBridge.java index d755377..4d106a8 100644 --- a/src/main/java/com/lions/dev/websocket/bridge/ChatKafkaBridge.java +++ b/src/main/java/com/lions/dev/websocket/bridge/ChatKafkaBridge.java @@ -36,9 +36,9 @@ public class ChatKafkaBridge { public CompletionStage processChatMessage(Message message) { try { ChatMessageEvent event = message.getPayload(); - - Log.debug("[CHAT-BRIDGE] Événement reçu: " + event.getEventType() + - " de " + event.getSenderId() + " à " + event.getRecipientId()); + // Log INFO pour confirmer la consommation Kafka (diagnostic "aucun log dans kafka") + Log.info("[CHAT-BRIDGE] Kafka consommé: type=" + event.getEventType() + + " de " + event.getSenderId() + " vers " + event.getRecipientId()); UUID recipientId = UUID.fromString(event.getRecipientId()); diff --git a/src/main/java/com/lions/dev/websocket/bridge/NotificationKafkaBridge.java b/src/main/java/com/lions/dev/websocket/bridge/NotificationKafkaBridge.java index 6d8d708..249c64e 100644 --- a/src/main/java/com/lions/dev/websocket/bridge/NotificationKafkaBridge.java +++ b/src/main/java/com/lions/dev/websocket/bridge/NotificationKafkaBridge.java @@ -8,8 +8,10 @@ import jakarta.enterprise.context.ApplicationScoped; import org.eclipse.microprofile.reactive.messaging.Incoming; import org.eclipse.microprofile.reactive.messaging.Message; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; /** @@ -42,8 +44,8 @@ public class NotificationKafkaBridge { try { NotificationEvent event = message.getPayload(); - Log.debug("[KAFKA-BRIDGE] Événement reçu: " + event.getType() + - " pour utilisateur: " + event.getUserId()); + Log.info("[KAFKA-BRIDGE] Événement reçu: type=" + event.getType() + + " userId=" + event.getUserId()); UUID userId = UUID.fromString(event.getUserId()); @@ -69,18 +71,26 @@ public class NotificationKafkaBridge { /** * Construit le message JSON pour WebSocket à partir de l'événement Kafka. + * Le champ "data" inclut "timestamp" et "type" pour compatibilité client Flutter (SystemNotification.fromJson). */ private String buildWebSocketMessage(NotificationEvent event) { try { - com.fasterxml.jackson.databind.ObjectMapper mapper = + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - - java.util.Map wsMessage = java.util.Map.of( + + long ts = event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis(); + Map data = event.getData() != null + ? new HashMap<>(event.getData()) + : new HashMap<>(); + data.put("timestamp", Instant.ofEpochMilli(ts).toString()); + data.put("type", event.getType()); + + Map wsMessage = Map.of( "type", event.getType(), - "data", event.getData() != null ? event.getData() : java.util.Map.of(), - "timestamp", event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis() + "data", data, + "timestamp", ts ); - + return mapper.writeValueAsString(wsMessage); } catch (Exception e) { Log.error("[KAFKA-BRIDGE] Erreur construction message WebSocket", e); diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 77252fd..570405b 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -12,13 +12,16 @@ afterwork.super-admin.api-key=${SUPER_ADMIN_API_KEY:dev-super-admin-key} # ==================================================================== -# Base de données H2 (en mémoire) +# Base de données PostgreSQL (développement local) # ==================================================================== -quarkus.datasource.db-kind=h2 -quarkus.datasource.jdbc.url=jdbc:h2:mem:afterwork_db;DB_CLOSE_DELAY=-1 -quarkus.datasource.username=sa -quarkus.datasource.password= -quarkus.datasource.jdbc.driver=org.h2.Driver +# H2 ne supporte pas LISTEN/NOTIFY ni certaines fonctionnalités temps réel. +# Utiliser PostgreSQL en dev avec les identifiants ci-dessous. +quarkus.datasource.db-kind=postgresql +# En dev local (mvn quarkus:dev sur l'hôte) : localhost. En conteneur Docker : définir DB_HOST=host.docker.internal +quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:afterwork_dev}?connectTimeout=10000 +quarkus.datasource.username=${DB_USERNAME:skyfile} +quarkus.datasource.password=${DB_PASSWORD:skyfile} +quarkus.datasource.jdbc.driver=org.postgresql.Driver quarkus.datasource.devservices.enabled=false # ==================================================================== @@ -34,13 +37,16 @@ quarkus.hibernate-orm.schema-generation.scripts.action=drop-and-create # ==================================================================== # Kafka (développement local) # ==================================================================== -# En dev, même connecteur que la base (smallrye-kafka). Démarrer Kafka en local -# (ex. docker-compose) ou définir KAFKA_BOOTSTRAP_SERVERS si Kafka est ailleurs. +# En dev, Kafka doit être joignable sur le port 9092 (conteneur Docker avec -p 9092:9092). +# Si Kafka est ailleurs, définir KAFKA_BOOTSTRAP_SERVERS (ex: host.docker.internal:9092). afterwork.kafka.enabled=${KAFKA_ENABLED:true} kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} -# SmallRye Reactive Messaging - Utiliser smallrye-kafka (pas d'extension in-memory en runtime). -# Les canaux sont définis dans application.properties. +# Propager explicitement bootstrap.servers au connecteur SmallRye Kafka (évite les soucis de résolution). +mp.messaging.connector.smallrye-kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + +# SmallRye Reactive Messaging - Les canaux sont définis dans application.properties. +# Voir REALTIME_DEV.md pour faire fonctionner le temps réel en local. # ==================================================================== # Logging diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index bdde3ce..d1713ef 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -67,7 +67,7 @@ quarkus.http.root-path=/afterwork # ==================================================================== # Quarkus fixe db-kind, jdbc.driver et root-path au BUILD. Si on met H2 ici, le JAR # ne pourra pas utiliser PostgreSQL en prod. Donc on met la config prod par défaut ; -# les profils dev/test surchargent avec H2 (application-dev.properties et %test en test). +# le profil dev surcharge avec PostgreSQL local (application-dev.properties). quarkus.datasource.db-kind=postgresql quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:postgresql-service.postgresql.svc.cluster.local}:${DB_PORT:5432}/${DB_NAME:mic-after-work-server-impl-quarkus-main} quarkus.datasource.username=${DB_USERNAME:lionsuser} diff --git a/src/test/java/com/lions/dev/service/EstablishmentRatingServiceTest.java b/src/test/java/com/lions/dev/service/EstablishmentRatingServiceTest.java index 667a862..ca4f410 100644 --- a/src/test/java/com/lions/dev/service/EstablishmentRatingServiceTest.java +++ b/src/test/java/com/lions/dev/service/EstablishmentRatingServiceTest.java @@ -97,7 +97,7 @@ class EstablishmentRatingServiceTest { assertThrows(RuntimeException.class, () -> establishmentRatingService.submitRating(establishmentId, userId, requestDTO)); - verify(ratingRepository, never()).persist(any()); + verify(ratingRepository, never()).persist(any(EstablishmentRating.class)); } @Test