Refactoring
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
-Xmx2048m
|
||||
-Xms1024m
|
||||
-XX:MaxMetaspaceSize=512m
|
||||
-Dfile.encoding=UTF-8
|
||||
|
||||
119
REALTIME_DEV.md
Normal file
119
REALTIME_DEV.md
Normal 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 l’hôte :
|
||||
```bash
|
||||
docker port <container_id_or_name> 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 à <userId> (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/<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 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: <userId>`.
|
||||
|
||||
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.
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user