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

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