Refactoring

This commit is contained in:
dahoud
2026-02-04 01:06:17 +00:00
parent 40de25315c
commit c31c6174cc
19 changed files with 360 additions and 70 deletions

View File

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

119
REALTIME_DEV.md Normal file
View File

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

View File

@@ -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<Throwable> {
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());

View File

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

View File

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

View File

@@ -13,4 +13,15 @@ public class BookingRepository implements PanacheRepositoryBase<Booking, UUID> {
public List<Booking> 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);
}
}

View File

@@ -109,6 +109,27 @@ public class EventsRepository implements PanacheRepositoryBase<Events, UUID> {
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).

View File

@@ -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();
}
}

View File

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

View File

@@ -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());
}

View File

@@ -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<NotificationEvent> 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<String, Object> 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<String, Object> 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.
*

View File

@@ -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<Users> 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);

View File

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

View File

@@ -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<String, Object> 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.
*

View File

@@ -36,9 +36,9 @@ public class ChatKafkaBridge {
public CompletionStage<Void> processChatMessage(Message<ChatMessageEvent> 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());

View File

@@ -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<String, Object> wsMessage = java.util.Map.of(
long ts = event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis();
Map<String, Object> data = event.getData() != null
? new HashMap<>(event.getData())
: new HashMap<>();
data.put("timestamp", Instant.ofEpochMilli(ts).toString());
data.put("type", event.getType());
Map<String, Object> 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);

View File

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

View File

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

View File

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