Compare commits

...

13 Commits

Author SHA1 Message Date
dahoud
c5a65bab5b Refactoring 2026-01-21 21:46:21 +00:00
dahoud
cb8b9da12e Refactoring 2026-01-21 21:37:48 +00:00
dahoud
8cb67f1762 Refactoring 2026-01-21 19:16:24 +00:00
dahoud
b9fc1ee05a fix: ajouter application-production.properties et corriger config WebSockets Next 2026-01-21 18:18:20 +00:00
dahoud
93c63fd600 feat: migration complète vers WebSockets Next + Kafka pour temps réel
- Migration de Jakarta WebSocket vers Quarkus WebSockets Next
- Implémentation de l'architecture Kafka pour événements temps réel
- Ajout des DTOs d'événements (NotificationEvent, ChatMessageEvent, ReactionEvent, PresenceEvent)
- Création des bridges Kafka → WebSocket (NotificationKafkaBridge, ChatKafkaBridge, ReactionKafkaBridge)
- Mise à jour des services pour publier dans Kafka au lieu d'appeler directement WebSocket
- Suppression des classes obsolètes (ChatWebSocket, NotificationWebSocket)
- Correction de l'injection des paramètres path dans WebSockets Next (utilisation de connection.pathParam)
- Ajout des migrations DB pour bookings, promotions, business hours, amenities, reviews
- Mise à jour de la configuration application.properties pour Kafka et WebSockets Next
- Mise à jour .gitignore pour ignorer les fichiers de logs
2026-01-21 13:46:16 +00:00
dahoud
7dd0969799 fix(backend): Correction du système de réactions (favoris) pour les événements 2026-01-19 22:44:14 +00:00
dahoud
a5fd9538fe chore: Mise à jour de la configuration Kubernetes 2026-01-13 21:09:45 +00:00
dahoud
7309fcc72d config: Configuration des environnements et migrations DB - Séparation des configurations dev et prod - Configuration H2 pour développement - Configuration PostgreSQL pour production - Migration SQL pour les nouveaux champs d'événements (V2__Add_Event_Additional_Fields.sql) - Configuration CORS et root-path pour production 2026-01-13 20:45:28 +00:00
dahoud
c26098b0d4 fix: Corrections WebSocket et upload de fichiers - FileUploadResource: support des métadonnées (type, fileName, contentType, fileSize, userId) - NotificationWebSocket: correction de l'erreur JTA transaction avec CompletableFuture.runAsync() - PresenceService: ajout de @ActivateRequestContext pour le contexte de requête 2026-01-13 20:45:25 +00:00
dahoud
bfb174bcf8 fix: Correction de la comparaison des utilisateurs dans Conversation - Utilisation de getId().equals() au lieu de equals() - Correction dans updateLastMessage, markAllAsReadForUser, etc. 2026-01-13 20:45:21 +00:00
dahoud
0443bd251f feat: Extension des événements avec 10 nouveaux champs - Ajout de maxParticipants, tags, organizer, participationFee - Ajout de privacyRules, transportInfo, accommodationInfo - Ajout de accessibilityInfo, parkingInfo, securityProtocol - Mise à jour des DTOs et services - Vérification des permissions pour le CRUD (créateur uniquement) - Ajout de creatorId dans EventCreateResponseDTO 2026-01-13 20:45:18 +00:00
dahoud
56d0aad6a6 feat: Système complet de gestion des établissements (backend) - Entités JPA pour Establishment, EstablishmentMedia, EstablishmentRating - DTOs pour création, mise à jour et réponses - Repositories Panache pour accès aux données - Services avec logique métier et validation - Resources REST avec tous les endpoints CRUD - Gestion des médias (photos/vidéos) - Système de notation avec statistiques 2026-01-13 20:45:13 +00:00
dahoud
c0b1863467 fix(ingress): Retrait des snippets désactivés et utilisation annotations standards 2026-01-10 16:22:34 +00:00
97 changed files with 7846 additions and 1211 deletions

6
.gitignore vendored
View File

@@ -43,3 +43,9 @@ nb-configuration.xml
/.quarkus/cli/plugins/
# TLS Certificates
.certs/
# Logs
*.log
hs_err_pid*.log
replay_pid*.log
backend_log.txt

3
.mvn/jvm.config Normal file
View File

@@ -0,0 +1,3 @@
-Xmx2048m
-Xms1024m
-XX:MaxMetaspaceSize=512m

View File

@@ -0,0 +1,450 @@
# 🚀 Architecture Temps Réel - Brainstorming & Plan d'Implémentation
## 📋 Contexte Actuel
### État des Lieux
-**Backend**: Jakarta WebSocket (`@ServerEndpoint`) - API legacy
-**Frontend**: `web_socket_channel` pour WebSocket
-**Services existants**:
- `NotificationWebSocket` (`/notifications/ws/{userId}`)
- `ChatWebSocket` (`/chat/ws/{userId}`)
- Services Flutter: `RealtimeNotificationService`, `ChatWebSocketService`
### Limitations Actuelles
1. **Pas de persistance des événements** : Si un utilisateur est déconnecté, les messages sont perdus
2. **Pas de scalabilité horizontale** : Les sessions WebSocket sont en mémoire, ne fonctionnent pas avec plusieurs instances
3. **Pas de garantie de livraison** : Pas de mécanisme de retry ou de queue
4. **Pas de découplage** : Services directement couplés aux WebSockets
---
## 🎯 Objectifs
1. **Garantir la livraison** : Aucun événement ne doit être perdu
2. **Scalabilité horizontale** : Support de plusieurs instances Quarkus
3. **Temps réel garanti** : Latence < 100ms pour les notifications critiques
4. **Durabilité** : Persistance des événements pour récupération après déconnexion
5. **Découplage** : Services métier indépendants des WebSockets
---
## 🏗️ Architecture Proposée
### Option 1 : WebSockets Next + Kafka (Recommandée) ⭐
```
┌─────────────┐
│ Flutter │
│ Client │
└──────┬──────┘
│ WebSocket (wss://)
┌──────▼─────────────────────────────────────┐
│ Quarkus WebSockets Next │
│ ┌──────────────────────────────────────┐ │
│ │ @WebSocket("/notifications/{userId}")│ │
│ │ @WebSocket("/chat/{userId}") │ │
│ └──────┬───────────────────────────────┘ │
│ │ │
│ ┌──────▼──────────────────────────────┐ │
│ │ Reactive Messaging Bridge │ │
│ │ @Incoming("kafka-notifications") │ │
│ │ @Outgoing("websocket-notifications") │ │
│ └──────┬───────────────────────────────┘ │
└─────────┼──────────────────────────────────┘
│ Kafka Topics
┌─────────▼──────────────────────────────────┐
│ Apache Kafka Cluster │
│ ┌──────────────────────────────────────┐ │
│ │ Topics: │ │
│ │ - notifications.{userId} │ │
│ │ - chat.messages │ │
│ │ - reactions.{postId} │ │
│ │ - presence.updates │ │
│ └──────────────────────────────────────┘ │
└─────────┬──────────────────────────────────┘
│ Producers
┌─────────▼──────────────────────────────────┐
│ Services Métier (Quarkus) │
│ - FriendshipService │
│ - MessageService │
│ - SocialPostService │
│ - EventService │
└────────────────────────────────────────────┘
```
### Avantages
- **Scalabilité** : Kafka gère la distribution entre instances
- **Durabilité** : Messages persistés dans Kafka (rétention configurable)
- **Découplage** : Services publient dans Kafka, WebSocket consomme
- **Performance** : WebSockets Next est plus performant que Jakarta WS
- **Replay** : Possibilité de rejouer les événements pour récupération
- **Monitoring** : Kafka fournit des métriques natives
### Inconvénients
- **Complexité** : Nécessite un cluster Kafka (mais Quarkus Dev Services le gère automatiquement)
- **Latence** : Légèrement plus élevée (Kafka + WebSocket vs WebSocket direct)
- **Ressources** : Kafka consomme plus de mémoire
---
## 📦 Technologies & Dépendances
### Backend (Quarkus)
#### 1. WebSockets Next (Remplace Jakarta WebSocket)
```xml
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets-next</artifactId>
</dependency>
```
**Documentation**: https://quarkus.io/guides/websockets-next
#### 2. Kafka Reactive Messaging
```xml
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-messaging-kafka</artifactId>
</dependency>
```
**Documentation**: https://quarkus.io/guides/kafka
#### 3. Reactive Messaging HTTP (Bridge Kafka ↔ WebSocket)
```xml
<dependency>
<groupId>io.quarkiverse.reactivemessaginghttp</groupId>
<artifactId>quarkus-reactive-messaging-http</artifactId>
<version>1.0.0</version>
</dependency>
```
**Documentation**: https://docs.quarkiverse.io/quarkus-reactive-messaging-http/dev/reactive-messaging-websocket.html
### Frontend (Flutter)
#### Package WebSocket (Déjà utilisé)
```yaml
dependencies:
web_socket_channel: ^2.4.0
```
**Documentation**: https://pub.dev/packages/web_socket_channel
---
## 🔧 Configuration
### application.properties (Quarkus)
```properties
# ============================================
# Kafka Configuration
# ============================================
kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
# Topics
mp.messaging.outgoing.notifications.connector=smallrye-kafka
mp.messaging.outgoing.notifications.topic=notifications
mp.messaging.outgoing.notifications.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.notifications.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
mp.messaging.outgoing.chat-messages.connector=smallrye-kafka
mp.messaging.outgoing.chat-messages.topic=chat.messages
mp.messaging.outgoing.chat-messages.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.chat-messages.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
mp.messaging.outgoing.reactions.connector=smallrye-kafka
mp.messaging.outgoing.reactions.topic=reactions
mp.messaging.outgoing.reactions.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.reactions.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
# ============================================
# WebSocket Configuration
# ============================================
# WebSockets Next
quarkus.websockets-next.server.enabled=true
quarkus.websockets-next.server.port=8080
# ============================================
# Reactive Messaging HTTP (Bridge)
# ============================================
# Incoming Kafka → Outgoing WebSocket
mp.messaging.incoming.kafka-notifications.connector=smallrye-kafka
mp.messaging.incoming.kafka-notifications.topic=notifications
mp.messaging.incoming.kafka-notifications.group.id=websocket-bridge
mp.messaging.incoming.kafka-notifications.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.kafka-notifications.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer
mp.messaging.outgoing.ws-notifications.connector=quarkus-websocket
mp.messaging.outgoing.ws-notifications.path=/notifications/{userId}
```
---
## 💻 Implémentation
### Backend : Migration vers WebSockets Next
#### 1. Nouveau NotificationWebSocket (WebSockets Next)
```java
package com.lions.dev.websocket;
import io.quarkus.websockets.next.OnOpen;
import io.quarkus.websockets.next.OnClose;
import io.quarkus.websockets.next.OnTextMessage;
import io.quarkus.websockets.next.WebSocket;
import io.quarkus.websockets.next.WebSocketConnection;
import io.smallrye.mutiny.Multi;
import jakarta.inject.Inject;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Outgoing;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;
/**
* WebSocket endpoint pour les notifications en temps réel (WebSockets Next).
*
* Architecture:
* - Services métier → Kafka Topic → WebSocket Bridge → Client
*/
@WebSocket(path = "/notifications/{userId}")
public class NotificationWebSocketNext {
@Inject
@Channel("ws-notifications")
Emitter<String> notificationEmitter;
// Stockage des connexions actives (pour routing)
private static final Map<UUID, WebSocketConnection> connections = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(WebSocketConnection connection, String userId) {
UUID userUUID = UUID.fromString(userId);
connections.put(userUUID, connection);
// Envoyer confirmation de connexion
connection.sendText("{\"type\":\"connected\",\"timestamp\":" + System.currentTimeMillis() + "}");
}
@OnClose
public void onClose(String userId) {
UUID userUUID = UUID.fromString(userId);
connections.remove(userUUID);
}
@OnTextMessage
public void onMessage(String message, String userId) {
// Gérer les messages du client (ping, ack, etc.)
// ...
}
/**
* Bridge: Consomme depuis Kafka et envoie via WebSocket
*/
@Incoming("kafka-notifications")
@Outgoing("ws-notifications")
public Multi<String> bridgeNotifications(String kafkaMessage) {
// Parser le message Kafka
// Extraire userId depuis la clé Kafka
// Router vers la bonne connexion WebSocket
return Multi.createFrom().item(kafkaMessage);
}
}
```
#### 2. Service Métier Publie dans Kafka
```java
package com.lions.dev.service;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import jakarta.inject.Inject;
@ApplicationScoped
public class FriendshipService {
@Inject
@Channel("notifications")
Emitter<NotificationEvent> notificationEmitter;
public void sendFriendRequest(UUID fromUserId, UUID toUserId) {
// ... logique métier ...
// Publier dans Kafka au lieu d'appeler directement WebSocket
NotificationEvent event = new NotificationEvent(
toUserId.toString(),
"friend_request",
Map.of("fromUserId", fromUserId.toString(), "fromName", fromUser.getFirstName())
);
notificationEmitter.send(event);
}
}
```
### Frontend : Amélioration du Service WebSocket
```dart
// afterwork/lib/data/services/realtime_notification_service_v2.dart
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status;
class RealtimeNotificationServiceV2 {
WebSocketChannel? _channel;
Timer? _heartbeatTimer;
Timer? _reconnectTimer;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
static const Duration _heartbeatInterval = Duration(seconds: 30);
static const Duration _reconnectDelay = Duration(seconds: 5);
Future<void> connect(String userId, String authToken) async {
final uri = Uri.parse('wss://api.afterwork.lions.dev/notifications/$userId');
_channel = WebSocketChannel.connect(
uri,
protocols: ['notifications-v2'],
headers: {
'Authorization': 'Bearer $authToken',
},
);
// Heartbeat pour maintenir la connexion
_heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) {
_channel?.sink.add(jsonEncode({'type': 'ping'}));
});
// Écouter les messages
_channel!.stream.listen(
_handleMessage,
onError: _handleError,
onDone: _handleDisconnection,
cancelOnError: false,
);
}
void _handleDisconnection() {
_heartbeatTimer?.cancel();
_scheduleReconnect();
}
void _scheduleReconnect() {
if (_reconnectAttempts < _maxReconnectAttempts) {
_reconnectTimer = Timer(_reconnectDelay * (_reconnectAttempts + 1), () {
_reconnectAttempts++;
connect(_userId, _authToken);
});
}
}
}
```
---
## 📊 Comparaison des Options
| Critère | Jakarta WS (Actuel) | WebSockets Next | WebSockets Next + Kafka |
|---------|---------------------|-----------------|-------------------------|
| **Performance** | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| **Scalabilité** | (1 instance) | (limité) | (illimitée) |
| **Durabilité** | | | |
| **Découplage** | | | |
| **Complexité** | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| **Latence** | < 50ms | < 50ms | < 100ms |
| **Replay Events** | | | |
| **Monitoring** | | | (Kafka metrics) |
---
## 🎯 Plan d'Implémentation Recommandé
### Phase 1 : Migration WebSockets Next (Sans Kafka)
**Durée**: 1-2 semaines
- Migrer `NotificationWebSocket` vers WebSockets Next
- Migrer `ChatWebSocket` vers WebSockets Next
- Tester avec le frontend existant
- **Avantage**: Amélioration immédiate des performances
### Phase 2 : Intégration Kafka
**Durée**: 2-3 semaines
- Ajouter dépendances Kafka
- Créer les topics Kafka
- Implémenter les bridges Kafka WebSocket
- Migrer les services pour publier dans Kafka
- **Avantage**: Scalabilité et durabilité
### Phase 3 : Optimisations
**Durée**: 1 semaine
- Compression des messages
- Batching pour les notifications
- Monitoring et alertes
- **Avantage**: Performance et observabilité
---
## 🔍 Alternatives Considérées
### Option 2 : Server-Sent Events (SSE)
- Plus simple que WebSocket
- Unidirectionnel (serveur client uniquement)
- Pas adapté pour le chat bidirectionnel
### Option 3 : gRPC Streaming
- Performant
- Plus complexe à configurer
- Nécessite HTTP/2
### Option 4 : Socket.IO
- Reconnexion automatique
- Nécessite Node.js (pas compatible Quarkus)
---
## 📚 Ressources & Documentation
### Quarkus
- [WebSockets Next Guide](https://quarkus.io/guides/websockets-next)
- [Kafka Guide](https://quarkus.io/guides/kafka)
- [Reactive Messaging](https://quarkus.io/guides/messaging)
### Kafka
- [Kafka Documentation](https://kafka.apache.org/documentation/)
- [Quarkus Kafka Dev Services](https://quarkus.io/guides/kafka-dev-services)
### Flutter
- [web_socket_channel Package](https://pub.dev/packages/web_socket_channel)
- [Flutter WebSocket Best Practices](https://ably.com/topic/websockets-flutter)
---
## ✅ Recommandation Finale
**Architecture recommandée**: **WebSockets Next + Kafka**
**Raisons**:
1. Scalabilité horizontale garantie
2. Durabilité des événements
3. Découplage des services
4. Compatible avec l'existant (migration progressive)
5. Support natif Quarkus (Dev Services pour Kafka)
6. Monitoring intégré
**Prochaines étapes**:
1. Ajouter les dépendances dans `pom.xml`
2. Créer un POC avec un seul endpoint (notifications)
3. Tester la scalabilité avec 2 instances Quarkus
4. Migrer progressivement les autres endpoints

View File

@@ -0,0 +1,930 @@
# 💻 Exemples d'Implémentation - Temps Réel avec Kafka
## 📦 Étape 1 : Ajouter les Dépendances
### pom.xml
```xml
<!-- WebSockets Next (remplace quarkus-websockets) -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets-next</artifactId>
</dependency>
<!-- Kafka Reactive Messaging -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-messaging-kafka</artifactId>
</dependency>
<!-- Reactive Messaging HTTP (Bridge Kafka ↔ WebSocket) -->
<dependency>
<groupId>io.quarkiverse.reactivemessaginghttp</groupId>
<artifactId>quarkus-reactive-messaging-http</artifactId>
<version>1.0.0</version>
</dependency>
<!-- JSON Serialization pour Kafka -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jsonb</artifactId>
</dependency>
```
---
## 🔧 Étape 2 : Configuration application.properties
```properties
# ============================================
# Kafka Configuration
# ============================================
kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
# Topic: Notifications
mp.messaging.outgoing.notifications.connector=smallrye-kafka
mp.messaging.outgoing.notifications.topic=notifications
mp.messaging.outgoing.notifications.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.notifications.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
# Topic: Chat Messages
mp.messaging.outgoing.chat-messages.connector=smallrye-kafka
mp.messaging.outgoing.chat-messages.topic=chat.messages
mp.messaging.outgoing.chat-messages.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.chat-messages.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
# Topic: Reactions (likes, comments)
mp.messaging.outgoing.reactions.connector=smallrye-kafka
mp.messaging.outgoing.reactions.topic=reactions
mp.messaging.outgoing.reactions.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.reactions.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
# Topic: Presence Updates
mp.messaging.outgoing.presence.connector=smallrye-kafka
mp.messaging.outgoing.presence.topic=presence.updates
mp.messaging.outgoing.presence.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.presence.value.serializer=io.quarkus.kafka.client.serialization.JsonbSerializer
# ============================================
# Kafka → WebSocket Bridge (Incoming)
# ============================================
# Consommer depuis Kafka et router vers WebSocket
mp.messaging.incoming.kafka-notifications.connector=smallrye-kafka
mp.messaging.incoming.kafka-notifications.topic=notifications
mp.messaging.incoming.kafka-notifications.group.id=websocket-notifications-bridge
mp.messaging.incoming.kafka-notifications.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.kafka-notifications.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer
mp.messaging.incoming.kafka-notifications.enable.auto.commit=true
mp.messaging.incoming.kafka-chat.connector=smallrye-kafka
mp.messaging.incoming.kafka-chat.topic=chat.messages
mp.messaging.incoming.kafka-chat.group.id=websocket-chat-bridge
mp.messaging.incoming.kafka-chat.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.kafka-chat.value.deserializer=io.quarkus.kafka.client.serialization.JsonbDeserializer
mp.messaging.incoming.kafka-chat.enable.auto.commit=true
# ============================================
# WebSocket Configuration
# ============================================
quarkus.websockets-next.server.enabled=true
```
---
## 📝 Étape 3 : DTOs pour les Événements Kafka
### NotificationEvent.java
```java
package com.lions.dev.dto.events;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.Map;
import java.util.UUID;
/**
* Événement de notification publié dans Kafka.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class NotificationEvent {
private String userId; // Clé Kafka (pour routing)
private String type; // friend_request, event_reminder, message_alert, etc.
private Map<String, Object> data;
private Long timestamp;
public NotificationEvent(String userId, String type, Map<String, Object> data) {
this.userId = userId;
this.type = type;
this.data = data;
this.timestamp = System.currentTimeMillis();
}
}
```
### ChatMessageEvent.java
```java
package com.lions.dev.dto.events;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.UUID;
/**
* Événement de message chat publié dans Kafka.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessageEvent {
private String conversationId; // Clé Kafka
private String senderId;
private String recipientId;
private String content;
private String messageId;
private Long timestamp;
}
```
### ReactionEvent.java
```java
package com.lions.dev.dto.events;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Événement de réaction (like, comment) publié dans Kafka.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReactionEvent {
private String postId; // Clé Kafka
private String userId;
private String reactionType; // like, comment, share
private Map<String, Object> data;
private Long timestamp;
}
```
---
## 🔌 Étape 4 : WebSocket avec WebSockets Next
### NotificationWebSocketNext.java
```java
package com.lions.dev.websocket;
import io.quarkus.logging.Log;
import io.quarkus.websockets.next.OnClose;
import io.quarkus.websockets.next.OnOpen;
import io.quarkus.websockets.next.OnTextMessage;
import io.quarkus.websockets.next.WebSocket;
import io.quarkus.websockets.next.WebSocketConnection;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* WebSocket endpoint pour les notifications en temps réel (WebSockets Next).
*
* Architecture:
* Services → Kafka → Bridge → WebSocket → Client
*/
@WebSocket(path = "/notifications/{userId}")
@ApplicationScoped
public class NotificationWebSocketNext {
// Stockage des connexions actives par utilisateur (multi-device support)
private static final Map<UUID, Set<WebSocketConnection>> userConnections = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(WebSocketConnection connection, String userId) {
try {
UUID userUUID = UUID.fromString(userId);
// Ajouter la connexion à l'ensemble des connexions de l'utilisateur
userConnections.computeIfAbsent(userUUID, k -> ConcurrentHashMap.newKeySet())
.add(connection);
Log.info("[WS-NEXT] Connexion ouverte pour l'utilisateur: " + userId +
" (Total: " + userConnections.get(userUUID).size() + ")");
// Envoyer confirmation
connection.sendText("{\"type\":\"connected\",\"timestamp\":" +
System.currentTimeMillis() + "}");
} catch (IllegalArgumentException e) {
Log.error("[WS-NEXT] UUID invalide: " + userId, e);
connection.close();
}
}
@OnClose
public void onClose(String userId) {
try {
UUID userUUID = UUID.fromString(userId);
Set<WebSocketConnection> connections = userConnections.get(userUUID);
if (connections != null) {
connections.removeIf(conn -> !conn.isOpen());
if (connections.isEmpty()) {
userConnections.remove(userUUID);
Log.info("[WS-NEXT] Toutes les connexions fermées pour: " + userId);
} else {
Log.info("[WS-NEXT] Connexion fermée pour: " + userId +
" (Restantes: " + connections.size() + ")");
}
}
} catch (Exception e) {
Log.error("[WS-NEXT] Erreur lors de la fermeture", e);
}
}
@OnTextMessage
public void onMessage(String message, String userId) {
try {
Log.debug("[WS-NEXT] Message reçu de " + userId + ": " + message);
// Parser le message JSON
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> messageData = mapper.readValue(message, Map.class);
String type = (String) messageData.get("type");
switch (type) {
case "ping":
handlePing(userId);
break;
case "ack":
handleAck(messageData, userId);
break;
default:
Log.warn("[WS-NEXT] Type de message inconnu: " + type);
}
} catch (Exception e) {
Log.error("[WS-NEXT] Erreur traitement message", e);
}
}
private void handlePing(String userId) {
UUID userUUID = UUID.fromString(userId);
Set<WebSocketConnection> connections = userConnections.get(userUUID);
if (connections != null) {
String pong = "{\"type\":\"pong\",\"timestamp\":" +
System.currentTimeMillis() + "}";
connections.forEach(conn -> {
if (conn.isOpen()) {
conn.sendText(pong);
}
});
}
}
private void handleAck(Map<String, Object> messageData, String userId) {
String notificationId = (String) messageData.get("notificationId");
Log.debug("[WS-NEXT] ACK reçu pour notification " + notificationId +
" de " + userId);
}
/**
* Envoie une notification à un utilisateur spécifique.
* Appelé par le bridge Kafka → WebSocket.
*/
public static void sendToUser(UUID userId, String message) {
Set<WebSocketConnection> connections = userConnections.get(userId);
if (connections == null || connections.isEmpty()) {
Log.debug("[WS-NEXT] Utilisateur " + userId + " non connecté");
return;
}
int success = 0;
int failed = 0;
for (WebSocketConnection conn : connections) {
if (conn.isOpen()) {
try {
conn.sendText(message);
success++;
} catch (Exception e) {
failed++;
Log.error("[WS-NEXT] Erreur envoi à " + userId, e);
}
} else {
failed++;
}
}
Log.info("[WS-NEXT] Notification envoyée à " + userId +
" (Succès: " + success + ", Échec: " + failed + ")");
}
}
```
---
## 🌉 Étape 5 : Bridge Kafka → WebSocket
### NotificationKafkaBridge.java
```java
package com.lions.dev.websocket;
import com.lions.dev.dto.events.NotificationEvent;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message;
import java.util.UUID;
/**
* Bridge qui consomme depuis Kafka et envoie via WebSocket.
*
* Architecture:
* Kafka Topic (notifications) → Bridge → WebSocket (NotificationWebSocketNext)
*/
@ApplicationScoped
public class NotificationKafkaBridge {
/**
* Consomme les événements depuis Kafka et les route vers WebSocket.
*/
@Incoming("kafka-notifications")
public void processNotification(Message<NotificationEvent> message) {
try {
NotificationEvent event = message.getPayload();
Log.debug("[KAFKA-BRIDGE] Événement reçu: " + event.getType() +
" pour utilisateur: " + event.getUserId());
UUID userId = UUID.fromString(event.getUserId());
// Construire le message JSON pour WebSocket
String wsMessage = buildWebSocketMessage(event);
// Envoyer via WebSocket
NotificationWebSocketNext.sendToUser(userId, wsMessage);
// Acknowledger le message Kafka
message.ack();
} catch (Exception e) {
Log.error("[KAFKA-BRIDGE] Erreur traitement événement", e);
message.nack(e);
}
}
private String buildWebSocketMessage(NotificationEvent event) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
java.util.Map<String, Object> wsMessage = java.util.Map.of(
"type", event.getType(),
"data", event.getData(),
"timestamp", event.getTimestamp()
);
return mapper.writeValueAsString(wsMessage);
} catch (Exception e) {
Log.error("[KAFKA-BRIDGE] Erreur construction message", e);
return "{\"type\":\"error\",\"message\":\"Erreur de traitement\"}";
}
}
}
```
---
## 📤 Étape 6 : Services Publient dans Kafka
### FriendshipService (Modifié)
```java
package com.lions.dev.service;
import com.lions.dev.dto.events.NotificationEvent;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import jakarta.inject.Inject;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class FriendshipService {
@Inject
@Channel("notifications")
Emitter<NotificationEvent> notificationEmitter;
// ... autres dépendances ...
/**
* Envoie une demande d'amitié (publie dans Kafka).
*/
@Transactional
public FriendshipCreateOneResponseDTO sendFriendRequest(
FriendshipCreateOneRequestDTO request) {
// ... logique métier existante ...
// ✅ NOUVEAU: Publier dans Kafka au lieu d'appeler directement WebSocket
try {
NotificationEvent event = new NotificationEvent(
request.getFriendId().toString(), // userId destinataire
"friend_request",
java.util.Map.of(
"fromUserId", request.getUserId().toString(),
"fromFirstName", user.getFirstName(),
"fromLastName", user.getLastName(),
"requestId", response.getFriendshipId().toString()
)
);
notificationEmitter.send(event);
logger.info("[LOG] Événement friend_request publié dans Kafka pour: " +
request.getFriendId());
} catch (Exception e) {
logger.error("[ERROR] Erreur publication Kafka", e);
// Ne pas bloquer la demande d'amitié si Kafka échoue
}
return response;
}
/**
* Accepte une demande d'amitié (publie dans Kafka).
*/
@Transactional
public FriendshipCreateOneResponseDTO acceptFriendRequest(UUID friendshipId) {
// ... logique métier existante ...
// ✅ NOUVEAU: Publier dans Kafka
try {
NotificationEvent event = new NotificationEvent(
originalRequest.getUserId().toString(), // userId émetteur
"friend_request_accepted",
java.util.Map.of(
"friendId", friend.getId().toString(),
"friendFirstName", friend.getFirstName(),
"friendLastName", friend.getLastName(),
"friendshipId", response.getFriendshipId().toString()
)
);
notificationEmitter.send(event);
logger.info("[LOG] Événement friend_request_accepted publié dans Kafka");
} catch (Exception e) {
logger.error("[ERROR] Erreur publication Kafka", e);
}
return response;
}
}
```
### MessageService (Modifié)
```java
package com.lions.dev.service;
import com.lions.dev.dto.events.ChatMessageEvent;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import jakarta.inject.Inject;
@ApplicationScoped
public class MessageService {
@Inject
@Channel("chat-messages")
Emitter<ChatMessageEvent> chatMessageEmitter;
/**
* Envoie un message (publie dans Kafka).
*/
@Transactional
public MessageResponseDTO sendMessage(SendMessageRequestDTO request) {
// ... logique métier existante ...
// ✅ NOUVEAU: Publier dans Kafka
try {
ChatMessageEvent event = new ChatMessageEvent();
event.setConversationId(conversation.getId().toString());
event.setSenderId(senderId.toString());
event.setRecipientId(recipientId.toString());
event.setContent(request.getContent());
event.setMessageId(message.getId().toString());
event.setTimestamp(System.currentTimeMillis());
// Utiliser conversationId comme clé Kafka pour garantir l'ordre
chatMessageEmitter.send(org.eclipse.microprofile.reactive.messaging.Message.of(
event,
() -> CompletableFuture.completedFuture(null), // ack
throwable -> {
logger.error("[ERROR] Erreur envoi Kafka", throwable);
return CompletableFuture.completedFuture(null); // nack
}
).addMetadata(org.eclipse.microprofile.reactive.messaging.OutgoingMessageMetadata.builder()
.withKey(conversation.getId().toString())
.build()));
logger.info("[LOG] Message publié dans Kafka: " + message.getId());
} catch (Exception e) {
logger.error("[ERROR] Erreur publication Kafka", e);
// Ne pas bloquer l'envoi du message si Kafka échoue
}
return response;
}
}
```
### SocialPostService (Modifié pour les Réactions)
```java
package com.lions.dev.service;
import com.lions.dev.dto.events.ReactionEvent;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import jakarta.inject.Inject;
@ApplicationScoped
public class SocialPostService {
@Inject
@Channel("reactions")
Emitter<ReactionEvent> reactionEmitter;
/**
* Like un post (publie dans Kafka).
*/
@Transactional
public SocialPost likePost(UUID postId, UUID userId) {
// ... logique métier existante ...
// ✅ NOUVEAU: Publier dans Kafka pour notifier en temps réel
try {
ReactionEvent event = new ReactionEvent();
event.setPostId(postId.toString());
event.setUserId(userId.toString());
event.setReactionType("like");
event.setData(java.util.Map.of(
"postId", postId.toString(),
"userId", userId.toString(),
"likesCount", post.getLikesCount()
));
event.setTimestamp(System.currentTimeMillis());
reactionEmitter.send(event);
logger.info("[LOG] Réaction like publiée dans Kafka pour post: " + postId);
} catch (Exception e) {
logger.error("[ERROR] Erreur publication Kafka", e);
}
return post;
}
}
```
---
## 🎨 Frontend : Amélioration du Service WebSocket
### realtime_notification_service_v2.dart
```dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status;
class RealtimeNotificationServiceV2 extends ChangeNotifier {
RealtimeNotificationServiceV2(this.userId, this.authToken);
final String userId;
final String authToken;
WebSocketChannel? _channel;
StreamSubscription? _subscription;
Timer? _heartbeatTimer;
Timer? _reconnectTimer;
bool _isConnected = false;
bool get isConnected => _isConnected;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
static const Duration _heartbeatInterval = Duration(seconds: 30);
static const Duration _reconnectDelay = Duration(seconds: 5);
// Streams pour différents types d'événements
final _friendRequestController = StreamController<Map<String, dynamic>>.broadcast();
final _systemNotificationController = StreamController<Map<String, dynamic>>.broadcast();
final _reactionController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get friendRequestStream => _friendRequestController.stream;
Stream<Map<String, dynamic>> get systemNotificationStream => _systemNotificationController.stream;
Stream<Map<String, dynamic>> get reactionStream => _reactionController.stream;
String get _wsUrl {
final baseUrl = 'wss://api.afterwork.lions.dev'; // Production
return '$baseUrl/notifications/$userId';
}
Future<void> connect() async {
if (_isConnected) return;
try {
_channel = WebSocketChannel.connect(
Uri.parse(_wsUrl),
protocols: ['notifications-v2'],
headers: {
'Authorization': 'Bearer $authToken',
},
);
// Heartbeat pour maintenir la connexion
_heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) {
_channel?.sink.add(jsonEncode({'type': 'ping'}));
});
// Écouter les messages
_subscription = _channel!.stream.listen(
_handleMessage,
onError: _handleError,
onDone: _handleDisconnection,
cancelOnError: false,
);
_isConnected = true;
notifyListeners();
} catch (e) {
_isConnected = false;
notifyListeners();
_scheduleReconnect();
}
}
void _handleMessage(dynamic message) {
try {
final data = jsonDecode(message as String) as Map<String, dynamic>;
final type = data['type'] as String;
switch (type) {
case 'connected':
_reconnectAttempts = 0; // Reset sur reconnexion réussie
break;
case 'pong':
// Heartbeat réponse
break;
case 'friend_request':
case 'friend_request_accepted':
_friendRequestController.add(data);
break;
case 'event_reminder':
case 'system_notification':
_systemNotificationController.add(data);
break;
case 'reaction':
_reactionController.add(data);
break;
default:
// Type inconnu, ignorer ou logger
break;
}
} catch (e) {
// Erreur de parsing, ignorer
}
}
void _handleError(dynamic error) {
_isConnected = false;
notifyListeners();
_scheduleReconnect();
}
void _handleDisconnection() {
_isConnected = false;
notifyListeners();
_scheduleReconnect();
}
void _scheduleReconnect() {
if (_reconnectAttempts >= _maxReconnectAttempts) {
// Arrêter les tentatives après max
return;
}
_reconnectTimer?.cancel();
_reconnectTimer = Timer(_reconnectDelay * (_reconnectAttempts + 1), () {
_reconnectAttempts++;
connect();
});
}
Future<void> disconnect() async {
_heartbeatTimer?.cancel();
_reconnectTimer?.cancel();
await _subscription?.cancel();
await _channel?.sink.close(status.normalClosure);
_isConnected = false;
notifyListeners();
}
@override
void dispose() {
disconnect();
_friendRequestController.close();
_systemNotificationController.close();
_reactionController.close();
super.dispose();
}
}
```
---
## 🧪 Tests
### Test du Bridge Kafka → WebSocket
```java
package com.lions.dev.websocket;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import org.junit.jupiter.api.Test;
@QuarkusTest
public class NotificationKafkaBridgeTest {
@Inject
@Channel("notifications")
Emitter<NotificationEvent> notificationEmitter;
@Test
public void testNotificationFlow() {
// Publier un événement dans Kafka
NotificationEvent event = new NotificationEvent(
"user-123",
"friend_request",
Map.of("fromUserId", "user-456")
);
notificationEmitter.send(event);
// Vérifier que le message arrive bien via WebSocket
// (nécessite un client WebSocket de test)
}
}
```
---
## 📊 Monitoring
### Métriques Kafka à Surveiller
1. **Lag Consumer** : Délai entre production et consommation
2. **Throughput** : Messages/seconde
3. **Error Rate** : Taux d'erreur
4. **Connection Count** : Nombre de connexions WebSocket actives
### Endpoint de Santé
```java
@Path("/health/realtime")
public class RealtimeHealthResource {
@GET
public Response health() {
return Response.ok(Map.of(
"websocket_connections", NotificationWebSocketNext.getConnectionCount(),
"kafka_consumers", getKafkaConsumerCount(),
"status", "healthy"
)).build();
}
}
```
---
## 🚀 Déploiement
### Docker Compose (Kafka Local)
```yaml
version: '3.8'
services:
zookeeper:
image: confluentinc/cp-zookeeper:latest
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
kafka:
image: confluentinc/cp-kafka:latest
depends_on:
- zookeeper
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
```
### Production (Kubernetes)
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: afterwork-backend
spec:
replicas: 3 # ✅ Scalabilité horizontale
template:
spec:
containers:
- name: quarkus
env:
- name: KAFKA_BOOTSTRAP_SERVERS
value: "kafka-service:9092"
```
---
## ✅ Checklist d'Implémentation
### Phase 1 : Setup
- [ ] Ajouter dépendances dans `pom.xml`
- [ ] Configurer `application.properties`
- [ ] Tester Kafka avec Quarkus Dev Services
- [ ] Créer les DTOs d'événements
### Phase 2 : Migration WebSocket
- [ ] Créer `NotificationWebSocketNext`
- [ ] Créer `ChatWebSocketNext`
- [ ] Tester avec le frontend existant
- [ ] Comparer performances (avant/après)
### Phase 3 : Intégration Kafka
- [ ] Créer `NotificationKafkaBridge`
- [ ] Créer `ChatKafkaBridge`
- [ ] Modifier `FriendshipService` pour publier dans Kafka
- [ ] Modifier `MessageService` pour publier dans Kafka
- [ ] Modifier `SocialPostService` pour les réactions
### Phase 4 : Frontend
- [ ] Améliorer `RealtimeNotificationService` avec heartbeat
- [ ] Améliorer `ChatWebSocketService` avec reconnect
- [ ] Tester la reconnexion automatique
- [ ] Tester multi-device
### Phase 5 : Tests & Monitoring
- [ ] Tests unitaires des bridges
- [ ] Tests d'intégration end-to-end
- [ ] Configurer monitoring Kafka
- [ ] Configurer alertes
---
## 📚 Ressources Complémentaires
- [Quarkus WebSockets Next Tutorial](https://quarkus.io/guides/websockets-next-tutorial)
- [Quarkus Kafka Guide](https://quarkus.io/guides/kafka)
- [Reactive Messaging HTTP Extension](https://docs.quarkiverse.io/quarkus-reactive-messaging-http/dev/reactive-messaging-websocket.html)
- [Kafka Best Practices](https://kafka.apache.org/documentation/#bestPractices)

View File

@@ -8860,7 +8860,7 @@ Removed unused interceptor INTERCEPTOR bean [bindings=[@MethodValidated], target
{"timestamp":"2026-01-05T22:35:44.20583Z","sequence":8419,"loggerClassName":"org.jboss.logging.DelegatingBasicLogger","loggerName":"org.hibernate.event.internal.AbstractFlushingEventListener","level":"DEBUG","message":"Flushed: 1 insertions, 0 updates, 0 deletions to 1 objects","threadName":"executor-thread-1","threadId":244,"mdc":{},"ndc":"","hostName":"gbanedahoud","processName":"C:\\Program Files\\Java\\jdk-17\\bin\\java.exe","processId":28280}
{"timestamp":"2026-01-05T22:35:44.20583Z","sequence":8421,"loggerClassName":"org.jboss.logging.DelegatingBasicLogger","loggerName":"org.hibernate.event.internal.AbstractFlushingEventListener","level":"DEBUG","message":"Flushed: 1 (re)creations, 0 updates, 0 removals to 1 collections","threadName":"executor-thread-1","threadId":244,"mdc":{},"ndc":"","hostName":"gbanedahoud","processName":"C:\\Program Files\\Java\\jdk-17\\bin\\java.exe","processId":28280}
{"timestamp":"2026-01-05T22:35:44.20828Z","sequence":8423,"loggerClassName":"org.jboss.logging.DelegatingBasicLogger","loggerName":"org.hibernate.internal.util.EntityPrinter","level":"DEBUG","message":"Listing entities:","threadName":"executor-thread-1","threadId":244,"mdc":{},"ndc":"","hostName":"gbanedahoud","processName":"C:\\Program Files\\Java\\jdk-17\\bin\\java.exe","processId":28280}
{"timestamp":"2026-01-05T22:35:44.209296Z","sequence":8424,"loggerClassName":"org.jboss.logging.DelegatingBasicLogger","loggerName":"org.hibernate.internal.util.EntityPrinter","level":"DEBUG","message":"com.lions.dev.entity.users.Users{createdAt=2026-01-05T22:35:44.135208300, motDePasse=$2a$12$bOn5irq0ntL5gZ0MgW3LdeeSpQv6fqKioxRcH/EUiYpw8oVXch9g2, preferredCategory=null, role=USER, favoriteEvents=[], id=9c46a967-dd49-494c-a9b1-cf5bb601f1d0, nom=Dady, profileImageUrl=https://via.placeholder.com/150, email=admin@afterwork.lions.dev, prenoms=One, updatedAt=2026-01-05T22:35:44.135208300}","threadName":"executor-thread-1","threadId":244,"mdc":{},"ndc":"","hostName":"gbanedahoud","processName":"C:\\Program Files\\Java\\jdk-17\\bin\\java.exe","processId":28280}
{"timestamp":"2026-01-05T22:35:44.209296Z","sequence":8424,"loggerClassName":"org.jboss.logging.DelegatingBasicLogger","loggerName":"org.hibernate.internal.util.EntityPrinter","level":"DEBUG","message":"com.lions.dev.entity.users.Users{createdAt=2026-01-05T22:35:44.135208300, motDePasse=$2a$12$bOn5irq0ntL5gZ0MgW3LdeeSpQv6fqKioxRcH/EUiYpw8oVXch9g2, preferredCategory=null, role=USER, favoriteEvents=[], id=9c46a967-dd49-494c-a9b1-cf5bb601f1d0, nom=Dady, profileImageUrl=https://placehold.co/150x150.png, email=admin@afterwork.lions.dev, prenoms=One, updatedAt=2026-01-05T22:35:44.135208300}","threadName":"executor-thread-1","threadId":244,"mdc":{},"ndc":"","hostName":"gbanedahoud","processName":"C:\\Program Files\\Java\\jdk-17\\bin\\java.exe","processId":28280}
{"timestamp":"2026-01-05T22:35:44.2325806Z","sequence":8425,"loggerClassName":"org.jboss.logging.Logger","loggerName":"org.hibernate.SQL","level":"DEBUG","message":"\r\n \u001b[34minsert\u001b[0m \r\n \u001b[34minto\u001b[0m\r\n users\r\n (created_at, email, mot_de_passe, nom, preferred_category, prenoms, profile_image_url, role, updated_at, id) \r\n \u001b[34mvalues\u001b[0m\r\n (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)","threadName":"executor-thread-1","threadId":244,"mdc":{},"ndc":"","hostName":"gbanedahoud","processName":"C:\\Program Files\\Java\\jdk-17\\bin\\java.exe","processId":28280}
[Hibernate]
insert

View File

@@ -29,7 +29,7 @@ spec:
spec:
containers:
- name: afterwork-api
image: registry.lions.dev/afterwork-api:1.0.0
image: registry.lions.dev/lionsdev/mic-after-work-server-impl-quarkus-main:d659416
imagePullPolicy: Always
ports:
- containerPort: 8080
@@ -75,5 +75,5 @@ spec:
emptyDir:
sizeLimit: 1Gi
imagePullSecrets:
- name: registry-credentials
- name: lionsregistry-secret
restartPolicy: Always

View File

@@ -27,9 +27,7 @@ metadata:
# WebSocket support
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
nginx.ingress.kubernetes.io/configuration-snippet: |
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
nginx.ingress.kubernetes.io/websocket-services: "mic-after-work-server-impl-quarkus-main-service"
# Security headers and CORS
nginx.ingress.kubernetes.io/enable-cors: "true"

13
pom.xml
View File

@@ -76,9 +76,20 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
</dependency>
<!-- WebSockets Next (remplace quarkus-websockets) -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets</artifactId>
<artifactId>quarkus-websockets-next</artifactId>
</dependency>
<!-- Kafka Reactive Messaging -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-messaging-kafka</artifactId>
</dependency>
<!-- JSON Serialization pour Kafka -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jsonb</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>

View File

@@ -0,0 +1,75 @@
package com.lions.dev.dto.events;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.UUID;
/**
* Événement de message chat publié dans Kafka.
*
* Utilisé pour garantir la livraison des messages même si le destinataire
* est temporairement déconnecté. Le message est persisté dans Kafka et
* délivré dès la reconnexion.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessageEvent {
/**
* ID de la conversation (utilisé comme clé Kafka pour garantir l'ordre).
*/
private String conversationId;
/**
* ID de l'expéditeur.
*/
private String senderId;
/**
* ID du destinataire.
*/
private String recipientId;
/**
* Contenu du message.
*/
private String content;
/**
* ID unique du message.
*/
private String messageId;
/**
* Timestamp de création.
*/
private Long timestamp;
/**
* Type d'événement (message, typing, read_receipt, delivery_confirmation).
*/
private String eventType;
/**
* Données additionnelles (pour typing indicators, read receipts, etc.).
*/
private java.util.Map<String, Object> metadata;
/**
* Constructeur pour un message standard.
*/
public ChatMessageEvent(String conversationId, String senderId, String recipientId,
String content, String messageId) {
this.conversationId = conversationId;
this.senderId = senderId;
this.recipientId = recipientId;
this.content = content;
this.messageId = messageId;
this.eventType = "message";
this.timestamp = System.currentTimeMillis();
}
}

View File

@@ -0,0 +1,52 @@
package com.lions.dev.dto.events;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.Map;
import java.util.UUID;
/**
* Événement de notification publié dans Kafka.
*
* Utilisé pour découpler les services métier des WebSockets.
* Les services publient dans Kafka, et un bridge consomme depuis Kafka
* pour envoyer via WebSocket aux clients connectés.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class NotificationEvent {
/**
* ID de l'utilisateur destinataire (utilisé comme clé Kafka pour routing).
*/
private String userId;
/**
* Type de notification (friend_request, friend_request_accepted, event_reminder, etc.).
*/
private String type;
/**
* Données de la notification (contenu spécifique au type).
*/
private Map<String, Object> data;
/**
* Timestamp de création de l'événement.
*/
private Long timestamp;
/**
* Constructeur simplifié (timestamp auto-généré).
*/
public NotificationEvent(String userId, String type, Map<String, Object> data) {
this.userId = userId;
this.type = type;
this.data = data;
this.timestamp = System.currentTimeMillis();
}
}

View File

@@ -0,0 +1,48 @@
package com.lions.dev.dto.events;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Événement de présence (online/offline) publié dans Kafka.
*
* Utilisé pour notifier les amis quand un utilisateur se connecte/déconnecte.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PresenceEvent {
/**
* ID de l'utilisateur concerné.
*/
private String userId;
/**
* Statut (online, offline).
*/
private String status;
/**
* Timestamp de dernière activité.
*/
private Long lastSeen;
/**
* Timestamp de l'événement.
*/
private Long timestamp;
/**
* Constructeur simplifié.
*/
public PresenceEvent(String userId, String status, Long lastSeen) {
this.userId = userId;
this.status = status;
this.lastSeen = lastSeen;
this.timestamp = System.currentTimeMillis();
}
}

View File

@@ -0,0 +1,63 @@
package com.lions.dev.dto.events;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.Map;
/**
* Événement de réaction (like, comment, share) publié dans Kafka.
*
* Utilisé pour notifier en temps réel les réactions sur les posts,
* stories et événements.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReactionEvent {
/**
* ID du post/story/event concerné (utilisé comme clé Kafka).
*/
private String targetId;
/**
* Type de cible (post, story, event).
*/
private String targetType;
/**
* ID de l'utilisateur qui réagit.
*/
private String userId;
/**
* Type de réaction (like, comment, share).
*/
private String reactionType;
/**
* Données additionnelles (contenu du commentaire, etc.).
*/
private Map<String, Object> data;
/**
* Timestamp de création.
*/
private Long timestamp;
/**
* Constructeur simplifié.
*/
public ReactionEvent(String targetId, String targetType, String userId,
String reactionType, Map<String, Object> data) {
this.targetId = targetId;
this.targetType = targetType;
this.userId = userId;
this.reactionType = reactionType;
this.data = data;
this.timestamp = System.currentTimeMillis();
}
}

View File

@@ -0,0 +1,85 @@
package com.lions.dev.dto.request.establishment;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
/**
* DTO pour la création d'un établissement.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Seuls les responsables d'établissement peuvent créer des établissements.
*/
@Getter
@Setter
public class EstablishmentCreateRequestDTO {
@NotNull(message = "Le nom de l'établissement est obligatoire.")
@Size(min = 2, max = 200, message = "Le nom doit comporter entre 2 et 200 caractères.")
private String name;
@NotNull(message = "Le type d'établissement est obligatoire.")
private String type;
@NotNull(message = "L'adresse est obligatoire.")
private String address;
@NotNull(message = "La ville est obligatoire.")
private String city;
@NotNull(message = "Le code postal est obligatoire.")
private String postalCode;
private String description;
private String phoneNumber;
private String website;
private String priceRange;
private String verificationStatus = "PENDING"; // v2.0 - Par défaut PENDING
private Double latitude;
private Double longitude;
@NotNull(message = "L'identifiant du responsable est obligatoire.")
private UUID managerId;
// Champs dépréciés (v1.0) - conservés pour compatibilité mais ignorés
/**
* @deprecated Supprimé en v2.0 (utiliser manager.email à la place).
*/
@Deprecated
private String email;
/**
* @deprecated Supprimé en v2.0 (utiliser establishment_media à la place).
*/
@Deprecated
private String imageUrl;
/**
* @deprecated Utiliser averageRating calculé depuis reviews à la place.
*/
@Deprecated
private Double rating;
/**
* @deprecated Supprimé en v2.0.
*/
@Deprecated
private Integer capacity;
/**
* @deprecated Supprimé en v2.0 (utiliser establishment_amenities à la place).
*/
@Deprecated
private String amenities;
/**
* @deprecated Supprimé en v2.0 (utiliser business_hours à la place).
*/
@Deprecated
private String openingHours;
}

View File

@@ -0,0 +1,31 @@
package com.lions.dev.dto.request.establishment;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
/**
* DTO pour la requête d'upload d'un média d'établissement.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
*/
@Getter
@Setter
public class EstablishmentMediaRequestDTO {
@NotBlank(message = "L'URL du média est obligatoire")
private String mediaUrl;
@NotBlank(message = "Le type de média est obligatoire")
private String mediaType; // PHOTO ou VIDEO
private String name; // Nom du fichier (fileName) - optionnel, peut être extrait de mediaUrl si non fourni
private String thumbnailUrl; // Optionnel, pour les vidéos
private Integer displayOrder = 0; // Ordre d'affichage (par défaut 0)
private String uploadedByUserId; // ID de l'utilisateur qui upload (optionnel, peut être extrait du contexte)
}

View File

@@ -0,0 +1,25 @@
package com.lions.dev.dto.request.establishment;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* DTO pour soumettre ou modifier une note d'établissement.
*/
@Getter
@Setter
public class EstablishmentRatingRequestDTO {
@NotNull(message = "La note est obligatoire.")
@Min(value = 1, message = "La note doit être au moins 1 étoile.")
@Max(value = 5, message = "La note ne peut pas dépasser 5 étoiles.")
private Integer rating; // Note de 1 à 5
@Size(max = 2000, message = "Le commentaire ne peut pas dépasser 2000 caractères.")
private String comment; // Commentaire optionnel
}

View File

@@ -0,0 +1,34 @@
package com.lions.dev.dto.request.establishment;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* DTO pour la mise à jour d'un établissement.
*/
@Getter
@Setter
public class EstablishmentUpdateRequestDTO {
@Size(min = 2, max = 200, message = "Le nom doit comporter entre 2 et 200 caractères.")
private String name;
private String type;
private String address;
private String city;
private String postalCode;
private String description;
private String phoneNumber;
private String email;
private String website;
private String imageUrl;
private Double rating;
private String priceRange;
private Integer capacity;
private String amenities;
private String openingHours;
private Double latitude;
private Double longitude;
}

View File

@@ -9,6 +9,10 @@ import java.time.LocalDateTime;
/**
* DTO pour la création d'un événement.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Ce DTO est utilisé dans les requêtes de création d'événements, envoyant les informations
* nécessaires comme le titre, les dates, la description, le créateur, et d'autres attributs.
*/
@@ -28,15 +32,45 @@ public class EventCreateRequestDTO {
@NotNull(message = "La date de fin est obligatoire.")
private LocalDateTime endDate; // Date de fin de l'événement
private String location; // Lieu de l'événement
private UUID establishmentId; // v2.0 - ID de l'établissement où se déroule l'événement
private String category; // Catégorie de l'événement
private String link; // Lien d'information supplémentaire
private String imageUrl; // URL de l'image associée à l'événement
private Integer maxParticipants; // Nombre maximum de participants autorisés
private String tags; // Tags/mots-clés associés à l'événement (séparés par des virgules)
private String organizer; // Nom de l'organisateur de l'événement
private Integer participationFee; // Frais de participation en centimes
private Boolean isPrivate = false; // v2.0 - Indique si l'événement est privé
private Boolean waitlistEnabled = false; // v2.0 - Indique si la liste d'attente est activée
private String privacyRules; // Règles de confidentialité de l'événement
private String transportInfo; // Informations sur les transports disponibles
private String accommodationInfo; // Informations sur l'hébergement
private String accessibilityInfo; // Informations sur l'accessibilité
private String parkingInfo; // Informations sur le parking
private String securityProtocol; // Protocole de sécurité de l'événement
@NotNull(message = "L'identifiant du créateur est obligatoire.")
private UUID creatorId; // Identifiant du créateur de l'événement
// Champ déprécié (v1.0) - conservé pour compatibilité mais ignoré
/**
* @deprecated Supprimé en v2.0 (utiliser establishmentId à la place).
*/
@Deprecated
private String location;
public EventCreateRequestDTO() {
System.out.println("[LOG] DTO de requête de création d'événement initialisé.");
}
/**
* Méthode pour obtenir le lieu (compatibilité v1.0 et v2.0).
* Retourne null car location est déprécié en v2.0.
*
* @return Le lieu (null en v2.0, utiliser establishmentId à la place).
*/
public String getLocation() {
return location; // Retourne null en v2.0
}
}

View File

@@ -16,7 +16,10 @@ import lombok.Setter;
@AllArgsConstructor
public class EventReadManyByIdRequestDTO {
private UUID userId; // Identifiant de l'utilisateur pour lequel on souhaite obtenir les événements
private UUID id; // v2.0 - Identifiant de l'utilisateur pour lequel on souhaite obtenir les événements
private Integer page = 0; // v2.0 - Numéro de la page (0-indexé)
private Integer size = 10; // v2.0 - Taille de la page
// Ajoutez ici d'autres critères de filtre si besoin, comme une plage de dates, un statut, etc.
}

View File

@@ -23,7 +23,7 @@ public class SocialPostCreateRequestDTO {
private String content; // Le contenu textuel du post
@NotNull(message = "L'identifiant de l'utilisateur est obligatoire.")
private UUID userId; // L'ID de l'utilisateur créateur
private UUID creatorId; // v2.0 - L'ID de l'utilisateur créateur
@Size(max = 500, message = "L'URL de l'image ne peut pas dépasser 500 caractères.")
private String imageUrl; // URL de l'image (optionnel)

View File

@@ -19,7 +19,7 @@ import lombok.Setter;
public class StoryCreateRequestDTO {
@NotNull(message = "L'identifiant de l'utilisateur est obligatoire.")
private UUID userId; // L'ID de l'utilisateur créateur
private UUID creatorId; // v2.0 - L'ID de l'utilisateur créateur
@NotNull(message = "Le type de média est obligatoire.")
private MediaType mediaType; // Type de média (IMAGE ou VIDEO)

View File

@@ -9,6 +9,10 @@ import org.slf4j.LoggerFactory;
/**
* DTO pour la requête d'authentification de l'utilisateur.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Utilisé pour encapsuler les informations nécessaires lors de l'authentification d'un utilisateur.
*/
@Getter
@@ -25,9 +29,16 @@ public class UserAuthenticateRequestDTO {
private String email;
/**
* Mot de passe de l'utilisateur en texte clair.
* Ce champ sera haché avant d'être utilisé pour l'authentification.
* Mot de passe hashé de l'utilisateur (v2.0).
* Format standardisé pour l'authentification.
*/
private String password_hash; // v2.0
/**
* Mot de passe de l'utilisateur en texte clair (v1.0 - déprécié).
* @deprecated Utiliser {@link #password_hash} à la place.
*/
@Deprecated
private String motDePasse;
/**
@@ -37,6 +48,15 @@ public class UserAuthenticateRequestDTO {
logger.info("UserAuthenticateRequestDTO - DTO pour l'authentification initialisé");
}
/**
* Méthode pour obtenir le mot de passe (compatibilité v1.0 et v2.0).
*
* @return Le mot de passe (password_hash ou motDePasse).
*/
public String getPassword() {
return password_hash != null ? password_hash : motDePasse;
}
// Méthode personnalisée pour loguer les détails de la requête
public void logRequestDetails() {
logger.info("Authentification demandée pour l'email: {}", email);

View File

@@ -7,21 +7,25 @@ import lombok.Getter;
import lombok.Setter;
/**
* DTO pour la création et l'authentification d'un utilisateur.
* Ce DTO est utilisé dans les requêtes pour créer ou authentifier un utilisateur,
* contenant les informations comme le nom, les prénoms, l'email, et le mot de passe.
* DTO pour la création d'un utilisateur.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Ce DTO est utilisé dans les requêtes pour créer un utilisateur,
* contenant les informations comme le prénom, le nom, l'email, et le mot de passe.
*/
@Getter
@Setter
public class UserCreateRequestDTO {
@NotNull(message = "Le nom est obligatoire.")
@Size(min = 1, max = 100, message = "Le nom doit comporter entre 1 et 100 caractères.")
private String nom;
@NotNull(message = "Le prénom est obligatoire.")
@Size(min = 1, max = 100, message = "Le prénom doit comporter entre 1 et 100 caractères.")
private String firstName; // v2.0
@NotNull(message = "Les prénoms sont obligatoires.")
@Size(min = 1, max = 100, message = "Les prénoms doivent comporter entre 1 et 100 caractères.")
private String prenoms;
@NotNull(message = "Le nom de famille est obligatoire.")
@Size(min = 1, max = 100, message = "Le nom de famille doit comporter entre 1 et 100 caractères.")
private String lastName; // v2.0
@NotNull(message = "L'adresse email est obligatoire.")
@Email(message = "Veuillez fournir une adresse email valide.")
@@ -29,11 +33,86 @@ public class UserCreateRequestDTO {
@NotNull(message = "Le mot de passe est obligatoire.")
@Size(min = 6, message = "Le mot de passe doit comporter au moins 6 caractères.")
private String motDePasse;
private String password; // v2.0 - sera hashé en passwordHash
private String profileImageUrl;
private String bio; // v2.0
private Integer loyaltyPoints = 0; // v2.0
/**
* Préférences utilisateur (v2.0).
*
* Structure attendue:
* {
* "preferredCategory": "RESTAURANT" | "BAR" | "CLUB" | "CAFE" | "EVENT" | null,
* "notifications": {
* "email": boolean,
* "push": boolean
* },
* "language": "fr" | "en" | "es"
* }
*
* Exemple:
* {
* "preferredCategory": "RESTAURANT",
* "notifications": {
* "email": true,
* "push": true
* },
* "language": "fr"
* }
*/
private java.util.Map<String, Object> preferences; // v2.0
// Ajout du rôle avec validation
@NotNull(message = "Le rôle est obligatoire.")
private String role; // Rôle de l'utilisateur (par exemple : ADMIN, USER, etc.)
private String role; // Rôle de l'utilisateur (par exemple : ADMIN, USER, MANAGER, etc.)
// Champs de compatibilité v1.0 (dépréciés mais supportés pour migration progressive)
/**
* @deprecated Utiliser {@link #firstName} à la place.
*/
@Deprecated
private String prenoms;
/**
* @deprecated Utiliser {@link #lastName} à la place.
*/
@Deprecated
private String nom;
/**
* @deprecated Utiliser {@link #password} à la place.
*/
@Deprecated
private String motDePasse;
/**
* Méthode pour obtenir le prénom (compatibilité v1.0 et v2.0).
*
* @return Le prénom (firstName ou prenoms).
*/
public String getFirstName() {
return firstName != null ? firstName : prenoms;
}
/**
* Méthode pour obtenir le nom de famille (compatibilité v1.0 et v2.0).
*
* @return Le nom de famille (lastName ou nom).
*/
public String getLastName() {
return lastName != null ? lastName : nom;
}
/**
* Méthode pour obtenir le mot de passe (compatibilité v1.0 et v2.0).
*
* @return Le mot de passe (password ou motDePasse).
*/
public String getPassword() {
return password != null ? password : motDePasse;
}
}

View File

@@ -40,8 +40,9 @@ public class ConversationResponseDTO {
Users otherUser = conversation.getOtherUser(currentUser);
if (otherUser != null) {
this.participantId = otherUser.getId();
this.participantFirstName = otherUser.getPrenoms();
this.participantLastName = otherUser.getNom();
// v2.0 - Utiliser les nouveaux noms de champs
this.participantFirstName = otherUser.getFirstName();
this.participantLastName = otherUser.getLastName();
this.participantProfileImageUrl = otherUser.getProfileImageUrl();
}

View File

@@ -30,14 +30,15 @@ public class MessageResponseDTO {
private LocalDateTime timestamp;
/**
* Constructeur depuis une entité Message.
* Constructeur depuis une entité Message (v2.0).
*/
public MessageResponseDTO(Message message) {
this.id = message.getId();
this.conversationId = message.getConversation().getId();
this.senderId = message.getSender().getId();
this.senderFirstName = message.getSender().getPrenoms();
this.senderLastName = message.getSender().getNom();
// v2.0 - Utiliser les nouveaux noms de champs
this.senderFirstName = message.getSender().getFirstName();
this.senderLastName = message.getSender().getLastName();
this.senderProfileImageUrl = message.getSender().getProfileImageUrl();
this.content = message.getContent();
this.attachmentType = message.getMessageType();

View File

@@ -64,8 +64,9 @@ public class CommentResponseDTO {
this.id = comment.getId(); // Identifiant unique du commentaire
this.texte = comment.getText(); // Texte du commentaire
this.userId = comment.getUser().getId(); // Identifiant de l'utilisateur (auteur du commentaire)
this.userNom = comment.getUser().getNom(); // Nom de l'utilisateur
this.userPrenoms = comment.getUser().getPrenoms(); // Prénom de l'utilisateur
// v2.0 - Utiliser les nouveaux noms de champs
this.userNom = comment.getUser().getLastName(); // Nom de famille de l'utilisateur (v2.0)
this.userPrenoms = comment.getUser().getFirstName(); // Prénom de l'utilisateur (v2.0)
}
}
}

View File

@@ -0,0 +1,67 @@
package com.lions.dev.dto.response.establishment;
import com.lions.dev.entity.establishment.EstablishmentMedia;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO pour renvoyer les informations d'un média d'établissement.
*/
@Getter
public class EstablishmentMediaResponseDTO {
private String id;
private String establishmentId;
private String mediaUrl;
private String mediaType; // "PHOTO" ou "VIDEO"
private String thumbnailUrl;
private MediaUploaderDTO uploadedBy;
private LocalDateTime uploadedAt;
private Integer displayOrder;
/**
* Constructeur qui transforme une entité EstablishmentMedia en DTO.
*
* @param media Le média à convertir en DTO.
*/
public EstablishmentMediaResponseDTO(EstablishmentMedia media) {
this.id = media.getId().toString();
this.establishmentId = media.getEstablishment().getId().toString();
this.mediaUrl = media.getMediaUrl();
this.mediaType = media.getMediaType().name();
this.thumbnailUrl = media.getThumbnailUrl();
this.uploadedAt = media.getUploadedAt();
this.displayOrder = media.getDisplayOrder();
if (media.getUploadedBy() != null) {
// v2.0 - Utiliser les nouveaux noms de champs
this.uploadedBy = new MediaUploaderDTO(
media.getUploadedBy().getId().toString(),
media.getUploadedBy().getFirstName(),
media.getUploadedBy().getLastName(),
media.getUploadedBy().getProfileImageUrl()
);
}
}
/**
* DTO interne pour les informations de l'uploader.
*/
@Getter
public static class MediaUploaderDTO {
private final String id;
private final String firstName;
private final String lastName;
private final String profileImageUrl;
public MediaUploaderDTO(String id, String firstName, String lastName, String profileImageUrl) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
this.profileImageUrl = profileImageUrl;
}
}
}

View File

@@ -0,0 +1,37 @@
package com.lions.dev.dto.response.establishment;
import com.lions.dev.entity.establishment.EstablishmentRating;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* DTO pour renvoyer les informations d'une note d'établissement.
*/
@Getter
public class EstablishmentRatingResponseDTO {
private String id;
private String establishmentId;
private String userId;
private Integer rating;
private String comment;
private LocalDateTime ratedAt;
private LocalDateTime updatedAt;
/**
* Constructeur qui transforme une entité EstablishmentRating en DTO.
*
* @param rating La note à convertir en DTO.
*/
public EstablishmentRatingResponseDTO(EstablishmentRating rating) {
this.id = rating.getId().toString();
this.establishmentId = rating.getEstablishment().getId().toString();
this.userId = rating.getUser().getId().toString();
this.rating = rating.getRating();
this.comment = rating.getComment();
this.ratedAt = rating.getRatedAt();
this.updatedAt = rating.getUpdatedAt();
}
}

View File

@@ -0,0 +1,30 @@
package com.lions.dev.dto.response.establishment;
import lombok.Getter;
import java.util.Map;
/**
* DTO pour renvoyer les statistiques de notation d'un établissement.
*/
@Getter
public class EstablishmentRatingStatsResponseDTO {
private Double averageRating; // Note moyenne (0.0 à 5.0)
private Integer totalRatings; // Nombre total de notes
private Map<Integer, Integer> distribution; // Distribution par étoile {5: 10, 4: 5, ...}
/**
* Constructeur pour créer les statistiques de notation.
*
* @param averageRating La note moyenne
* @param totalRatings Le nombre total de notes
* @param distribution La distribution des notes par étoile
*/
public EstablishmentRatingStatsResponseDTO(Double averageRating, Integer totalRatings, Map<Integer, Integer> distribution) {
this.averageRating = averageRating;
this.totalRatings = totalRatings;
this.distribution = distribution;
}
}

View File

@@ -0,0 +1,143 @@
package com.lions.dev.dto.response.establishment;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.establishment.MediaType;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO pour renvoyer les informations d'un établissement.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Ce DTO est utilisé pour structurer les données retournées dans les réponses
* après les opérations sur les établissements (création, récupération, mise à jour).
*/
@Getter
public class EstablishmentResponseDTO {
private String id;
private String name;
private String type;
private String address;
private String city;
private String postalCode;
private String description;
private String phoneNumber;
private String website;
private Double averageRating; // Note moyenne calculée
private Integer totalReviewsCount; // v2.0 - renommé depuis totalRatingsCount
private String priceRange;
private String verificationStatus; // v2.0 - PENDING, VERIFIED, REJECTED
private Double latitude;
private Double longitude;
private String managerId;
private String managerEmail;
private String managerFirstName; // v2.0
private String managerLastName; // v2.0
private String mainImageUrl; // v2.0 - URL de l'image principale (premier média avec displayOrder 0)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// Champs dépréciés (v1.0) - conservés pour compatibilité
/**
* @deprecated Utiliser {@link #averageRating} à la place.
*/
@Deprecated
private Double rating;
/**
* @deprecated Supprimé en v2.0 (utiliser manager.email à la place).
*/
@Deprecated
private String email;
/**
* @deprecated Supprimé en v2.0 (utiliser establishment_media à la place).
*/
@Deprecated
private String imageUrl;
/**
* @deprecated Supprimé en v2.0.
*/
@Deprecated
private Integer capacity;
/**
* @deprecated Supprimé en v2.0 (utiliser establishment_amenities à la place).
*/
@Deprecated
private String amenities;
/**
* @deprecated Supprimé en v2.0 (utiliser business_hours à la place).
*/
@Deprecated
private String openingHours;
/**
* @deprecated Utiliser {@link #totalReviewsCount} à la place.
*/
@Deprecated
private Integer totalRatingsCount;
/**
* Constructeur qui transforme une entité Establishment en DTO (v2.0).
*
* @param establishment L'établissement à convertir en DTO.
*/
public EstablishmentResponseDTO(Establishment establishment) {
this.id = establishment.getId().toString();
this.name = establishment.getName();
this.type = establishment.getType();
this.address = establishment.getAddress();
this.city = establishment.getCity();
this.postalCode = establishment.getPostalCode();
this.description = establishment.getDescription();
this.phoneNumber = establishment.getPhoneNumber();
this.website = establishment.getWebsite();
this.averageRating = establishment.getAverageRating();
this.totalReviewsCount = establishment.getTotalReviewsCount(); // v2.0
this.priceRange = establishment.getPriceRange();
this.verificationStatus = establishment.getVerificationStatus(); // v2.0
this.latitude = establishment.getLatitude();
this.longitude = establishment.getLongitude();
if (establishment.getManager() != null) {
this.managerId = establishment.getManager().getId().toString();
this.managerEmail = establishment.getManager().getEmail();
this.managerFirstName = establishment.getManager().getFirstName(); // v2.0
this.managerLastName = establishment.getManager().getLastName(); // v2.0
}
// Récupérer l'image principale (premier média photo avec displayOrder 0 ou le premier disponible)
if (establishment.getMedias() != null && !establishment.getMedias().isEmpty()) {
this.mainImageUrl = establishment.getMedias().stream()
.filter(media -> media.getMediaType() == MediaType.PHOTO)
.sorted((a, b) -> Integer.compare(
a.getDisplayOrder() != null ? a.getDisplayOrder() : Integer.MAX_VALUE,
b.getDisplayOrder() != null ? b.getDisplayOrder() : Integer.MAX_VALUE))
.map(media -> media.getMediaUrl())
.findFirst()
.orElse(null);
} else {
this.mainImageUrl = null;
}
this.createdAt = establishment.getCreatedAt();
this.updatedAt = establishment.getUpdatedAt();
// Compatibilité v1.0 - valeurs null pour les champs dépréciés
this.rating = null;
this.email = null;
this.imageUrl = null;
this.capacity = null;
this.amenities = null;
this.openingHours = null;
this.totalRatingsCount = this.totalReviewsCount; // Alias pour compatibilité
}
}

View File

@@ -1,10 +1,16 @@
package com.lions.dev.dto.response.events;
import com.lions.dev.entity.events.Events;
import com.lions.dev.repository.UsersRepository;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO pour renvoyer les informations d'un événement.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Ce DTO est utilisé pour structurer les données retournées dans les réponses
* après les opérations sur les événements (création, récupération).
*/
@@ -16,33 +22,89 @@ public class EventCreateResponseDTO {
private String description; // Description de l'événement
private LocalDateTime startDate; // Date de début de l'événement
private LocalDateTime endDate; // Date de fin de l'événement
private String location; // Lieu de l'événement
private String establishmentId; // v2.0 - ID de l'établissement où se déroule l'événement
private String establishmentName; // v2.0 - Nom de l'établissement
private String category; // Catégorie de l'événement
private String link; // Lien vers plus d'informations
private String imageUrl; // URL d'une image pour l'événement
private String creatorId; // ID du créateur de l'événement
private String creatorEmail; // Email du créateur de l'événement
private String creatorFirstName; // Prénom du créateur de l'événement
private String creatorLastName; // Nom de famille du création de l'événement
private String status; // Statut de l'événement
private String creatorFirstName; // v2.0 - Prénom du créateur de l'événement
private String creatorLastName; // v2.0 - Nom de famille du créateur de l'événement
private String status; // Statut de l'événement (OPEN, CLOSED, CANCELLED, COMPLETED)
private Boolean isPrivate; // v2.0 - Indique si l'événement est privé
private Boolean waitlistEnabled; // v2.0 - Indique si la liste d'attente est activée
private Integer maxParticipants; // Nombre maximum de participants autorisés
private Integer participationFee; // Frais de participation en centimes
private Long reactionsCount; // ✅ Nombre de réactions (utilisateurs qui ont cet événement en favori)
private Boolean isFavorite; // ✅ Indique si l'utilisateur actuel a cet événement en favori (optionnel, dépend du contexte)
// Champ déprécié (v1.0) - conservé pour compatibilité
/**
* @deprecated Utiliser {@link #establishmentId} et {@link #establishmentName} à la place.
*/
@Deprecated
private String location;
/**
* Constructeur qui transforme une entité Events en DTO.
* Constructeur qui transforme une entité Events en DTO (v2.0).
* Utilise UsersRepository pour calculer reactionsCount et isFavorite.
*
* @param event L'événement à convertir en DTO.
* @param usersRepository Le repository pour compter les réactions (peut être null).
* @param currentUserId L'ID de l'utilisateur actuel pour vérifier isFavorite (peut être null).
*/
public EventCreateResponseDTO(Events event) {
public EventCreateResponseDTO(Events event, UsersRepository usersRepository, UUID currentUserId) {
this.id = event.getId().toString();
this.title = event.getTitle();
this.description = event.getDescription();
this.startDate = event.getStartDate();
this.endDate = event.getEndDate();
this.location = event.getLocation();
this.category = event.getCategory();
this.link = event.getLink();
this.imageUrl = event.getImageUrl();
this.creatorEmail = event.getCreator().getEmail();
this.creatorFirstName = event.getCreator().getPrenoms();
this.creatorLastName = event.getCreator().getNom();
this.status = event.getStatus();
this.isPrivate = event.getIsPrivate(); // v2.0
this.waitlistEnabled = event.getWaitlistEnabled(); // v2.0
this.maxParticipants = event.getMaxParticipants();
this.participationFee = event.getParticipationFee();
// ✅ Calculer reactionsCount si usersRepository est fourni
if (usersRepository != null) {
this.reactionsCount = usersRepository.countUsersWithFavoriteEvent(event.getId());
} else {
this.reactionsCount = 0L;
}
// ✅ Vérifier isFavorite si currentUserId est fourni
if (currentUserId != null && usersRepository != null) {
this.isFavorite = usersRepository.hasUserFavoriteEvent(currentUserId, event.getId());
} else {
this.isFavorite = null;
}
// v2.0 - Informations sur l'établissement
if (event.getEstablishment() != null) {
this.establishmentId = event.getEstablishment().getId().toString();
this.establishmentName = event.getEstablishment().getName();
this.location = event.getLocation(); // Méthode qui retourne l'adresse de l'établissement
}
// v2.0 - Informations sur le créateur
if (event.getCreator() != null) {
this.creatorId = event.getCreator().getId().toString();
this.creatorEmail = event.getCreator().getEmail();
this.creatorFirstName = event.getCreator().getFirstName(); // v2.0
this.creatorLastName = event.getCreator().getLastName(); // v2.0
}
}
/**
* Constructeur simplifié sans calcul de réactions (pour compatibilité).
*
* @param event L'événement à convertir en DTO.
*/
public EventCreateResponseDTO(Events event) {
this(event, null, null);
}
}

View File

@@ -27,7 +27,7 @@ public class EventReadManyByIdResponseDTO {
private String profileImageUrl; // URL de l'image de profil de l'utilisateur qui a criané l'événement
/**
* Constructeur qui transforme une entité Events en DTO de réponse.
* Constructeur qui transforme une entité Events en DTO de réponse (v2.0).
*
* @param event L'événement à convertir en DTO.
*/
@@ -37,14 +37,16 @@ public class EventReadManyByIdResponseDTO {
this.description = event.getDescription();
this.startDate = event.getStartDate();
this.endDate = event.getEndDate();
// v2.0 - Utiliser getLocation() qui retourne l'adresse de l'établissement
this.location = event.getLocation();
this.category = event.getCategory();
this.link = event.getLink();
this.imageUrl = event.getImageUrl();
this.status = event.getStatus();
this.creatorEmail = event.getCreator().getEmail();
this.creatorFirstName = event.getCreator().getPrenoms();
this.creatorLastName = event.getCreator().getNom();
// v2.0 - Utiliser les nouveaux noms de champs
this.creatorFirstName = event.getCreator().getFirstName();
this.creatorLastName = event.getCreator().getLastName();
this.profileImageUrl = event.getCreator().getProfileImageUrl();
}
}

View File

@@ -36,11 +36,12 @@ public class FriendshipReadStatusResponseDTO {
public FriendshipReadStatusResponseDTO(Friendship friendship) {
this.friendshipId = friendship.getId();
this.userId = friendship.getUser().getId();
this.userNom = friendship.getUser().getNom();
this.userPrenoms = friendship.getUser().getPrenoms();
// v2.0 - Utiliser les nouveaux noms de champs
this.userNom = friendship.getUser().getLastName();
this.userPrenoms = friendship.getUser().getFirstName();
this.friendId = friendship.getFriend().getId();
this.friendNom = friendship.getFriend().getNom();
this.friendPrenoms = friendship.getFriend().getPrenoms();
this.friendNom = friendship.getFriend().getLastName();
this.friendPrenoms = friendship.getFriend().getFirstName();
this.status = friendship.getStatus();
this.createdAt = friendship.getCreatedAt();
}

View File

@@ -33,7 +33,7 @@ public class SocialPostResponseDTO {
private int sharesCount;
/**
* Constructeur à partir d'une entité SocialPost.
* Constructeur à partir d'une entité SocialPost (v2.0).
*
* @param post L'entité SocialPost
*/
@@ -42,8 +42,9 @@ public class SocialPostResponseDTO {
this.id = post.getId();
this.content = post.getContent();
this.userId = post.getUser() != null ? post.getUser().getId() : null;
this.userFirstName = post.getUser() != null ? post.getUser().getPrenoms() : null;
this.userLastName = post.getUser() != null ? post.getUser().getNom() : null;
// v2.0 - Utiliser les nouveaux noms de champs
this.userFirstName = post.getUser() != null ? post.getUser().getFirstName() : null;
this.userLastName = post.getUser() != null ? post.getUser().getLastName() : null;
this.userProfileImageUrl = post.getUser() != null ? post.getUser().getProfileImageUrl() : null;
this.timestamp = post.getCreatedAt();
this.imageUrl = post.getImageUrl();

View File

@@ -46,8 +46,9 @@ public class StoryResponseDTO {
if (story != null) {
this.id = story.getId();
this.userId = story.getUser() != null ? story.getUser().getId() : null;
this.userFirstName = story.getUser() != null ? story.getUser().getPrenoms() : null;
this.userLastName = story.getUser() != null ? story.getUser().getNom() : null;
// v2.0 - Utiliser les nouveaux noms de champs
this.userFirstName = story.getUser() != null ? story.getUser().getFirstName() : null;
this.userLastName = story.getUser() != null ? story.getUser().getLastName() : null;
this.userProfileImageUrl = story.getUser() != null ? story.getUser().getProfileImageUrl() : null;
this.userIsVerified = story.getUser() != null && story.getUser().isVerified();
this.mediaType = story.getMediaType();

View File

@@ -26,7 +26,7 @@ public class FriendSuggestionResponseDTO {
}
/**
* Constructeur à partir d'un utilisateur.
* Constructeur à partir d'un utilisateur (v2.0).
*
* @param user L'utilisateur suggéré
* @param mutualFriendsCount Le nombre d'amis en commun
@@ -34,8 +34,9 @@ public class FriendSuggestionResponseDTO {
*/
public FriendSuggestionResponseDTO(Users user, int mutualFriendsCount, String reason) {
this.userId = user.getId();
this.nom = user.getNom();
this.prenoms = user.getPrenoms();
// v2.0 - Utiliser les nouveaux noms de champs
this.nom = user.getLastName(); // Compatibilité v1.0
this.prenoms = user.getFirstName(); // Compatibilité v1.0
this.email = user.getEmail();
this.profileImageUrl = user.getProfileImageUrl();
this.mutualFriendsCount = mutualFriendsCount;

View File

@@ -10,30 +10,33 @@ import org.slf4j.LoggerFactory;
/**
* DTO pour la réponse d'authentification de l'utilisateur.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Utilisé pour renvoyer les informations nécessaires après l'authentification réussie d'un utilisateur.
*/
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class UserAuthenticateResponseDTO {
private static final Logger logger = LoggerFactory.getLogger(UserAuthenticateResponseDTO.class);
/**
* Identifiant unique de l'utilisateur authentifié.
* Identifiant unique de l'utilisateur authentifié (v2.0).
*/
private UUID userId;
private UUID id; // v2.0
/**
* Nom de l'utilisateur.
* Prénom de l'utilisateur (v2.0).
*/
private String nom;
private String firstName; // v2.0
/**
* Prénom de l'utilisateur.
* Nom de famille de l'utilisateur (v2.0).
*/
private String prenoms;
private String lastName; // v2.0
/**
* Adresse email de l'utilisateur.
@@ -45,6 +48,49 @@ public class UserAuthenticateResponseDTO {
*/
private String role;
// Champs de compatibilité v1.0 (dépréciés)
/**
* @deprecated Utiliser {@link #id} à la place.
*/
@Deprecated
private UUID userId;
/**
* @deprecated Utiliser {@link #lastName} à la place.
*/
@Deprecated
private String nom;
/**
* @deprecated Utiliser {@link #firstName} à la place.
*/
@Deprecated
private String prenoms;
/**
* Constructeur v2.0.
*/
public UserAuthenticateResponseDTO(UUID id, String firstName, String lastName, String email, String role) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.role = role;
// Compatibilité v1.0
this.userId = id;
this.nom = lastName;
this.prenoms = firstName;
}
/**
* Constructeur de compatibilité v1.0 (déprécié).
* @deprecated Utiliser le constructeur avec firstName et lastName à la place.
*/
@Deprecated
public UserAuthenticateResponseDTO(UUID userId, String prenoms, String nom, String email, String role, boolean deprecated) {
this(userId, prenoms, nom, email, role);
}
/**
* Log de création de l'objet DTO.
*/
@@ -53,9 +99,10 @@ public class UserAuthenticateResponseDTO {
}
/**
* Méthode personnalisée pour loguer les détails de la réponse.
* Méthode personnalisée pour loguer les détails de la réponse (v2.0).
*/
public void logResponseDetails() {
logger.info("[LOG] Réponse d'authentification - Utilisateur: {}, {}, Email: {}, Rôle: {}, ID: {}", prenoms, nom, email, role, userId);
logger.info("[LOG] Réponse d'authentification - Utilisateur: {} {}, Email: {}, Rôle: {}, ID: {}",
firstName, lastName, email, role, id);
}
}

View File

@@ -6,30 +6,66 @@ import lombok.Getter;
/**
* DTO pour renvoyer les informations d'un utilisateur.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Ce DTO est utilisé pour structurer les données retournées dans les réponses
* après les opérations sur les utilisateurs (création, récupération).
*/
@Getter
public class UserCreateResponseDTO {
private UUID uuid; // Identifiant unique de l'utilisateur
private String nom; // Nom de l'utilisateur
private String prenoms; // Prénoms de l'utilisateur
private UUID id; // v2.0 - Identifiant unique de l'utilisateur
private String firstName; // v2.0 - Prénom de l'utilisateur
private String lastName; // v2.0 - Nom de famille de l'utilisateur
private String email; // Email de l'utilisateur
private String role; // Re de l'utilisateur
private String profileImageUrl; // Url de l'image de profil de l'utilisateur
private String role; // Rôle de l'utilisateur
private String profileImageUrl; // URL de l'image de profil de l'utilisateur
private String bio; // v2.0 - Biographie courte
private Integer loyaltyPoints; // v2.0 - Points de fidélité
private java.util.Map<String, Object> preferences; // v2.0 - Préférences utilisateur
// Champs de compatibilité v1.0 (dépréciés)
/**
* Constructeur qui transforme une entité Users en DTO.
* @deprecated Utiliser {@link #id} à la place.
*/
@Deprecated
private UUID uuid;
/**
* @deprecated Utiliser {@link #lastName} à la place.
*/
@Deprecated
private String nom;
/**
* @deprecated Utiliser {@link #firstName} à la place.
*/
@Deprecated
private String prenoms;
/**
* Constructeur qui transforme une entité Users en DTO (v2.0).
*
* @param user L'utilisateur à convertir en DTO.
*/
public UserCreateResponseDTO(Users user) {
this.uuid = user.getId();
this.nom = user.getNom();
this.prenoms = user.getPrenoms();
this.id = user.getId(); // v2.0
this.firstName = user.getFirstName(); // v2.0
this.lastName = user.getLastName(); // v2.0
this.email = user.getEmail();
this.role = user.getRole();
this.profileImageUrl = user.getProfileImageUrl();
this.bio = user.getBio(); // v2.0
this.loyaltyPoints = user.getLoyaltyPoints(); // v2.0
this.preferences = user.getPreferences(); // v2.0
// Compatibilité v1.0
this.uuid = this.id;
this.nom = this.lastName;
this.prenoms = this.firstName;
System.out.println("[LOG] DTO créé pour l'utilisateur : " + this.email);
}
}

View File

@@ -9,6 +9,10 @@ import lombok.Setter;
/**
* DTO (Data Transfer Object) pour l'utilisateur.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* <p>
* Cette classe sert de représentation simplifiée d'un utilisateur, avec un ensemble d'informations nécessaires à
* la réponse de l'API. Elle est utilisée pour transférer des données entre le backend (serveur) et le frontend (client)
@@ -27,14 +31,14 @@ public class UserResponseDTO {
private UUID id;
/**
* Nom de famille de l'utilisateur. C'est une donnée importante pour l'affichage du profil.
* Prénom de l'utilisateur (v2.0).
*/
private String nom;
private String firstName;
/**
* Prénom(s) de l'utilisateur. Représente le ou les prénoms associés à l'utilisateur.
* Nom de famille de l'utilisateur (v2.0).
*/
private String prenoms;
private String lastName;
/**
* Adresse email de l'utilisateur. C'est une donnée souvent utilisée pour les communications.
@@ -47,13 +51,48 @@ public class UserResponseDTO {
*/
private String profileImageUrl;
/**
* Biographie courte de l'utilisateur (v2.0).
*/
private String bio;
/**
* Points de fidélité accumulés (v2.0).
*/
private Integer loyaltyPoints;
/**
* Préférences utilisateur en JSON (v2.0).
*/
private java.util.Map<String, Object> preferences;
/**
* Rôle de l'utilisateur (ADMIN, USER, MANAGER, etc.).
*/
private String role;
/**
* Indique si l'utilisateur est vérifié (compte officiel).
*/
private boolean isVerified;
/**
* Constructeur de DTO à partir d'une entité Users.
* Indique si l'utilisateur est actuellement en ligne.
*/
private boolean isOnline;
/**
* Dernière fois que l'utilisateur était en ligne.
*/
private java.time.LocalDateTime lastSeen;
/**
* Date de création du compte.
*/
private java.time.LocalDateTime createdAt;
/**
* Constructeur de DTO à partir d'une entité Users (v2.0).
* <p>
* Ce constructeur prend une entité {@link Users} et extrait les données nécessaires pour
* peupler les champs du DTO. Cette transformation permet de transférer des données sans exposer
@@ -64,12 +103,28 @@ public class UserResponseDTO {
*/
public UserResponseDTO(Users user) {
if (user != null) {
this.id = user.getId(); // Identifiant unique de l'utilisateur
this.nom = user.getNom(); // Nom de famille
this.prenoms = user.getPrenoms(); // Prénom(s)
this.email = user.getEmail(); // Email
this.profileImageUrl = user.getProfileImageUrl(); // URL de l'image de profil
this.isVerified = user.isVerified(); // Statut de vérification
this.id = user.getId();
this.firstName = user.getFirstName(); // v2.0
this.lastName = user.getLastName(); // v2.0
this.email = user.getEmail();
this.profileImageUrl = user.getProfileImageUrl();
this.bio = user.getBio(); // v2.0
this.loyaltyPoints = user.getLoyaltyPoints(); // v2.0
this.preferences = user.getPreferences(); // v2.0
this.role = user.getRole();
this.isVerified = user.isVerified();
this.isOnline = user.isOnline();
this.lastSeen = user.getLastSeen();
this.createdAt = user.getCreatedAt();
}
}
/**
* Retourne le nom complet de l'utilisateur (v2.0).
*
* @return Le nom complet (firstName + lastName).
*/
public String getFullName() {
return (firstName != null ? firstName : "") + " " + (lastName != null ? lastName : "").trim();
}
}

View File

@@ -0,0 +1,105 @@
package com.lions.dev.entity.booking;
import com.lions.dev.entity.BaseEntity;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.users.Users;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* Entité représentant une réservation d'établissement.
*
* Version 2.0 - Architecture refactorée.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Cette entité remplace/étend Reservation avec plus de détails et de flexibilité.
*/
@Entity
@Table(name = "bookings")
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Booking extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "establishment_id", nullable = false)
private Establishment establishment; // L'établissement concerné par la réservation
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private Users user; // L'utilisateur qui a fait la réservation
@Column(name = "reservation_time", nullable = false)
private LocalDateTime reservationTime; // Date et heure de la réservation
@Column(name = "guest_count", nullable = false)
private Integer guestCount = 1; // Nombre de convives
@Column(name = "status", nullable = false, length = 20)
private String status = "PENDING"; // Statut: PENDING, CONFIRMED, CANCELLED, COMPLETED
@Column(name = "special_requests", columnDefinition = "TEXT")
private String specialRequests; // Demandes spéciales (allergies, préférences, etc.)
@Column(name = "table_number", length = 20)
private String tableNumber; // Numéro de table assigné (si applicable)
/**
* Constructeur pour créer une réservation.
*
* @param establishment L'établissement concerné
* @param user L'utilisateur qui fait la réservation
* @param reservationTime La date et heure de la réservation
* @param guestCount Le nombre de convives
*/
public Booking(Establishment establishment, Users user, LocalDateTime reservationTime, Integer guestCount) {
this.establishment = establishment;
this.user = user;
this.reservationTime = reservationTime;
this.guestCount = guestCount;
this.status = "PENDING";
}
/**
* Vérifie si la réservation est confirmée.
*
* @return true si la réservation est confirmée, false sinon.
*/
public boolean isConfirmed() {
return "CONFIRMED".equalsIgnoreCase(this.status);
}
/**
* Vérifie si la réservation est en attente.
*
* @return true si la réservation est en attente, false sinon.
*/
public boolean isPending() {
return "PENDING".equalsIgnoreCase(this.status);
}
/**
* Vérifie si la réservation est annulée.
*
* @return true si la réservation est annulée, false sinon.
*/
public boolean isCancelled() {
return "CANCELLED".equalsIgnoreCase(this.status);
}
/**
* Vérifie si la réservation est complétée.
*
* @return true si la réservation est complétée, false sinon.
*/
public boolean isCompleted() {
return "COMPLETED".equalsIgnoreCase(this.status);
}
}

View File

@@ -74,9 +74,9 @@ public class Conversation extends BaseEntity {
this.lastMessageSender = message.getSender();
// Incrémenter le compteur de non-lus pour le destinataire
if (message.getSender().equals(user1)) {
if (message.getSender() != null && user1 != null && message.getSender().getId().equals(user1.getId())) {
unreadCountUser2++;
} else {
} else if (message.getSender() != null && user2 != null && message.getSender().getId().equals(user2.getId())) {
unreadCountUser1++;
}
@@ -87,10 +87,10 @@ public class Conversation extends BaseEntity {
* Marque tous les messages comme lus pour un utilisateur.
*/
public void markAllAsReadForUser(Users user) {
if (user.equals(user1)) {
if (user != null && user1 != null && user.getId().equals(user1.getId())) {
unreadCountUser1 = 0;
System.out.println("[LOG] Messages marqués comme lus pour user1 dans la conversation " + this.getId());
} else if (user.equals(user2)) {
} else if (user != null && user2 != null && user.getId().equals(user2.getId())) {
unreadCountUser2 = 0;
System.out.println("[LOG] Messages marqués comme lus pour user2 dans la conversation " + this.getId());
}
@@ -100,9 +100,9 @@ public class Conversation extends BaseEntity {
* Récupère le nombre de messages non lus pour un utilisateur.
*/
public int getUnreadCountForUser(Users user) {
if (user.equals(user1)) {
if (user != null && user1 != null && user.getId().equals(user1.getId())) {
return unreadCountUser1;
} else if (user.equals(user2)) {
} else if (user != null && user2 != null && user.getId().equals(user2.getId())) {
return unreadCountUser2;
}
return 0;
@@ -112,9 +112,9 @@ public class Conversation extends BaseEntity {
* Récupère l'autre utilisateur de la conversation.
*/
public Users getOtherUser(Users user) {
if (user.equals(user1)) {
if (user != null && user1 != null && user.getId().equals(user1.getId())) {
return user2;
} else if (user.equals(user2)) {
} else if (user != null && user2 != null && user.getId().equals(user2.getId())) {
return user1;
}
return null;
@@ -124,6 +124,8 @@ public class Conversation extends BaseEntity {
* Vérifie si un utilisateur fait partie de cette conversation.
*/
public boolean containsUser(Users user) {
return user.equals(user1) || user.equals(user2);
if (user == null) return false;
return (user1 != null && user.getId().equals(user1.getId())) ||
(user2 != null && user.getId().equals(user2.getId()));
}
}

View File

@@ -0,0 +1,90 @@
package com.lions.dev.entity.establishment;
import com.lions.dev.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* Entité représentant les horaires d'ouverture d'un établissement.
*
* Version 2.0 - Architecture refactorée.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Cette entité permet de gérer les horaires d'ouverture par jour de la semaine,
* ainsi que les exceptions (fermetures temporaires, jours fériés, etc.).
*/
@Entity
@Table(name = "business_hours")
@Getter
@Setter
@NoArgsConstructor
@ToString
public class BusinessHours extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "establishment_id", nullable = false)
private Establishment establishment; // L'établissement concerné
@Column(name = "day_of_week", nullable = false, length = 20)
private String dayOfWeek; // Jour de la semaine: MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
@Column(name = "open_time", nullable = false, length = 5)
private String openTime; // Heure d'ouverture (format HH:MM, ex: "09:00")
@Column(name = "close_time", nullable = false, length = 5)
private String closeTime; // Heure de fermeture (format HH:MM, ex: "18:00")
@Column(name = "is_closed", nullable = false)
private Boolean isClosed = false; // Indique si l'établissement est fermé ce jour
@Column(name = "is_exception", nullable = false)
private Boolean isException = false; // Indique si c'est un jour exceptionnel (fermeture temporaire, jour férié, etc.)
@Column(name = "exception_date")
private LocalDateTime exceptionDate; // Date de l'exception (si is_exception = true)
/**
* Constructeur pour créer des horaires d'ouverture standard.
*
* @param establishment L'établissement concerné
* @param dayOfWeek Le jour de la semaine
* @param openTime L'heure d'ouverture (format HH:MM)
* @param closeTime L'heure de fermeture (format HH:MM)
*/
public BusinessHours(Establishment establishment, String dayOfWeek, String openTime, String closeTime) {
this.establishment = establishment;
this.dayOfWeek = dayOfWeek;
this.openTime = openTime;
this.closeTime = closeTime;
this.isClosed = false;
this.isException = false;
}
/**
* Constructeur pour créer un jour de fermeture.
*
* @param establishment L'établissement concerné
* @param dayOfWeek Le jour de la semaine
*/
public BusinessHours(Establishment establishment, String dayOfWeek) {
this.establishment = establishment;
this.dayOfWeek = dayOfWeek;
this.isClosed = true;
this.isException = false;
}
/**
* Vérifie si l'établissement est ouvert ce jour.
*
* @return true si l'établissement est ouvert, false sinon.
*/
public boolean isOpen() {
return !isClosed;
}
}

View File

@@ -0,0 +1,113 @@
package com.lions.dev.entity.establishment;
import com.lions.dev.entity.BaseEntity;
import com.lions.dev.entity.users.Users;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
/**
* Entité représentant un établissement dans le système AfterWork.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Un établissement est un lieu physique (bar, restaurant, club, etc.)
* où peuvent se dérouler des événements Afterwork.
* Seuls les responsables d'établissement peuvent créer et gérer des établissements.
*/
@Entity
@Table(name = "establishments")
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Establishment extends BaseEntity {
@Column(name = "name", nullable = false)
private String name; // Le nom de l'établissement
@Column(name = "type", nullable = false)
private String type; // Type d'établissement (bar, restaurant, club, etc.)
@Column(name = "address", nullable = false)
private String address; // Adresse de l'établissement
@Column(name = "city", nullable = false)
private String city; // Ville de l'établissement
@Column(name = "postal_code", nullable = false)
private String postalCode; // Code postal
@Column(name = "description", length = 2000)
private String description; // Description de l'établissement
@Column(name = "phone_number")
private String phoneNumber; // Numéro de téléphone
@Column(name = "website")
private String website; // Site web
@Column(name = "average_rating")
private Double averageRating; // Note moyenne calculée (0.0 à 5.0)
@Column(name = "total_reviews_count")
private Integer totalReviewsCount; // Nombre total d'avis (v2.0 - renommé depuis total_ratings_count)
@Column(name = "price_range")
private String priceRange; // Fourchette de prix (LOW, MEDIUM, HIGH, PREMIUM)
@Column(name = "verification_status", nullable = false)
private String verificationStatus = "PENDING"; // Statut de vérification: PENDING, VERIFIED, REJECTED (v2.0)
@Column(name = "latitude")
private Double latitude; // Latitude pour la géolocalisation
@Column(name = "longitude")
private Double longitude; // Longitude pour la géolocalisation
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "manager_id", nullable = false)
private Users manager; // Le responsable de l'établissement
// Relations avec les médias et les notes
@OneToMany(mappedBy = "establishment", cascade = CascadeType.ALL, orphanRemoval = true)
private java.util.List<EstablishmentMedia> medias = new java.util.ArrayList<>(); // Liste des médias de l'établissement
@OneToMany(mappedBy = "establishment", cascade = CascadeType.ALL, orphanRemoval = true)
private java.util.List<EstablishmentRating> ratings = new java.util.ArrayList<>(); // Liste des notes de l'établissement (v1.0 - à migrer vers Review)
/**
* Vérifie si l'établissement est vérifié.
*
* @return true si l'établissement est vérifié, false sinon.
*/
public boolean isVerified() {
return "VERIFIED".equalsIgnoreCase(this.verificationStatus);
}
/**
* Vérifie si l'établissement est en attente de vérification.
*
* @return true si l'établissement est en attente, false sinon.
*/
public boolean isPending() {
return "PENDING".equalsIgnoreCase(this.verificationStatus);
}
/**
* Constructeur pour créer un établissement avec les informations de base.
*/
public Establishment(String name, String type, String address, String city,
String postalCode, Users manager) {
this.name = name;
this.type = type;
this.address = address;
this.city = city;
this.postalCode = postalCode;
this.manager = manager;
}
}

View File

@@ -0,0 +1,98 @@
package com.lions.dev.entity.establishment;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Entité représentant un équipement d'un établissement.
*
* Version 2.0 - Architecture refactorée.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Cette entité fait la liaison entre un établissement et un type d'équipement,
* avec la possibilité d'ajouter des détails supplémentaires.
*/
@Entity
@Table(name = "establishment_amenities")
@Getter
@Setter
@NoArgsConstructor
@ToString
@IdClass(EstablishmentAmenityId.class)
public class EstablishmentAmenity {
@Id
@Column(name = "establishment_id", nullable = false)
private UUID establishmentId; // ID de l'établissement
@Id
@Column(name = "amenity_id", nullable = false)
private UUID amenityId; // ID du type d'équipement (référence à amenity_types)
@Column(name = "details", length = 500)
private String details; // Détails supplémentaires (ex: "Parking gratuit pour 20 voitures")
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt = LocalDateTime.now(); // Date de création
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "establishment_id", insertable = false, updatable = false)
private Establishment establishment; // L'établissement concerné
/**
* Constructeur pour créer une liaison établissement-équipement.
*
* @param establishmentId L'ID de l'établissement
* @param amenityId L'ID du type d'équipement
* @param details Les détails supplémentaires (optionnel)
*/
public EstablishmentAmenity(UUID establishmentId, UUID amenityId, String details) {
this.establishmentId = establishmentId;
this.amenityId = amenityId;
this.details = details;
this.createdAt = LocalDateTime.now();
}
@PrePersist
protected void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}
/**
* Classe composite pour la clé primaire de EstablishmentAmenity.
*/
@Getter
@Setter
@NoArgsConstructor
class EstablishmentAmenityId implements java.io.Serializable {
private UUID establishmentId;
private UUID amenityId;
public EstablishmentAmenityId(UUID establishmentId, UUID amenityId) {
this.establishmentId = establishmentId;
this.amenityId = amenityId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
EstablishmentAmenityId that = (EstablishmentAmenityId) o;
return establishmentId.equals(that.establishmentId) && amenityId.equals(that.amenityId);
}
@Override
public int hashCode() {
return establishmentId.hashCode() + amenityId.hashCode();
}
}

View File

@@ -0,0 +1,61 @@
package com.lions.dev.entity.establishment;
import com.lions.dev.entity.BaseEntity;
import com.lions.dev.entity.users.Users;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
/**
* Entité représentant un média (photo ou vidéo) associé à un établissement.
*
* Un établissement peut avoir plusieurs médias pour promouvoir son établissement.
* Les médias sont uploadés par le responsable de l'établissement.
*/
@Entity
@Table(name = "establishment_media")
@Getter
@Setter
@NoArgsConstructor
@ToString
public class EstablishmentMedia extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "establishment_id", nullable = false)
private Establishment establishment; // L'établissement auquel ce média appartient
@Column(name = "media_url", nullable = false, length = 1000)
private String mediaUrl; // URL du média (photo ou vidéo)
@Enumerated(EnumType.STRING)
@Column(name = "media_type", nullable = false)
private MediaType mediaType; // Type de média (PHOTO ou VIDEO)
@Column(name = "thumbnail_url", length = 1000)
private String thumbnailUrl; // URL de la miniature (pour les vidéos)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "uploaded_by", nullable = false)
private Users uploadedBy; // L'utilisateur qui a uploadé le média
@Column(name = "uploaded_at", nullable = false)
private java.time.LocalDateTime uploadedAt; // Date et heure d'upload
@Column(name = "display_order", nullable = false)
private Integer displayOrder = 0; // Ordre d'affichage dans la galerie
/**
* Constructeur pour créer un média avec les informations de base.
*/
public EstablishmentMedia(Establishment establishment, String mediaUrl, MediaType mediaType, Users uploadedBy) {
this.establishment = establishment;
this.mediaUrl = mediaUrl;
this.mediaType = mediaType;
this.uploadedBy = uploadedBy;
this.uploadedAt = java.time.LocalDateTime.now();
this.displayOrder = 0;
}
}

View File

@@ -0,0 +1,72 @@
package com.lions.dev.entity.establishment;
import com.lions.dev.entity.BaseEntity;
import com.lions.dev.entity.users.Users;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
/**
* Entité représentant une note donnée par un utilisateur à un établissement.
*
* Chaque utilisateur ne peut avoir qu'une seule note par établissement.
* La note est un entier entre 1 et 5 (étoiles).
*/
@Entity
@Table(
name = "establishment_ratings",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_establishment_user_rating",
columnNames = {"establishment_id", "user_id"}
)
}
)
@Getter
@Setter
@NoArgsConstructor
@ToString
public class EstablishmentRating extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "establishment_id", nullable = false)
private Establishment establishment; // L'établissement noté
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private Users user; // L'utilisateur qui a donné la note
@Column(name = "rating", nullable = false)
private Integer rating; // Note donnée (1 à 5 étoiles)
@Column(name = "comment", length = 2000)
private String comment; // Commentaire optionnel accompagnant la note
@Column(name = "rated_at", nullable = false, updatable = false)
private java.time.LocalDateTime ratedAt; // Date et heure de création de la note
@Column(name = "updated_at")
private java.time.LocalDateTime updatedAt; // Date et heure de dernière modification
/**
* Constructeur pour créer une note avec les informations de base.
*/
public EstablishmentRating(Establishment establishment, Users user, Integer rating) {
this.establishment = establishment;
this.user = user;
this.rating = rating;
this.ratedAt = java.time.LocalDateTime.now();
}
/**
* Met à jour la note et la date de modification.
*/
public void updateRating(Integer newRating, String newComment) {
this.rating = newRating;
this.comment = newComment;
this.updatedAt = java.time.LocalDateTime.now();
}
}

View File

@@ -0,0 +1,10 @@
package com.lions.dev.entity.establishment;
/**
* Enum représentant le type de média pour un établissement.
*/
public enum MediaType {
PHOTO,
VIDEO
}

View File

@@ -0,0 +1,92 @@
package com.lions.dev.entity.establishment;
import com.lions.dev.entity.BaseEntity;
import com.lions.dev.entity.users.Users;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.util.HashMap;
import java.util.Map;
/**
* Entité représentant un avis (review) sur un établissement.
*
* Version 2.0 - Architecture refactorée.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Cette entité remplace EstablishmentRating avec des fonctionnalités plus avancées,
* incluant des notes par critères et la vérification des visites.
*/
@Entity
@Table(name = "reviews",
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "establishment_id"}))
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Review extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private Users user; // L'utilisateur qui a écrit l'avis
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "establishment_id", nullable = false)
private Establishment establishment; // L'établissement concerné
@Column(name = "overall_rating", nullable = false)
private Integer overallRating; // Note globale (1-5)
@Column(name = "comment", columnDefinition = "TEXT")
private String comment; // Commentaire libre de l'utilisateur
@Column(name = "criteria_ratings", nullable = false)
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON)
private Map<String, Integer> criteriaRatings = new HashMap<>(); // Notes par critères (ex: {"ambiance": 4, "service": 5})
@Column(name = "is_verified_visit", nullable = false)
private Boolean isVerifiedVisit = false; // Indique si l'avis est lié à une visite vérifiée (réservation complétée)
/**
* Constructeur pour créer un avis.
*
* @param user L'utilisateur qui écrit l'avis
* @param establishment L'établissement concerné
* @param overallRating La note globale (1-5)
* @param comment Le commentaire (optionnel)
*/
public Review(Users user, Establishment establishment, Integer overallRating, String comment) {
this.user = user;
this.establishment = establishment;
this.overallRating = overallRating;
this.comment = comment;
this.criteriaRatings = new HashMap<>();
this.isVerifiedVisit = false;
}
/**
* Ajoute une note pour un critère spécifique.
*
* @param criteria Le nom du critère (ex: "ambiance", "service", "qualité")
* @param rating La note (1-5)
*/
public void addCriteriaRating(String criteria, Integer rating) {
if (rating >= 1 && rating <= 5) {
this.criteriaRatings.put(criteria, rating);
}
}
/**
* Retourne la note pour un critère spécifique.
*
* @param criteria Le nom du critère
* @return La note, ou null si le critère n'existe pas
*/
public Integer getCriteriaRating(String criteria) {
return this.criteriaRatings.get(criteria);
}
}

View File

@@ -16,8 +16,12 @@ import java.util.Set;
/**
* Entité représentant un événement dans le système AfterWork.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Chaque événement possède un titre, une description, une date de début, une date de fin,
* un créateur, une catégorie, un lieu, une URL d'image, et des participants.
* un créateur, une catégorie, un établissement, une URL d'image, et des participants.
* Tous les logs et commentaires nécessaires pour la traçabilité et la documentation sont inclus.
*/
@Entity
@@ -40,8 +44,9 @@ public class Events extends BaseEntity {
@Column(name = "end_date", nullable = false)
private LocalDateTime endDate; // La date de fin de l'événement
@Column(name = "location")
private String location; // Le lieu de l'événement
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "establishment_id")
private com.lions.dev.entity.establishment.Establishment establishment; // L'établissement où se déroule l'événement (v2.0)
@Column(name = "category")
private String category; // La catégorie de l'événement
@@ -53,7 +58,43 @@ public class Events extends BaseEntity {
private String imageUrl; // URL d'une image associée à l'événement
@Column(name = "status", nullable = false)
private String status = "ouvert"; // Le statut de l'événement (en cours, terminé, annulé, etc.)
private String status = "OPEN"; // Le statut de l'événement (OPEN, CLOSED, CANCELLED, COMPLETED) (v2.0)
@Column(name = "max_participants")
private Integer maxParticipants; // Nombre maximum de participants autorisés
@Column(name = "tags", length = 500)
private String tags; // Tags/mots-clés associés à l'événement (séparés par des virgules)
@Column(name = "organizer", length = 200)
private String organizer; // Nom de l'organisateur de l'événement
@Column(name = "participation_fee")
private Integer participationFee; // Frais de participation en centimes
@Column(name = "is_private", nullable = false)
private Boolean isPrivate = false; // Indique si l'événement est privé (v2.0)
@Column(name = "waitlist_enabled", nullable = false)
private Boolean waitlistEnabled = false; // Indique si la liste d'attente est activée (v2.0)
@Column(name = "privacy_rules", length = 1000)
private String privacyRules; // Règles de confidentialité de l'événement
@Column(name = "transport_info", length = 1000)
private String transportInfo; // Informations sur les transports disponibles
@Column(name = "accommodation_info", length = 1000)
private String accommodationInfo; // Informations sur l'hébergement
@Column(name = "accessibility_info", length = 1000)
private String accessibilityInfo; // Informations sur l'accessibilité
@Column(name = "parking_info", length = 1000)
private String parkingInfo; // Informations sur le parking
@Column(name = "security_protocol", length = 1000)
private String securityProtocol; // Protocole de sécurité de l'événement
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "creator_id", nullable = false)
@@ -101,13 +142,44 @@ public class Events extends BaseEntity {
}
/**
* Ferme l'événement en changeant son statut.
* Ferme l'événement en changeant son statut (v2.0).
*/
public void setClosed(boolean closed) {
this.status = closed ? "fermé" : "ouvert";
this.status = closed ? "CLOSED" : "OPEN";
System.out.println("[LOG] Statut de l'événement mis à jour : " + this.title + " - " + this.status);
}
/**
* Vérifie si l'événement est ouvert (v2.0).
*
* @return true si l'événement est ouvert, false sinon.
*/
public boolean isOpen() {
return "OPEN".equalsIgnoreCase(this.status);
}
/**
* Vérifie si l'événement est fermé (v2.0).
*
* @return true si l'événement est fermé, false sinon.
*/
public boolean isClosed() {
return "CLOSED".equalsIgnoreCase(this.status);
}
/**
* Retourne le lieu de l'événement depuis l'établissement associé (v2.0).
* Méthode de compatibilité pour remplacer l'ancien champ location.
*
* @return L'adresse de l'établissement, ou null si aucun établissement n'est associé.
*/
public String getLocation() {
if (establishment != null) {
return establishment.getAddress() + ", " + establishment.getCity();
}
return null;
}
@OneToMany(fetch = FetchType.LAZY, mappedBy = "event")
private List<Comment> comments; // Liste des commentaires associés à l'événement

View File

@@ -0,0 +1,119 @@
package com.lions.dev.entity.promotion;
import com.lions.dev.entity.BaseEntity;
import com.lions.dev.entity.establishment.Establishment;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* Entité représentant une promotion ou une offre spéciale d'un établissement.
*
* Version 2.0 - Architecture refactorée.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Cette entité permet de gérer les promotions, happy hours, et offres spéciales
* avec différents types de réductions et codes promo.
*/
@Entity
@Table(name = "promotions")
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Promotion extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "establishment_id", nullable = false)
private Establishment establishment; // L'établissement qui propose la promotion
@Column(name = "title", nullable = false, length = 200)
private String title; // Titre de la promotion (ex: "Happy Hour", "Menu du jour")
@Column(name = "description", columnDefinition = "TEXT")
private String description; // Description détaillée de la promotion
@Column(name = "promo_code", length = 50, unique = true)
private String promoCode; // Code promo optionnel (ex: "HAPPY2024")
@Column(name = "discount_type", nullable = false, length = 20)
private String discountType; // Type de réduction: PERCENTAGE, FIXED_AMOUNT, FREE_ITEM
@Column(name = "discount_value", nullable = false)
private java.math.BigDecimal discountValue; // Valeur de la réduction (pourcentage, montant fixe, etc.)
@Column(name = "valid_from", nullable = false)
private LocalDateTime validFrom; // Date de début de validité
@Column(name = "valid_until", nullable = false)
private LocalDateTime validUntil; // Date de fin de validité
@Column(name = "is_active", nullable = false)
private Boolean isActive = true; // Indique si la promotion est active
/**
* Constructeur pour créer une promotion.
*
* @param establishment L'établissement qui propose la promotion
* @param title Le titre de la promotion
* @param description La description
* @param discountType Le type de réduction
* @param discountValue La valeur de la réduction
* @param validFrom La date de début
* @param validUntil La date de fin
*/
public Promotion(Establishment establishment, String title, String description,
String discountType, java.math.BigDecimal discountValue,
LocalDateTime validFrom, LocalDateTime validUntil) {
this.establishment = establishment;
this.title = title;
this.description = description;
this.discountType = discountType;
this.discountValue = discountValue;
this.validFrom = validFrom;
this.validUntil = validUntil;
this.isActive = true;
}
/**
* Vérifie si la promotion est actuellement valide.
*
* @return true si la promotion est active et dans sa période de validité, false sinon.
*/
public boolean isValid() {
LocalDateTime now = LocalDateTime.now();
return isActive && now.isAfter(validFrom) && now.isBefore(validUntil);
}
/**
* Vérifie si la promotion est expirée.
*
* @return true si la promotion est expirée, false sinon.
*/
public boolean isExpired() {
return LocalDateTime.now().isAfter(validUntil);
}
/**
* Vérifie si la promotion est une réduction en pourcentage.
*
* @return true si c'est un pourcentage, false sinon.
*/
public boolean isPercentage() {
return "PERCENTAGE".equalsIgnoreCase(this.discountType);
}
/**
* Vérifie si la promotion est un montant fixe.
*
* @return true si c'est un montant fixe, false sinon.
*/
public boolean isFixedAmount() {
return "FIXED_AMOUNT".equalsIgnoreCase(this.discountType);
}
}

View File

@@ -2,7 +2,6 @@ package com.lions.dev.entity.reaction;
import com.lions.dev.entity.BaseEntity;
import com.lions.dev.entity.users.Users;
import com.lions.dev.entity.events.Events;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -10,58 +9,66 @@ import lombok.Setter;
import lombok.ToString;
/**
* Entité représentant une réaction d'un utilisateur à un événement dans le système AfterWork.
* Une réaction peut être un "like", un "dislike" ou toute autre forme de réaction définie.
*
* Cette entité est liée à l'utilisateur qui réagit et à l'événement auquel la réaction est associée.
* Tous les logs et commentaires sont inclus pour la traçabilité.
* Entité représentant une réaction d'un utilisateur à un contenu.
*
* Version 2.0 - Architecture refactorée.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Cette entité remplace les compteurs de réactions simples avec un système
* plus flexible permettant différents types de réactions sur différents contenus.
*/
@Entity
@Table(name = "reactions")
@Table(name = "reactions",
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "target_type", "target_id"}))
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Reaction extends BaseEntity {
// Types de réaction possibles
public enum ReactionType {
LIKE, DISLIKE, LOVE, ANGRY, SAD
}
@Enumerated(EnumType.STRING)
@Column(name = "reaction_type", nullable = false)
private ReactionType reactionType; // Le type de réaction (LIKE, DISLIKE, etc.)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private Users user; // L'utilisateur qui a effectué la réaction
private Users user; // L'utilisateur qui a réagi
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", nullable = false)
private Events event; // L'événement auquel la réaction est associée
@Column(name = "reaction_type", nullable = false, length = 20)
private String reactionType; // Type de réaction: LIKE, LOVE, HAHA, WOW, SAD, ANGRY
@Column(name = "target_type", nullable = false, length = 20)
private String targetType; // Type de contenu ciblé: POST, EVENT, COMMENT, REVIEW
@Column(name = "target_id", nullable = false)
private java.util.UUID targetId; // ID du contenu ciblé (post_id, event_id, comment_id, review_id)
/**
* Associe une réaction à un utilisateur et un événement.
* Constructeur pour créer une réaction.
*
* @param user L'utilisateur qui réagit.
* @param event L'événement auquel la réaction est liée.
* @param reactionType Le type de réaction.
* @param user L'utilisateur qui réagit
* @param reactionType Le type de réaction (LIKE, LOVE, HAHA, WOW, SAD, ANGRY)
* @param targetType Le type de contenu (POST, EVENT, COMMENT, REVIEW)
* @param targetId L'ID du contenu ciblé
*/
public Reaction(Users user, Events event, ReactionType reactionType) {
public Reaction(Users user, String reactionType, String targetType, java.util.UUID targetId) {
this.user = user;
this.event = event;
this.reactionType = reactionType;
System.out.println("[LOG] Nouvelle réaction ajoutée : " + reactionType + " par l'utilisateur : " + user.getEmail() + " à l'événement : " + event.getTitle());
this.targetType = targetType;
this.targetId = targetId;
}
/**
* Modifie le type de réaction de l'utilisateur pour cet événement.
* Vérifie si la réaction est un "like".
*
* @param newReactionType Le nouveau type de réaction.
* @return true si c'est un like, false sinon.
*/
public void updateReaction(ReactionType newReactionType) {
System.out.println("[LOG] Changement de la réaction de " + this.reactionType + " à " + newReactionType + " pour l'utilisateur : " + user.getEmail() + " à l'événement : " + event.getTitle());
this.reactionType = newReactionType;
public boolean isLike() {
return "LIKE".equalsIgnoreCase(this.reactionType);
}
/**
* Vérifie si la réaction est un "love".
*
* @return true si c'est un love, false sinon.
*/
public boolean isLove() {
return "LOVE".equalsIgnoreCase(this.reactionType);
}
}

View File

@@ -3,6 +3,8 @@ package com.lions.dev.entity.users;
import com.lions.dev.entity.BaseEntity;
import com.lions.dev.entity.events.Events;
import jakarta.persistence.*;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import java.util.HashSet;
import java.util.Set;
import lombok.Getter;
@@ -13,6 +15,10 @@ import at.favre.lib.crypto.bcrypt.BCrypt;
/**
* Représentation de l'entité Utilisateur dans le système AfterWork.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Cette entité contient les informations de base sur un utilisateur, telles que le nom,
* les prénoms, l'email, le mot de passe haché, et son rôle.
*
@@ -26,17 +32,17 @@ import at.favre.lib.crypto.bcrypt.BCrypt;
@ToString
public class Users extends BaseEntity {
@Column(name = "nom", nullable = false, length = 100)
private String nom; // Le nom de l'utilisateur
@Column(name = "first_name", nullable = false, length = 100)
private String firstName; // Le prénom de l'utilisateur (v2.0)
@Column(name = "prenoms", nullable = false, length = 100)
private String prenoms; // Les prénoms de l'utilisateur
@Column(name = "last_name", nullable = false, length = 100)
private String lastName; // Le nom de famille de l'utilisateur (v2.0)
@Column(name = "email", nullable = false, unique = true, length = 100)
private String email; // L'adresse email unique de l'utilisateur
@Column(name = "mot_de_passe", nullable = false)
private String motDePasse; // Mot de passe haché avec BCrypt
@Column(name = "password_hash", nullable = false)
private String passwordHash; // Mot de passe haché avec BCrypt (v2.0)
@Column(name = "role", nullable = false)
private String role; // Le rôle de l'utilisateur (ADMIN, MODERATOR, USER, etc.)
@@ -44,8 +50,15 @@ public class Users extends BaseEntity {
@Column(name = "profile_image_url")
private String profileImageUrl; // L'URL de l'image de profil de l'utilisateur
@Column(name = "preferred_category")
private String preferredCategory; // La catégorie préférée de l'utilisateur
@Column(name = "bio", length = 500)
private String bio; // Biographie courte de l'utilisateur (v2.0)
@Column(name = "loyalty_points", nullable = false)
private Integer loyaltyPoints = 0; // Points de fidélité accumulés (v2.0)
@Column(name = "preferences", nullable = false)
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON)
private java.util.Map<String, Object> preferences = new java.util.HashMap<>(); // Préférences utilisateur en JSON (v2.0)
@Column(name = "is_verified", nullable = false)
private boolean isVerified = false; // Indique si l'utilisateur est vérifié (compte officiel)
@@ -60,28 +73,57 @@ public class Users extends BaseEntity {
// private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
/**
* Hache le mot de passe avec BCrypt et le stocke dans l'attribut `motDePasse`.
* Hache le mot de passe avec BCrypt et le stocke dans l'attribut `passwordHash`.
* Version 2.0 - Utilise passwordHash au lieu de motDePasse.
*
* @param motDePasse Le mot de passe en texte clair à hacher.
* @param password Le mot de passe en texte clair à hacher.
*/
public void setMotDePasse(String motDePasse) {
this.motDePasse = BCrypt.withDefaults().hashToString(12, motDePasse.toCharArray());
public void setPassword(String password) {
this.passwordHash = BCrypt.withDefaults().hashToString(12, password.toCharArray());
System.out.println("[LOG] Mot de passe haché pour l'utilisateur : " + this.email);
}
/**
* Vérifie que le mot de passe fourni correspond au mot de passe haché de l'utilisateur.
* Définit directement le hash du mot de passe (pour compatibilité).
*
* @param motDePasse Le mot de passe en texte clair à vérifier.
* @param passwordHash Le hash du mot de passe.
*/
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
/**
* Vérifie que le mot de passe fourni correspond au mot de passe haché de l'utilisateur.
* Version 2.0 - Utilise passwordHash au lieu de motDePasse.
*
* @param password Le mot de passe en texte clair à vérifier.
* @return true si le mot de passe est correct, false sinon.
*/
public boolean verifierMotDePasse(String motDePasse) {
BCrypt.Result result = BCrypt.verifyer().verify(motDePasse.toCharArray(), this.motDePasse);
public boolean verifyPassword(String password) {
BCrypt.Result result = BCrypt.verifyer().verify(password.toCharArray(), this.passwordHash);
boolean isValid = result.verified;
System.out.println("[LOG] Vérification du mot de passe pour l'utilisateur : " + this.email + " - Résultat : " + isValid);
return isValid;
}
/**
* Méthode de compatibilité avec l'ancienne API (dépréciée).
* @deprecated Utiliser {@link #setPassword(String)} à la place.
*/
@Deprecated
public void setMotDePasse(String motDePasse) {
setPassword(motDePasse);
}
/**
* Méthode de compatibilité avec l'ancienne API (dépréciée).
* @deprecated Utiliser {@link #verifyPassword(String)} à la place.
*/
@Deprecated
public boolean verifierMotDePasse(String motDePasse) {
return verifyPassword(motDePasse);
}
/**
* Vérifie si l'utilisateur a le rôle d'administrateur.
*
@@ -93,8 +135,12 @@ public class Users extends BaseEntity {
return isAdmin;
}
@OneToMany(fetch = FetchType.LAZY)
@JoinColumn(name = "favorite_events")
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "user_favorite_events",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "event_id")
)
private Set<Events> favoriteEvents = new HashSet<>(); // Liste des événements favoris
/**
@@ -107,6 +153,26 @@ public class Users extends BaseEntity {
System.out.println("[LOG] Événement ajouté aux favoris pour l'utilisateur : " + this.email);
}
/**
* Retire un événement des favoris de l'utilisateur.
*
* @param event L'événement à retirer.
*/
public void removeFavoriteEvent(Events event) {
favoriteEvents.remove(event);
System.out.println("[LOG] Événement retiré des favoris pour l'utilisateur : " + this.email);
}
/**
* Vérifie si un événement est dans les favoris de l'utilisateur.
*
* @param event L'événement à vérifier.
* @return true si l'événement est favori, false sinon.
*/
public boolean hasFavoriteEvent(Events event) {
return favoriteEvents.contains(event);
}
/**
* Retourne la liste des événements favoris de l'utilisateur.
*
@@ -118,25 +184,44 @@ public class Users extends BaseEntity {
}
/**
* Retourne la catégorie préférée de l'utilisateur.
* Retourne la catégorie préférée de l'utilisateur depuis preferences (v2.0).
*
* @return La catégorie préférée de l'utilisateur.
* @return La catégorie préférée de l'utilisateur, ou null si non définie.
*/
public String getPreferredCategory() {
System.out.println("[LOG] Récupération de la catégorie préférée pour l'utilisateur : " + this.email);
return preferredCategory;
if (preferences != null && preferences.containsKey("preferred_category")) {
Object category = preferences.get("preferred_category");
return category != null ? category.toString() : null;
}
return null;
}
/**
* Définit la catégorie préférée de l'utilisateur.
* Définit la catégorie préférée de l'utilisateur dans preferences (v2.0).
*
* @param category La catégorie à définir.
*/
public void setPreferredCategory(String category) {
this.preferredCategory = category;
if (preferences == null) {
preferences = new java.util.HashMap<>();
}
if (category != null) {
preferences.put("preferred_category", category);
} else {
preferences.remove("preferred_category");
}
System.out.println("[LOG] Catégorie préférée définie pour l'utilisateur : " + this.email + " - Catégorie : " + category);
}
/**
* Retourne le nom complet de l'utilisateur (v2.0).
*
* @return Le nom complet (firstName + lastName).
*/
public String getFullName() {
return (firstName != null ? firstName : "") + " " + (lastName != null ? lastName : "").trim();
}
/**
* Met à jour la présence de l'utilisateur (marque comme en ligne et met à jour lastSeen).
*/

View File

@@ -0,0 +1,35 @@
package com.lions.dev.repository;
import com.lions.dev.entity.establishment.EstablishmentMedia;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
/**
* Repository Panache pour les médias d'établissements.
*/
@ApplicationScoped
public class EstablishmentMediaRepository implements PanacheRepositoryBase<EstablishmentMedia, UUID> {
/**
* Récupère tous les médias d'un établissement, triés par ordre d'affichage.
*
* @param establishmentId L'ID de l'établissement
* @return Liste des médias de l'établissement
*/
public List<EstablishmentMedia> findByEstablishmentId(UUID establishmentId) {
return find("establishment.id = ?1 ORDER BY displayOrder ASC", establishmentId).list();
}
/**
* Supprime tous les médias d'un établissement.
*
* @param establishmentId L'ID de l'établissement
*/
public void deleteByEstablishmentId(UUID establishmentId) {
delete("establishment.id = ?1", establishmentId);
}
}

View File

@@ -0,0 +1,93 @@
package com.lions.dev.repository;
import com.lions.dev.entity.establishment.EstablishmentRating;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Repository Panache pour les notations d'établissements.
*/
@ApplicationScoped
public class EstablishmentRatingRepository implements PanacheRepositoryBase<EstablishmentRating, UUID> {
/**
* Récupère la note d'un utilisateur pour un établissement.
* Utilise un fetch join pour charger les relations establishment et user en une seule requête.
*
* @param establishmentId L'ID de l'établissement
* @param userId L'ID de l'utilisateur
* @return La note de l'utilisateur ou null si pas encore noté
*/
public EstablishmentRating findByEstablishmentIdAndUserId(UUID establishmentId, UUID userId) {
List<EstablishmentRating> results = getEntityManager()
.createQuery(
"SELECT r FROM EstablishmentRating r " +
"JOIN FETCH r.establishment " +
"JOIN FETCH r.user " +
"WHERE r.establishment.id = :establishmentId AND r.user.id = :userId",
EstablishmentRating.class
)
.setParameter("establishmentId", establishmentId)
.setParameter("userId", userId)
.getResultList();
return results.isEmpty() ? null : results.get(0);
}
/**
* Récupère toutes les notes d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @return Liste des notes de l'établissement
*/
public List<EstablishmentRating> findByEstablishmentId(UUID establishmentId) {
return find("establishment.id = ?1 ORDER BY ratedAt DESC", establishmentId).list();
}
/**
* Calcule la note moyenne d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @return La note moyenne (0.0 si aucune note)
*/
public Double calculateAverageRating(UUID establishmentId) {
List<EstablishmentRating> ratings = findByEstablishmentId(establishmentId);
if (ratings.isEmpty()) {
return 0.0;
}
return ratings.stream()
.mapToInt(EstablishmentRating::getRating)
.average()
.orElse(0.0);
}
/**
* Calcule la distribution des notes pour un établissement.
*
* @param establishmentId L'ID de l'établissement
* @return Map avec clé = nombre d'étoiles (1-5), valeur = nombre de notes
*/
public Map<Integer, Integer> calculateRatingDistribution(UUID establishmentId) {
List<EstablishmentRating> ratings = findByEstablishmentId(establishmentId);
return ratings.stream()
.collect(Collectors.groupingBy(
EstablishmentRating::getRating,
Collectors.collectingAndThen(Collectors.counting(), Long::intValue)
));
}
/**
* Compte le nombre total de notes pour un établissement.
*
* @param establishmentId L'ID de l'établissement
* @return Le nombre total de notes
*/
public Long countByEstablishmentId(UUID establishmentId) {
return count("establishment.id = ?1", establishmentId);
}
}

View File

@@ -0,0 +1,88 @@
package com.lions.dev.repository;
import com.lions.dev.entity.establishment.Establishment;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
import org.jboss.logging.Logger;
/**
* Repository pour l'entité Establishment.
* Ce repository gère les opérations de base (CRUD) sur les établissements.
*/
@ApplicationScoped
public class EstablishmentRepository implements PanacheRepositoryBase<Establishment, UUID> {
private static final Logger LOG = Logger.getLogger(EstablishmentRepository.class);
/**
* Récupère tous les établissements gérés par un responsable.
*
* @param managerId L'ID du responsable.
* @return Une liste d'établissements gérés par ce responsable.
*/
public List<Establishment> findByManagerId(UUID managerId) {
LOG.info("[LOG] Récupération des établissements du responsable : " + managerId);
List<Establishment> establishments = list("manager.id = ?1", managerId);
LOG.info("[LOG] Nombre d'établissements trouvés pour le responsable " + managerId + " : " + establishments.size());
return establishments;
}
/**
* Recherche des établissements par nom ou ville.
*
* @param query Le terme de recherche.
* @return Une liste d'établissements correspondant à la recherche.
*/
public List<Establishment> searchByNameOrCity(String query) {
LOG.info("[LOG] Recherche d'établissements avec la requête : " + query);
String searchPattern = "%" + query.toLowerCase() + "%";
List<Establishment> establishments = list(
"LOWER(name) LIKE ?1 OR LOWER(city) LIKE ?1",
searchPattern
);
LOG.info("[LOG] Nombre d'établissements trouvés : " + establishments.size());
return establishments;
}
/**
* Filtre les établissements par type.
*
* @param type Le type d'établissement.
* @return Une liste d'établissements du type spécifié.
*/
public List<Establishment> findByType(String type) {
LOG.info("[LOG] Filtrage des établissements par type : " + type);
List<Establishment> establishments = list("type = ?1", type);
LOG.info("[LOG] Nombre d'établissements trouvés : " + establishments.size());
return establishments;
}
/**
* Filtre les établissements par ville.
*
* @param city La ville.
* @return Une liste d'établissements dans cette ville.
*/
public List<Establishment> findByCity(String city) {
LOG.info("[LOG] Filtrage des établissements par ville : " + city);
List<Establishment> establishments = list("city = ?1", city);
LOG.info("[LOG] Nombre d'établissements trouvés : " + establishments.size());
return establishments;
}
/**
* Filtre les établissements par fourchette de prix.
*
* @param priceRange La fourchette de prix.
* @return Une liste d'établissements avec cette fourchette de prix.
*/
public List<Establishment> findByPriceRange(String priceRange) {
LOG.info("[LOG] Filtrage des établissements par fourchette de prix : " + priceRange);
List<Establishment> establishments = list("priceRange = ?1", priceRange);
LOG.info("[LOG] Nombre d'établissements trouvés : " + establishments.size());
return establishments;
}
}

View File

@@ -25,10 +25,23 @@ public class StoryRepository implements PanacheRepositoryBase<Story, UUID> {
* @return Liste des stories actives
*/
public List<Story> findAllActive() {
System.out.println("[LOG] Récupération de toutes les stories actives");
return findAllActive(0, Integer.MAX_VALUE);
}
/**
* Récupère toutes les stories actives (non expirées) avec pagination, triées par date de création décroissante.
*
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des stories actives
*/
public List<Story> findAllActive(int page, int size) {
System.out.println("[LOG] Récupération de toutes les stories actives (page: " + page + ", size: " + size + ")");
List<Story> stories = find("isActive = true AND expiresAt > ?1",
Sort.by("createdAt", Sort.Direction.Descending),
LocalDateTime.now()).list();
LocalDateTime.now())
.page(page, size)
.list();
System.out.println("[LOG] " + stories.size() + " story(ies) active(s) récupérée(s)");
return stories;
}
@@ -40,11 +53,25 @@ public class StoryRepository implements PanacheRepositoryBase<Story, UUID> {
* @return Liste des stories actives de l'utilisateur
*/
public List<Story> findActiveByUserId(UUID userId) {
System.out.println("[LOG] Récupération des stories actives pour l'utilisateur ID : " + userId);
return findActiveByUserId(userId, 0, Integer.MAX_VALUE);
}
/**
* Récupère toutes les stories actives d'un utilisateur avec pagination.
*
* @param userId L'ID de l'utilisateur
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des stories actives de l'utilisateur
*/
public List<Story> findActiveByUserId(UUID userId, int page, int size) {
System.out.println("[LOG] Récupération des stories actives pour l'utilisateur ID : " + userId + " (page: " + page + ", size: " + size + ")");
List<Story> stories = find("user.id = ?1 AND isActive = true AND expiresAt > ?2",
Sort.by("createdAt", Sort.Direction.Descending),
userId,
LocalDateTime.now()).list();
LocalDateTime.now())
.page(page, size)
.list();
System.out.println("[LOG] " + stories.size() + " story(ies) active(s) trouvée(s) pour l'utilisateur ID : " + userId);
return stories;
}

View File

@@ -1,8 +1,11 @@
package com.lions.dev.repository;
import com.lions.dev.entity.events.Events;
import com.lions.dev.entity.users.Users;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.Optional;
import java.util.UUID;
@@ -15,6 +18,43 @@ import java.util.UUID;
@ApplicationScoped
public class UsersRepository implements PanacheRepositoryBase<Users, UUID> {
@PersistenceContext
EntityManager entityManager;
/**
* Compte le nombre d'utilisateurs qui ont un événement en favori.
*
* @param eventId L'ID de l'événement
* @return Le nombre d'utilisateurs qui ont cet événement en favori
*/
public long countUsersWithFavoriteEvent(UUID eventId) {
// ✅ Utiliser la table de jointure user_favorite_events
return entityManager.createQuery(
"SELECT COUNT(u) FROM Users u JOIN u.favoriteEvents e WHERE e.id = :eventId",
Long.class
)
.setParameter("eventId", eventId)
.getSingleResult();
}
/**
* Vérifie si un utilisateur a un événement en favori.
*
* @param userId L'ID de l'utilisateur
* @param eventId L'ID de l'événement
* @return true si l'utilisateur a cet événement en favori, false sinon
*/
public boolean hasUserFavoriteEvent(UUID userId, UUID eventId) {
Long count = entityManager.createQuery(
"SELECT COUNT(u) FROM Users u JOIN u.favoriteEvents e WHERE u.id = :userId AND e.id = :eventId",
Long.class
)
.setParameter("userId", userId)
.setParameter("eventId", eventId)
.getSingleResult();
return count > 0;
}
/**
* Recherche un utilisateur par son adresse email.
*

View File

@@ -0,0 +1,172 @@
package com.lions.dev.resource;
import com.lions.dev.dto.request.establishment.EstablishmentMediaRequestDTO;
import com.lions.dev.dto.response.establishment.EstablishmentMediaResponseDTO;
import com.lions.dev.entity.establishment.EstablishmentMedia;
import com.lions.dev.entity.establishment.MediaType;
import com.lions.dev.service.EstablishmentMediaService;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Ressource REST pour la gestion des médias d'établissements.
* Cette classe expose des endpoints pour uploader, récupérer et supprimer des médias.
*/
@Path("/establishments/{establishmentId}/media")
@Produces(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)
@Consumes(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)
@Tag(name = "Establishment Media", description = "Opérations liées aux médias des établissements")
public class EstablishmentMediaResource {
@Inject
EstablishmentMediaService mediaService;
private static final Logger LOG = Logger.getLogger(EstablishmentMediaResource.class);
/**
* Récupère tous les médias d'un établissement.
*/
@GET
@Operation(summary = "Récupérer tous les médias d'un établissement",
description = "Retourne la liste de tous les médias (photos et vidéos) d'un établissement")
public Response getEstablishmentMedia(@PathParam("establishmentId") String establishmentId) {
LOG.info("Récupération des médias pour l'établissement : " + establishmentId);
try {
UUID id = UUID.fromString(establishmentId);
List<EstablishmentMedia> medias = mediaService.getMediaByEstablishmentId(id);
List<EstablishmentMediaResponseDTO> responseDTOs = medias.stream()
.map(EstablishmentMediaResponseDTO::new)
.collect(Collectors.toList());
return Response.ok(responseDTOs).build();
} catch (IllegalArgumentException e) {
LOG.error("ID d'établissement invalide : " + establishmentId);
return Response.status(Response.Status.BAD_REQUEST)
.entity("ID d'établissement invalide")
.build();
} catch (Exception e) {
LOG.error("Erreur lors de la récupération des médias", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de la récupération des médias")
.build();
}
}
/**
* Upload un nouveau média pour un établissement.
* Accepte un body JSON avec les informations du média.
*/
@POST
@Transactional
@Operation(summary = "Uploader un média pour un établissement",
description = "Upload un nouveau média (photo ou vidéo) pour un établissement")
public Response uploadMedia(
@PathParam("establishmentId") String establishmentId,
@Valid EstablishmentMediaRequestDTO requestDTO,
@QueryParam("uploadedByUserId") String uploadedByUserIdStr) {
LOG.info("Upload d'un média pour l'établissement : " + establishmentId);
try {
UUID id = UUID.fromString(establishmentId);
// Utiliser uploadedByUserId du query param ou du DTO
String userIdStr = uploadedByUserIdStr != null && !uploadedByUserIdStr.isEmpty()
? uploadedByUserIdStr
: requestDTO.getUploadedByUserId();
if (userIdStr == null || userIdStr.isEmpty()) {
LOG.error("uploadedByUserId est obligatoire");
return Response.status(Response.Status.BAD_REQUEST)
.entity("L'ID de l'utilisateur (uploadedByUserId) est obligatoire")
.build();
}
UUID uploadedByUserId = UUID.fromString(userIdStr);
// Valider le type de média
MediaType mediaType;
try {
mediaType = MediaType.valueOf(requestDTO.getMediaType().toUpperCase());
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("Type de média invalide. Utilisez PHOTO ou VIDEO")
.build();
}
// Appeler le service avec displayOrder
EstablishmentMedia media = mediaService.uploadMedia(
id,
requestDTO.getMediaUrl(),
mediaType,
uploadedByUserId,
requestDTO.getThumbnailUrl(),
requestDTO.getDisplayOrder() != null ? requestDTO.getDisplayOrder() : 0
);
EstablishmentMediaResponseDTO responseDTO = new EstablishmentMediaResponseDTO(media);
return Response.status(Response.Status.CREATED).entity(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.error("Paramètres invalides : " + e.getMessage(), e);
return Response.status(Response.Status.BAD_REQUEST)
.entity("Paramètres invalides : " + e.getMessage())
.build();
} catch (RuntimeException e) {
LOG.error("Erreur lors de l'upload du média : " + e.getMessage(), e);
return Response.status(Response.Status.BAD_REQUEST)
.entity(e.getMessage())
.build();
} catch (Exception e) {
LOG.error("Erreur inattendue lors de l'upload du média", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de l'upload du média")
.build();
}
}
/**
* Supprime un média d'un établissement.
*/
@DELETE
@Path("/{mediaId}")
@Transactional
@Operation(summary = "Supprimer un média d'un établissement",
description = "Supprime un média spécifique d'un établissement")
public Response deleteMedia(
@PathParam("establishmentId") String establishmentId,
@PathParam("mediaId") String mediaId) {
LOG.info("Suppression du média " + mediaId + " de l'établissement " + establishmentId);
try {
UUID establishmentUuid = UUID.fromString(establishmentId);
UUID mediaUuid = UUID.fromString(mediaId);
mediaService.deleteMedia(establishmentUuid, mediaUuid);
return Response.status(Response.Status.NO_CONTENT).build();
} catch (IllegalArgumentException e) {
LOG.error("ID invalide : " + e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity("ID invalide")
.build();
} catch (RuntimeException e) {
LOG.error("Erreur lors de la suppression du média : " + e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity(e.getMessage())
.build();
} catch (Exception e) {
LOG.error("Erreur inattendue lors de la suppression du média", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de la suppression du média")
.build();
}
}
}

View File

@@ -0,0 +1,233 @@
package com.lions.dev.resource;
import com.lions.dev.dto.request.establishment.EstablishmentRatingRequestDTO;
import com.lions.dev.dto.response.establishment.EstablishmentRatingResponseDTO;
import com.lions.dev.dto.response.establishment.EstablishmentRatingStatsResponseDTO;
import com.lions.dev.entity.establishment.EstablishmentRating;
import com.lions.dev.service.EstablishmentRatingService;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import java.util.Map;
import java.util.UUID;
/**
* Ressource REST pour la gestion des notations d'établissements.
* Cette classe expose des endpoints pour soumettre, modifier et récupérer les notes.
*/
@Path("/establishments/{establishmentId}/ratings")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Establishment Ratings", description = "Opérations liées aux notations des établissements")
public class EstablishmentRatingResource {
@Inject
EstablishmentRatingService ratingService;
private static final Logger LOG = Logger.getLogger(EstablishmentRatingResource.class);
/**
* Soumet une nouvelle note pour un établissement.
*/
@POST
@Transactional
@Operation(summary = "Soumettre une note pour un établissement",
description = "Soumet une nouvelle note (1 à 5 étoiles) pour un établissement")
public Response submitRating(
@PathParam("establishmentId") String establishmentId,
@QueryParam("userId") String userIdStr,
@Valid EstablishmentRatingRequestDTO requestDTO) {
LOG.info("Soumission d'une note pour l'établissement " + establishmentId + " par l'utilisateur " + userIdStr);
try {
UUID id = UUID.fromString(establishmentId);
UUID userId = UUID.fromString(userIdStr);
EstablishmentRating rating = ratingService.submitRating(id, userId, requestDTO);
EstablishmentRatingResponseDTO responseDTO = new EstablishmentRatingResponseDTO(rating);
return Response.status(Response.Status.CREATED).entity(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.error("ID invalide : " + e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity("ID invalide : " + e.getMessage())
.build();
} catch (RuntimeException e) {
LOG.error("Erreur lors de la soumission de la note : " + e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity(e.getMessage())
.build();
} catch (Exception e) {
LOG.error("Erreur inattendue lors de la soumission de la note", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de la soumission de la note")
.build();
}
}
/**
* Met à jour une note existante.
*/
@PUT
@Transactional
@Operation(summary = "Modifier une note existante",
description = "Met à jour une note existante pour un établissement")
public Response updateRating(
@PathParam("establishmentId") String establishmentId,
@QueryParam("userId") String userIdStr,
@Valid EstablishmentRatingRequestDTO requestDTO) {
LOG.info("Mise à jour de la note pour l'établissement " + establishmentId + " par l'utilisateur " + userIdStr);
try {
UUID id = UUID.fromString(establishmentId);
UUID userId = UUID.fromString(userIdStr);
EstablishmentRating rating = ratingService.updateRating(id, userId, requestDTO);
EstablishmentRatingResponseDTO responseDTO = new EstablishmentRatingResponseDTO(rating);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.error("ID invalide : " + e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity("ID invalide : " + e.getMessage())
.build();
} catch (RuntimeException e) {
LOG.error("Erreur lors de la mise à jour de la note : " + e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity(e.getMessage())
.build();
} catch (Exception e) {
LOG.error("Erreur inattendue lors de la mise à jour de la note", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de la mise à jour de la note")
.build();
}
}
/**
* Récupère les statistiques de notation d'un établissement.
* Doit être déclaré avant les endpoints génériques GET pour la résolution correcte par JAX-RS.
*/
@GET
@Path("/stats")
@Operation(summary = "Récupérer les statistiques de notation",
description = "Récupère les statistiques de notation d'un établissement (moyenne, total, distribution)")
public Response getRatingStats(@PathParam("establishmentId") String establishmentId) {
LOG.info("Récupération des statistiques de notation pour l'établissement " + establishmentId);
try {
UUID id = UUID.fromString(establishmentId);
Map<String, Object> stats = ratingService.getRatingStats(id);
EstablishmentRatingStatsResponseDTO responseDTO = new EstablishmentRatingStatsResponseDTO(
(Double) stats.get("averageRating"),
(Integer) stats.get("totalRatingsCount"),
(Map<Integer, Integer>) stats.get("ratingDistribution")
);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.error("ID invalide : " + e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity("ID invalide : " + e.getMessage())
.build();
} catch (Exception e) {
LOG.error("Erreur inattendue lors de la récupération des statistiques", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de la récupération des statistiques")
.build();
}
}
/**
* Récupère la note d'un utilisateur pour un établissement (via path parameter).
* Endpoint alternatif pour compatibilité.
*/
@GET
@Path("/users/{userId}")
@Operation(summary = "Récupérer la note d'un utilisateur (path parameter)",
description = "Récupère la note donnée par un utilisateur spécifique pour un établissement (via path parameter)")
public Response getUserRatingByPath(
@PathParam("establishmentId") String establishmentId,
@PathParam("userId") String userIdStr) {
LOG.info("Récupération de la note de l'utilisateur " + userIdStr + " pour l'établissement " + establishmentId);
try {
UUID id = UUID.fromString(establishmentId);
UUID userId = UUID.fromString(userIdStr);
EstablishmentRating rating = ratingService.getUserRating(id, userId);
if (rating == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity("Note non trouvée")
.build();
}
EstablishmentRatingResponseDTO responseDTO = new EstablishmentRatingResponseDTO(rating);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.error("ID invalide : " + e.getMessage(), e);
return Response.status(Response.Status.BAD_REQUEST)
.entity("ID invalide : " + e.getMessage())
.build();
} catch (Exception e) {
LOG.error("Erreur inattendue lors de la récupération de la note", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de la récupération de la note")
.build();
}
}
/**
* Récupère la note d'un utilisateur pour un établissement (via query parameter).
* Endpoint utilisé par le frontend Flutter.
* Doit être déclaré en dernier car c'est l'endpoint le plus générique.
*/
@GET
@Operation(summary = "Récupérer la note d'un utilisateur",
description = "Récupère la note donnée par un utilisateur spécifique pour un établissement (via query parameter userId)")
public Response getUserRatingByQuery(
@PathParam("establishmentId") String establishmentId,
@QueryParam("userId") String userIdStr) {
LOG.info("Récupération de la note de l'utilisateur " + userIdStr + " pour l'établissement " + establishmentId);
// Si userId n'est pas fourni, retourner une erreur
if (userIdStr == null || userIdStr.isEmpty()) {
LOG.warn("userId manquant dans la requête");
return Response.status(Response.Status.BAD_REQUEST)
.entity("Le paramètre userId est requis")
.build();
}
try {
UUID id = UUID.fromString(establishmentId);
UUID userId = UUID.fromString(userIdStr);
EstablishmentRating rating = ratingService.getUserRating(id, userId);
if (rating == null) {
// Retourner 404 si la note n'existe pas
return Response.status(Response.Status.NOT_FOUND)
.entity("Note non trouvée")
.build();
}
EstablishmentRatingResponseDTO responseDTO = new EstablishmentRatingResponseDTO(rating);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
LOG.error("ID invalide : " + e.getMessage(), e);
return Response.status(Response.Status.BAD_REQUEST)
.entity("ID invalide : " + e.getMessage())
.build();
} catch (Exception e) {
LOG.error("Erreur inattendue lors de la récupération de la note", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de la récupération de la note")
.build();
}
}
}

View File

@@ -0,0 +1,288 @@
package com.lions.dev.resource;
import com.lions.dev.dto.request.establishment.EstablishmentCreateRequestDTO;
import com.lions.dev.dto.request.establishment.EstablishmentUpdateRequestDTO;
import com.lions.dev.dto.response.establishment.EstablishmentResponseDTO;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.UsersRepository;
import com.lions.dev.service.EstablishmentService;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Ressource REST pour la gestion des établissements dans le système AfterWork.
* Cette classe expose des endpoints pour créer, récupérer, mettre à jour et supprimer des établissements.
* Seuls les responsables d'établissement peuvent créer et gérer des établissements.
*/
@Path("/establishments")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Establishments", description = "Opérations liées à la gestion des établissements")
public class EstablishmentResource {
@Inject
EstablishmentService establishmentService;
@Inject
UsersRepository usersRepository;
private static final Logger LOG = Logger.getLogger(EstablishmentResource.class);
// *********** Création d'un établissement ***********
@POST
@Transactional
@Operation(summary = "Créer un nouvel établissement",
description = "Crée un nouvel établissement. Seuls les responsables d'établissement peuvent créer des établissements.")
public Response createEstablishment(@Valid EstablishmentCreateRequestDTO requestDTO) {
LOG.info("[LOG] Tentative de création d'un nouvel établissement : " + requestDTO.getName());
try {
// Vérifier que managerId est fourni
if (requestDTO.getManagerId() == null) {
LOG.error("[ERROR] managerId est obligatoire pour créer un établissement");
return Response.status(Response.Status.BAD_REQUEST)
.entity("L'identifiant du responsable (managerId) est obligatoire")
.build();
}
// Récupérer le responsable
Users manager = usersRepository.findById(requestDTO.getManagerId());
if (manager == null) {
LOG.error("[ERROR] Responsable non trouvé avec l'ID : " + requestDTO.getManagerId());
return Response.status(Response.Status.BAD_REQUEST)
.entity("Responsable non trouvé avec l'ID fourni")
.build();
}
// Créer l'établissement
Establishment establishment = new Establishment();
establishment.setName(requestDTO.getName());
establishment.setType(requestDTO.getType());
establishment.setAddress(requestDTO.getAddress());
establishment.setCity(requestDTO.getCity());
establishment.setPostalCode(requestDTO.getPostalCode());
establishment.setDescription(requestDTO.getDescription());
establishment.setPhoneNumber(requestDTO.getPhoneNumber());
establishment.setWebsite(requestDTO.getWebsite());
establishment.setPriceRange(requestDTO.getPriceRange());
establishment.setLatitude(requestDTO.getLatitude());
establishment.setLongitude(requestDTO.getLongitude());
Establishment createdEstablishment = establishmentService.createEstablishment(establishment, requestDTO.getManagerId());
LOG.info("[LOG] Établissement créé avec succès : " + createdEstablishment.getName());
EstablishmentResponseDTO responseDTO = new EstablishmentResponseDTO(createdEstablishment);
return Response.status(Response.Status.CREATED).entity(responseDTO).build();
} catch (RuntimeException e) {
LOG.error("[ERROR] Erreur lors de la création de l'établissement : " + e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity(e.getMessage())
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur inattendue lors de la création de l'établissement", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de la création de l'établissement")
.build();
}
}
// *********** Récupération de tous les établissements ***********
@GET
@Operation(summary = "Récupérer tous les établissements",
description = "Retourne la liste de tous les établissements disponibles")
public Response getAllEstablishments() {
LOG.info("[LOG] Récupération de tous les établissements");
try {
List<Establishment> establishments = establishmentService.getAllEstablishments();
List<EstablishmentResponseDTO> responseDTOs = establishments.stream()
.map(EstablishmentResponseDTO::new)
.collect(Collectors.toList());
return Response.ok(responseDTOs).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des établissements", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de la récupération des établissements")
.build();
}
}
// *********** Récupération d'un établissement par ID ***********
@GET
@Path("/{id}")
@Operation(summary = "Récupérer un établissement par ID",
description = "Retourne les détails de l'établissement demandé")
public Response getEstablishmentById(@PathParam("id") UUID id) {
LOG.info("[LOG] Récupération de l'établissement avec l'ID : " + id);
try {
Establishment establishment = establishmentService.getEstablishmentById(id);
EstablishmentResponseDTO responseDTO = new EstablishmentResponseDTO(establishment);
return Response.ok(responseDTO).build();
} catch (RuntimeException e) {
LOG.warn("[WARN] " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity(e.getMessage())
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération de l'établissement", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de la récupération de l'établissement")
.build();
}
}
// *********** Recherche d'établissements ***********
@GET
@Path("/search")
@Operation(summary = "Rechercher des établissements",
description = "Recherche des établissements par nom ou ville")
public Response searchEstablishments(@QueryParam("q") String query) {
LOG.info("[LOG] Recherche d'établissements avec la requête : " + query);
try {
if (query == null || query.trim().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("Le paramètre de recherche 'q' est obligatoire")
.build();
}
List<Establishment> establishments = establishmentService.searchEstablishments(query);
List<EstablishmentResponseDTO> responseDTOs = establishments.stream()
.map(EstablishmentResponseDTO::new)
.collect(Collectors.toList());
return Response.ok(responseDTOs).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la recherche d'établissements", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de la recherche d'établissements")
.build();
}
}
// *********** Filtrage d'établissements ***********
@GET
@Path("/filter")
@Operation(summary = "Filtrer les établissements",
description = "Filtre les établissements par type, fourchette de prix et/ou ville")
public Response filterEstablishments(
@QueryParam("type") String type,
@QueryParam("priceRange") String priceRange,
@QueryParam("city") String city) {
LOG.info("[LOG] Filtrage des établissements : type=" + type + ", priceRange=" + priceRange + ", city=" + city);
try {
List<Establishment> establishments = establishmentService.filterEstablishments(type, priceRange, city);
List<EstablishmentResponseDTO> responseDTOs = establishments.stream()
.map(EstablishmentResponseDTO::new)
.collect(Collectors.toList());
return Response.ok(responseDTOs).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors du filtrage des établissements", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors du filtrage des établissements")
.build();
}
}
// *********** Récupération des établissements d'un responsable ***********
@GET
@Path("/manager/{managerId}")
@Operation(summary = "Récupérer les établissements d'un responsable",
description = "Retourne tous les établissements gérés par un responsable")
public Response getEstablishmentsByManager(@PathParam("managerId") UUID managerId) {
LOG.info("[LOG] Récupération des établissements du responsable : " + managerId);
try {
List<Establishment> establishments = establishmentService.getEstablishmentsByManager(managerId);
List<EstablishmentResponseDTO> responseDTOs = establishments.stream()
.map(EstablishmentResponseDTO::new)
.collect(Collectors.toList());
return Response.ok(responseDTOs).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des établissements du responsable", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de la récupération des établissements du responsable")
.build();
}
}
// *********** Mise à jour d'un établissement ***********
@PUT
@Path("/{id}")
@Transactional
@Operation(summary = "Mettre à jour un établissement",
description = "Met à jour les informations d'un établissement existant")
public Response updateEstablishment(
@PathParam("id") UUID id,
@Valid EstablishmentUpdateRequestDTO requestDTO) {
LOG.info("[LOG] Mise à jour de l'établissement avec l'ID : " + id);
try {
Establishment establishment = new Establishment();
establishment.setName(requestDTO.getName());
establishment.setType(requestDTO.getType());
establishment.setAddress(requestDTO.getAddress());
establishment.setCity(requestDTO.getCity());
establishment.setPostalCode(requestDTO.getPostalCode());
establishment.setDescription(requestDTO.getDescription());
establishment.setPhoneNumber(requestDTO.getPhoneNumber());
establishment.setWebsite(requestDTO.getWebsite());
establishment.setPriceRange(requestDTO.getPriceRange());
establishment.setLatitude(requestDTO.getLatitude());
establishment.setLongitude(requestDTO.getLongitude());
Establishment updatedEstablishment = establishmentService.updateEstablishment(id, establishment);
EstablishmentResponseDTO responseDTO = new EstablishmentResponseDTO(updatedEstablishment);
return Response.ok(responseDTO).build();
} catch (RuntimeException e) {
LOG.error("[ERROR] " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity(e.getMessage())
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la mise à jour de l'établissement", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de la mise à jour de l'établissement")
.build();
}
}
// *********** Suppression d'un établissement ***********
@DELETE
@Path("/{id}")
@Transactional
@Operation(summary = "Supprimer un établissement",
description = "Supprime un établissement du système")
public Response deleteEstablishment(@PathParam("id") UUID id) {
LOG.info("[LOG] Suppression de l'établissement avec l'ID : " + id);
try {
establishmentService.deleteEstablishment(id);
return Response.noContent().build();
} catch (RuntimeException e) {
LOG.error("[ERROR] " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity(e.getMessage())
.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")
.build();
}
}
}

View File

@@ -66,11 +66,25 @@ public class EventsResource {
@Operation(summary = "Créer un nouvel événement", description = "Crée un nouvel événement et retourne ses détails")
public Response createEvent(EventCreateRequestDTO eventCreateRequestDTO) {
LOG.info("[LOG] Tentative de création d'un nouvel événement : " + eventCreateRequestDTO.getTitle());
// Valider que creatorId est fourni
if (eventCreateRequestDTO.getCreatorId() == null) {
LOG.error("[ERROR] creatorId est obligatoire pour créer un événement");
return Response.status(Response.Status.BAD_REQUEST)
.entity("L'identifiant du créateur (creatorId) est obligatoire")
.build();
}
// Récupérer le créateur par son ID
Users creator = usersRepository.findById(eventCreateRequestDTO.getCreatorId());
if (creator == null) {
LOG.error("[ERROR] Créateur non trouvé avec l'ID : " + eventCreateRequestDTO.getCreatorId());
return Response.status(Response.Status.BAD_REQUEST).entity("Créateur non trouvé").build();
return Response.status(Response.Status.BAD_REQUEST)
.entity("Créateur non trouvé avec l'ID fourni")
.build();
}
// Créer l'événement
Events event = eventService.createEvent(eventCreateRequestDTO, creator);
LOG.info("[LOG] Événement créé avec succès : " + event.getTitle());
EventCreateResponseDTO responseDTO = new EventCreateResponseDTO(event);
@@ -100,10 +114,16 @@ public class EventsResource {
@Path("/{id}")
@Transactional
@Operation(summary = "Supprimer un événement", description = "Supprime un événement de la base de données")
public Response deleteEvent(@PathParam("id") UUID id) {
LOG.info("Tentative de suppression de l'événement avec l'ID : " + id);
public Response deleteEvent(@PathParam("id") UUID id, @QueryParam("userId") UUID userId) {
LOG.info("Tentative de suppression de l'événement avec l'ID : " + id + " par l'utilisateur : " + userId);
if (userId == null) {
LOG.error("[ERROR] userId est obligatoire pour supprimer un événement");
return Response.status(Response.Status.BAD_REQUEST).entity("L'identifiant de l'utilisateur (userId) est obligatoire").build();
}
try {
boolean deleted = eventService.deleteEvent(id);
boolean deleted = eventService.deleteEvent(id, userId);
if (deleted) {
LOG.info("Événement supprimé avec succès.");
return Response.noContent().build();
@@ -114,6 +134,9 @@ public class EventsResource {
} catch (EventNotFoundException e) {
LOG.error("[ERROR] Échec de la suppression : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND).entity("Événement non trouvé.").build();
} catch (SecurityException e) {
LOG.error("[ERROR] Permissions insuffisantes : " + e.getMessage());
return Response.status(Response.Status.FORBIDDEN).entity(e.getMessage()).build();
}
}
@@ -166,19 +189,31 @@ public class EventsResource {
@Path("/{id}")
@Transactional
@Operation(summary = "Mettre à jour un événement", description = "Modifie un événement existant")
public Response updateEvent(@PathParam("id") UUID id, EventUpdateRequestDTO eventUpdateRequestDTO) {
LOG.info("[LOG] Tentative de mise à jour de l'événement avec l'ID : " + id);
public Response updateEvent(@PathParam("id") UUID id, @QueryParam("userId") UUID userId, EventUpdateRequestDTO eventUpdateRequestDTO) {
LOG.info("[LOG] Tentative de mise à jour de l'événement avec l'ID : " + id + " par l'utilisateur : " + userId);
if (userId == null) {
LOG.error("[ERROR] userId est obligatoire pour mettre à jour un événement");
return Response.status(Response.Status.BAD_REQUEST).entity("L'identifiant de l'utilisateur (userId) est obligatoire").build();
}
Events event = eventsRepository.findById(id);
if (event == null) {
LOG.warn("[LOG] Événement non trouvé avec l'ID : " + id);
return Response.status(Response.Status.NOT_FOUND).entity("Événement non trouvé.").build();
}
// Vérifier que l'utilisateur est le créateur
if (!eventService.canModifyEvent(event, userId)) {
LOG.error("[ERROR] L'utilisateur " + userId + " n'a pas les permissions pour modifier l'événement " + id);
return Response.status(Response.Status.FORBIDDEN).entity("Vous n'avez pas les permissions pour modifier cet événement").build();
}
// Mise à jour des attributs de l'événement
event.setTitle(eventUpdateRequestDTO.getTitle());
event.setStartDate(eventUpdateRequestDTO.getStartDate());
event.setEndDate(eventUpdateRequestDTO.getEndDate());
event.setDescription(eventUpdateRequestDTO.getDescription());
event.setLocation(eventUpdateRequestDTO.getLocation());
event.setCategory(eventUpdateRequestDTO.getCategory());
event.setLink(eventUpdateRequestDTO.getLink());
event.setImageUrl(eventUpdateRequestDTO.getImageUrl());
@@ -194,10 +229,12 @@ public class EventsResource {
@Path("/created-by-user-and-friends")
@Consumes("application/json")
@Produces("application/json")
@Operation(summary = "Récupérer les événements créés par un utilisateur et ses amis", description = "Retourne la liste des événements créés par un utilisateur spécifique et ses amis")
@Operation(summary = "Récupérer les événements créés par un utilisateur et ses amis", description = "Retourne la liste paginée des événements créés par un utilisateur spécifique et ses amis")
public Response getEventsCreatedByUserAndFriends(EventReadManyByIdRequestDTO requestDTO) {
UUID userId = requestDTO.getUserId();
LOG.info("[LOG] Récupération des événements pour l'utilisateur avec l'ID : " + userId + " et ses amis");
UUID userId = requestDTO.getId();
int page = requestDTO.getPage() != null ? requestDTO.getPage() : 0;
int size = requestDTO.getSize() != null ? requestDTO.getSize() : 10;
LOG.info("[LOG] Récupération des événements pour l'utilisateur avec l'ID : " + userId + " et ses amis (page: " + page + ", size: " + size + ")");
Users user = usersRepository.findById(userId);
if (user == null) {
LOG.warn("[LOG] Utilisateur non trouvé avec l'ID : " + userId);
@@ -206,18 +243,22 @@ public class EventsResource {
try {
List<FriendshipReadFriendDetailsResponseDTO> friends = friendshipService.listFriends(userId, 0, Integer.MAX_VALUE);
Set<UUID> friendIds = friends.stream().map(FriendshipReadFriendDetailsResponseDTO::getFriendId).collect(Collectors.toSet());
//friendIds = friendIds.stream().distinct().collect(Collectors.toSet());
LOG.info("[LOG] IDs d'amis + utilisateur (taille attendue: " + friendIds.size() + ") : " + friendIds);
if (friendIds.isEmpty()) {
LOG.warn("[LOG] Aucun ami trouvé.");
return Response.status(Response.Status.NOT_FOUND).entity("Aucun ami trouvé.").build();
}
List<Events> events = eventsRepository.find("creator.id IN ?1", friendIds).list();
// IMPORTANT: Ajouter l'ID de l'utilisateur lui-même pour inclure ses propres événements
friendIds.add(userId);
LOG.info("[LOG] IDs d'amis + utilisateur (taille: " + friendIds.size() + ") : " + friendIds);
// Rechercher les événements créés par l'utilisateur et ses amis avec pagination
List<Events> events = eventsRepository.find("creator.id IN ?1 ORDER BY startDate DESC", friendIds)
.page(page, size)
.list();
LOG.info("[LOG] Nombre d'événements récupérés dans la requête : " + events.size());
if (events.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND).entity("Aucun événement trouvé.").build();
}
List<EventReadManyByIdResponseDTO> responseDTOs = events.stream().map(EventReadManyByIdResponseDTO::new).toList();
// ✅ Retourner avec reactionsCount et isFavorite pour l'utilisateur actuel
List<EventCreateResponseDTO> responseDTOs = events.stream()
.map(event -> new EventCreateResponseDTO(event, usersRepository, userId))
.toList();
return Response.ok(responseDTOs).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur de récupération des événements : ", e);
@@ -366,13 +407,26 @@ public class EventsResource {
@Operation(
summary = "Mettre à jour le statut d'un événement",
description = "Modifie le statut d'un événement (ouvert, fermé, annulé, etc.)")
public Response updateEventStatus(@PathParam("id") UUID id, @QueryParam("status") String status) {
LOG.info("[LOG] Mise à jour du statut de l'événement avec l'ID : " + id);
public Response updateEventStatus(@PathParam("id") UUID id, @QueryParam("status") String status, @QueryParam("userId") UUID userId) {
LOG.info("[LOG] Mise à jour du statut de l'événement avec l'ID : " + id + " par l'utilisateur : " + userId);
if (userId == null) {
LOG.error("[ERROR] userId est obligatoire pour mettre à jour le statut d'un événement");
return Response.status(Response.Status.BAD_REQUEST).entity("L'identifiant de l'utilisateur (userId) est obligatoire").build();
}
Events event = eventsRepository.findById(id);
if (event == null) {
LOG.warn("[LOG] Événement non trouvé avec l'ID : " + id);
return Response.status(Response.Status.NOT_FOUND).entity("Événement non trouvé.").build();
}
// Vérifier que l'utilisateur est le créateur
if (!eventService.canModifyEvent(event, userId)) {
LOG.error("[ERROR] L'utilisateur " + userId + " n'a pas les permissions pour modifier le statut de l'événement " + id);
return Response.status(Response.Status.FORBIDDEN).entity("Vous n'avez pas les permissions pour modifier cet événement").build();
}
event.setStatus(status);
eventsRepository.persist(event);
LOG.info("[LOG] Statut de l'événement mis à jour avec succès : " + status);
@@ -393,14 +447,17 @@ public class EventsResource {
@Operation(
summary = "Récupérer les événements auxquels un utilisateur est inscrit",
description = "Retourne la liste des événements auxquels un utilisateur spécifique est inscrit")
public Response getEventsByUser(@PathParam("userId") UUID userId) {
LOG.info("[LOG] Récupération des événements pour l'utilisateur avec l'ID : " + userId);
public Response getEventsByUser(
@PathParam("userId") UUID userId,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération des événements pour l'utilisateur avec l'ID : " + userId + " (page: " + page + ", size: " + size + ")");
Users user = usersRepository.findById(userId);
if (user == null) {
LOG.warn("[LOG] Utilisateur non trouvé avec l'ID : " + userId);
return Response.status(Response.Status.NOT_FOUND).entity("Utilisateur non trouvé.").build();
}
List<Events> events = eventService.findEventsByUser(user);
List<Events> events = eventService.findEventsByUser(user, page, size);
if (events.isEmpty()) {
LOG.warn("[LOG] Aucun événement trouvé pour l'utilisateur avec l'ID : " + userId);
return Response.status(Response.Status.NOT_FOUND).entity("Aucun événement trouvé.").build();
@@ -423,8 +480,13 @@ public class EventsResource {
*/
@PUT
@Path("/{id}/image")
public Response updateEventImage(@PathParam("id") UUID id, String imageFilePath) {
LOG.info("[LOG] Tentative de mise à jour de l'image pour l'événement avec l'ID : " + id);
public Response updateEventImage(@PathParam("id") UUID id, @QueryParam("userId") UUID userId, String imageFilePath) {
LOG.info("[LOG] Tentative de mise à jour de l'image pour l'événement avec l'ID : " + id + " par l'utilisateur : " + userId);
if (userId == null) {
LOG.error("[ERROR] userId est obligatoire pour mettre à jour l'image d'un événement");
return Response.status(Response.Status.BAD_REQUEST).entity("L'identifiant de l'utilisateur (userId) est obligatoire").build();
}
try {
if (imageFilePath == null || imageFilePath.isEmpty()) {
@@ -444,9 +506,15 @@ public class EventsResource {
return Response.status(Response.Status.NOT_FOUND).entity("Événement non trouvé.").build();
}
// Vérifier que l'utilisateur est le créateur
if (!eventService.canModifyEvent(event, userId)) {
LOG.error("[ERROR] L'utilisateur " + userId + " n'a pas les permissions pour modifier l'image de l'événement " + id);
return Response.status(Response.Status.FORBIDDEN).entity("Vous n'avez pas les permissions pour modifier cet événement").build();
}
String imageUrl = file.getAbsolutePath();
event.setImageUrl(imageUrl);
eventService.updateEvent(event);
eventService.updateEvent(event, userId);
LOG.info("[LOG] Image de l'événement mise à jour avec succès pour : " + event.getTitle());
return Response.ok("Image de l'événement mise à jour avec succès.").build();
@@ -461,8 +529,13 @@ public class EventsResource {
@Path("/{id}/partial-update")
@Transactional
@Operation(summary = "Mettre à jour partiellement un événement", description = "Mise à jour partielle des informations d'un événement")
public Response partialUpdateEvent(@PathParam("id") UUID id, Map<String, Object> updates) {
LOG.info("[LOG] Tentative de mise à jour partielle de l'événement avec l'ID : " + id);
public Response partialUpdateEvent(@PathParam("id") UUID id, @QueryParam("userId") UUID userId, Map<String, Object> updates) {
LOG.info("[LOG] Tentative de mise à jour partielle de l'événement avec l'ID : " + id + " par l'utilisateur : " + userId);
if (userId == null) {
LOG.error("[ERROR] userId est obligatoire pour mettre à jour un événement");
return Response.status(Response.Status.BAD_REQUEST).entity("L'identifiant de l'utilisateur (userId) est obligatoire").build();
}
Events event = eventsRepository.findById(id);
if (event == null) {
@@ -470,9 +543,36 @@ public class EventsResource {
return Response.status(Response.Status.NOT_FOUND).entity("Événement non trouvé.").build();
}
// Vérifier que l'utilisateur est le créateur
if (!eventService.canModifyEvent(event, userId)) {
LOG.error("[ERROR] L'utilisateur " + userId + " n'a pas les permissions pour modifier l'événement " + id);
return Response.status(Response.Status.FORBIDDEN).entity("Vous n'avez pas les permissions pour modifier cet événement").build();
}
// Mise à jour des champs dynamiquement
updates.forEach((field, value) -> {
// Mise à jour des champs dynamiquement, à adapter selon la structure de l'événement
// Exemple d'utilisation de réflexion si applicable
switch (field) {
case "title":
event.setTitle(value != null ? value.toString() : null);
break;
case "description":
event.setDescription(value != null ? value.toString() : null);
break;
case "category":
event.setCategory(value != null ? value.toString() : null);
break;
case "link":
event.setLink(value != null ? value.toString() : null);
break;
case "imageUrl":
event.setImageUrl(value != null ? value.toString() : null);
break;
case "status":
event.setStatus(value != null ? value.toString() : null);
break;
default:
LOG.warn("[LOG] Champ inconnu ignoré lors de la mise à jour partielle : " + field);
}
});
eventsRepository.persist(event);
@@ -519,8 +619,13 @@ public class EventsResource {
@Path("/{id}/cancel")
@Transactional
@Operation(summary = "Annuler un événement", description = "Annule un événement sans le supprimer.")
public Response cancelEvent(@PathParam("id") UUID id) {
LOG.info("[LOG] Annulation de l'événement avec l'ID : " + id);
public Response cancelEvent(@PathParam("id") UUID id, @QueryParam("userId") UUID userId) {
LOG.info("[LOG] Annulation de l'événement avec l'ID : " + id + " par l'utilisateur : " + userId);
if (userId == null) {
LOG.error("[ERROR] userId est obligatoire pour annuler un événement");
return Response.status(Response.Status.BAD_REQUEST).entity("L'identifiant de l'utilisateur (userId) est obligatoire").build();
}
Events event = eventsRepository.findById(id);
if (event == null) {
@@ -528,6 +633,12 @@ public class EventsResource {
return Response.status(Response.Status.NOT_FOUND).entity("Événement non trouvé.").build();
}
// Vérifier que l'utilisateur est le créateur
if (!eventService.canModifyEvent(event, userId)) {
LOG.error("[ERROR] L'utilisateur " + userId + " n'a pas les permissions pour annuler l'événement " + id);
return Response.status(Response.Status.FORBIDDEN).entity("Vous n'avez pas les permissions pour annuler cet événement").build();
}
event.setStatus("Annulé");
eventsRepository.persist(event);
LOG.info("[LOG] Événement annulé avec succès : " + event.getTitle());
@@ -654,9 +765,9 @@ public class EventsResource {
@POST
@Path("/{id}/favorite")
@Transactional
@Operation(summary = "Marquer un événement comme favori", description = "Permet à un utilisateur de marquer un événement comme favori.")
@Operation(summary = "Toggle favori d'un événement", description = "Permet à un utilisateur d'ajouter ou retirer un événement de ses favoris (toggle).")
public Response favoriteEvent(@PathParam("id") UUID eventId, @QueryParam("userId") UUID userId) {
LOG.info("[LOG] Marquage de l'événement comme favori pour l'utilisateur ID : " + userId);
LOG.info("[LOG] Toggle favori de l'événement " + eventId + " pour l'utilisateur ID : " + userId);
Events event = eventsRepository.findById(eventId);
Users user = usersRepository.findById(userId);
@@ -665,9 +776,22 @@ public class EventsResource {
return Response.status(Response.Status.NOT_FOUND).entity("Événement ou utilisateur non trouvé.").build();
}
user.addFavoriteEvent(event);
// ✅ Toggle : ajouter si pas favori, retirer si déjà favori
boolean wasFavorite = user.hasFavoriteEvent(event);
if (wasFavorite) {
user.removeFavoriteEvent(event);
LOG.info("[LOG] Événement retiré des favoris pour l'utilisateur : " + user.getEmail());
} else {
user.addFavoriteEvent(event);
LOG.info("[LOG] Événement ajouté aux favoris pour l'utilisateur : " + user.getEmail());
}
usersRepository.persist(user);
return Response.ok("Événement marqué comme favori.").build();
// Retourner un JSON avec le statut
Map<String, Object> response = new java.util.HashMap<>();
response.put("isFavorite", !wasFavorite);
response.put("message", wasFavorite ? "Événement retiré des favoris." : "Événement ajouté aux favoris.");
return Response.ok(response).build();
}
@GET
@@ -716,6 +840,50 @@ public class EventsResource {
return Response.ok(responseDTOs).build();
}
@POST
@Path("/{id}/comments")
@Transactional
@Operation(summary = "Ajouter un commentaire à un événement", description = "Crée un nouveau commentaire pour un événement.")
public Response addComment(
@PathParam("id") UUID eventId,
@QueryParam("userId") UUID userId,
Map<String, String> requestBody) {
LOG.info("[LOG] Ajout d'un commentaire à l'événement ID : " + eventId + " par l'utilisateur ID : " + userId);
Events event = eventsRepository.findById(eventId);
if (event == null) {
LOG.warn("[LOG] Événement non trouvé avec l'ID : " + eventId);
return Response.status(Response.Status.NOT_FOUND).entity("Événement non trouvé.").build();
}
Users user = usersRepository.findById(userId);
if (user == null) {
LOG.warn("[LOG] Utilisateur non trouvé avec l'ID : " + userId);
return Response.status(Response.Status.NOT_FOUND).entity("Utilisateur non trouvé.").build();
}
String text = requestBody.get("text");
if (text == null || text.trim().isEmpty()) {
LOG.warn("[LOG] Le texte du commentaire est vide");
return Response.status(Response.Status.BAD_REQUEST).entity("Le texte du commentaire est requis.").build();
}
try {
Comment comment = new Comment(user, event, text);
// Le commentaire sera automatiquement ajouté à la liste des commentaires de l'événement via la relation JPA
eventsRepository.persist(event);
LOG.info("[LOG] Commentaire ajouté avec succès à l'événement : " + event.getTitle());
CommentResponseDTO responseDTO = new CommentResponseDTO(comment);
return Response.status(Response.Status.CREATED).entity(responseDTO).build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de l'ajout du commentaire : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Erreur lors de l'ajout du commentaire.").build();
}
}
@GET
@Path("/recommendations/{userId}")
@Operation(summary = "Recommander des événements basés sur les intérêts", description = "Retourne une liste d'événements recommandés pour un utilisateur.")
@@ -850,7 +1018,7 @@ public class EventsResource {
public Response getEventsByFriends(
@PathParam("userId") UUID userId,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) {
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération des événements des amis pour l'utilisateur ID : " + userId);
try {

View File

@@ -2,17 +2,25 @@ package com.lions.dev.resource;
import com.lions.dev.service.FileService;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import jakarta.inject.Inject;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Path("/upload")
@Path("/media")
public class FileUploadResource {
private static final Logger LOG = Logger.getLogger(FileUploadResource.class);
@@ -20,17 +28,208 @@ public class FileUploadResource {
@Inject
FileService fileService;
@Context
UriInfo uriInfo;
@POST
@Path("/upload")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response uploadFile(@RestForm("file") FileUpload file) {
String uploadDir = "/tmp/uploads/";
@Produces(MediaType.APPLICATION_JSON)
public Response uploadFile(
@RestForm("file") FileUpload file,
@RestForm("type") String type,
@RestForm("fileName") String fileName,
@RestForm("contentType") String contentType,
@RestForm("fileSize") String fileSize,
@RestForm("userId") String userId) {
LOG.info("Début de l'upload de fichier");
LOG.infof("Type: %s, FileName: %s, ContentType: %s, FileSize: %s, UserId: %s",
type, fileName, contentType, fileSize, userId);
if (file == null || file.uploadedFile() == null) {
LOG.error("Aucun fichier fourni dans la requête");
return Response.status(Response.Status.BAD_REQUEST)
.entity(createErrorResponse("Aucun fichier fourni"))
.build();
}
try {
Path savedFilePath = (jakarta.ws.rs.Path) fileService.saveFile(file.uploadedFile(), uploadDir, file.fileName());
return Response.ok("Fichier uploadé avec succès : " + savedFilePath).build();
// Générer un nom de fichier unique si nécessaire
String finalFileName = fileName != null && !fileName.isEmpty()
? fileName
: generateUniqueFileName(file.fileName());
// Déterminer le type de média
String mediaType = type != null && !type.isEmpty()
? type
: determineMediaType(file.fileName());
// Répertoire d'upload
String uploadDir = "/tmp/uploads/";
// Sauvegarder le fichier
java.nio.file.Path savedFilePath = fileService.saveFile(
file.uploadedFile(),
uploadDir,
finalFileName
);
LOG.infof("Fichier sauvegardé avec succès: %s", savedFilePath);
// Construire l'URL du fichier
// Note: En production, vous devriez utiliser une URL publique (CDN, S3, etc.)
String fileUrl = buildFileUrl(finalFileName, uriInfo);
String thumbnailUrl = null;
// Pour les vidéos, on pourrait générer un thumbnail ici
if ("video".equalsIgnoreCase(mediaType)) {
// TODO: Générer un thumbnail pour les vidéos
thumbnailUrl = fileUrl; // Placeholder
}
// Construire la réponse JSON
Map<String, Object> response = new HashMap<>();
response.put("url", fileUrl);
response.put("fileName", finalFileName); // ✅ Ajout du fileName dans la réponse (DRY/WOU)
if (thumbnailUrl != null) {
response.put("thumbnailUrl", thumbnailUrl);
}
response.put("type", mediaType);
if (fileSize != null && !fileSize.isEmpty()) {
try {
long size = Long.parseLong(fileSize);
// Pour les vidéos, on pourrait calculer la durée ici
// response.put("duration", durationInSeconds);
} catch (NumberFormatException e) {
LOG.warnf("Impossible de parser fileSize: %s", fileSize);
}
}
LOG.infof("Upload réussi, URL: %s, FileName: %s", fileUrl, finalFileName);
return Response.status(Response.Status.CREATED)
.entity(response)
.build();
} catch (IOException e) {
LOG.error("Erreur lors de l'upload du fichier", e);
return Response.serverError().entity("Erreur lors de l'upload du fichier.").build();
LOG.errorf(e, "Erreur lors de l'upload du fichier: %s", e.getMessage());
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(createErrorResponse("Erreur lors de l'upload du fichier: " + e.getMessage()))
.build();
} catch (Exception e) {
LOG.errorf(e, "Erreur inattendue lors de l'upload: %s", e.getMessage());
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(createErrorResponse("Erreur inattendue."))
.build();
}
}
/**
* Génère un nom de fichier unique.
*/
private String generateUniqueFileName(String originalFileName) {
String extension = "";
int lastDot = originalFileName.lastIndexOf('.');
if (lastDot > 0) {
extension = originalFileName.substring(lastDot);
}
return UUID.randomUUID().toString() + extension;
}
/**
* Détermine le type de média basé sur l'extension du fichier.
*/
private String determineMediaType(String fileName) {
if (fileName == null) {
return "image";
}
String lowerFileName = fileName.toLowerCase();
if (lowerFileName.endsWith(".mp4") ||
lowerFileName.endsWith(".mov") ||
lowerFileName.endsWith(".avi") ||
lowerFileName.endsWith(".mkv") ||
lowerFileName.endsWith(".m4v")) {
return "video";
}
return "image";
}
/**
* Construit l'URL du fichier.
* En production, cela devrait pointer vers un CDN ou un service de stockage.
*/
private String buildFileUrl(String fileName, UriInfo uriInfo) {
// Construire l'URL de base à partir de l'URI de la requête
String baseUri = uriInfo.getBaseUri().toString();
// Retirer le slash final s'il existe
if (baseUri.endsWith("/")) {
baseUri = baseUri.substring(0, baseUri.length() - 1);
}
// Retourner une URL absolue
return baseUri + "/media/files/" + fileName;
}
@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);
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)
.entity(createErrorResponse("Erreur lors de la récupération du fichier"))
.build();
}
}
/**
* Détermine le content-type basé sur l'extension du fichier.
*/
private String determineContentType(String fileName) {
if (fileName == null) {
return MediaType.APPLICATION_OCTET_STREAM;
}
String lowerFileName = fileName.toLowerCase();
if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) {
return "image/jpeg";
} else if (lowerFileName.endsWith(".png")) {
return "image/png";
} else if (lowerFileName.endsWith(".gif")) {
return "image/gif";
} else if (lowerFileName.endsWith(".mp4")) {
return "video/mp4";
} else if (lowerFileName.endsWith(".mov")) {
return "video/quicktime";
} else if (lowerFileName.endsWith(".avi")) {
return "video/x-msvideo";
}
return MediaType.APPLICATION_OCTET_STREAM;
}
/**
* Crée une réponse d'erreur JSON.
*/
private Map<String, String> createErrorResponse(String message) {
Map<String, String> error = new HashMap<>();
error.put("error", message);
return error;
}
}

View File

@@ -80,7 +80,7 @@ public class NotificationResource {
public Response getNotificationsByUserIdWithPagination(
@PathParam("userId") UUID userId,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) {
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération paginée des notifications pour l'utilisateur ID : " + userId);
try {

View File

@@ -11,6 +11,7 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import org.eclipse.microprofile.openapi.annotations.Operation;
@@ -49,7 +50,7 @@ public class SocialPostResource {
description = "Retourne une liste paginée de tous les posts sociaux, triés par date de création décroissante")
public Response getAllPosts(
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) {
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération de tous les posts (page: " + page + ", size: " + size + ")");
try {
@@ -110,12 +111,12 @@ public class SocialPostResource {
summary = "Créer un nouveau post",
description = "Crée un nouveau post social et retourne ses détails")
public Response createPost(@Valid SocialPostCreateRequestDTO requestDTO) {
LOG.info("[LOG] Création d'un nouveau post par l'utilisateur ID : " + requestDTO.getUserId());
LOG.info("[LOG] Création d'un nouveau post par l'utilisateur ID : " + requestDTO.getCreatorId());
try {
SocialPost post = socialPostService.createPost(
requestDTO.getContent(),
requestDTO.getUserId(),
requestDTO.getCreatorId(),
requestDTO.getImageUrl()
);
SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post);
@@ -244,11 +245,19 @@ public class SocialPostResource {
@Operation(
summary = "Liker un post",
description = "Incrémente le compteur de likes d'un post")
public Response likePost(@PathParam("id") UUID postId) {
LOG.info("[LOG] Like du post ID : " + postId);
public Response likePost(
@PathParam("id") UUID postId,
@QueryParam("userId") UUID userId) {
LOG.info("[LOG] Like du post ID : " + postId + " par utilisateur : " + userId);
if (userId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"message\": \"userId est requis.\"}")
.build();
}
try {
SocialPost post = socialPostService.likePost(postId);
SocialPost post = socialPostService.likePost(postId, userId);
SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
@@ -273,14 +282,30 @@ public class SocialPostResource {
@POST
@Path("/{id}/comment")
@Transactional
@Consumes(MediaType.APPLICATION_JSON)
@Operation(
summary = "Commenter un post",
description = "Incrémente le compteur de commentaires d'un post")
public Response addComment(@PathParam("id") UUID postId) {
LOG.info("[LOG] Ajout de commentaire au post ID : " + postId);
description = "Ajoute un commentaire à un post")
public Response addComment(
@PathParam("id") UUID postId,
@QueryParam("userId") UUID userId,
String requestBody) {
LOG.info("[LOG] Ajout de commentaire au post ID : " + postId + " par utilisateur : " + userId);
if (userId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"message\": \"userId est requis.\"}")
.build();
}
try {
SocialPost post = socialPostService.addComment(postId);
// Parser le body pour obtenir le contenu du commentaire
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> body = mapper.readValue(requestBody, Map.class);
String commentContent = (String) body.getOrDefault("content", "");
SocialPost post = socialPostService.addComment(postId, userId, commentContent);
SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
@@ -308,11 +333,19 @@ public class SocialPostResource {
@Operation(
summary = "Partager un post",
description = "Incrémente le compteur de partages d'un post")
public Response sharePost(@PathParam("id") UUID postId) {
LOG.info("[LOG] Partage du post ID : " + postId);
public Response sharePost(
@PathParam("id") UUID postId,
@QueryParam("userId") UUID userId) {
LOG.info("[LOG] Partage du post ID : " + postId + " par utilisateur : " + userId);
if (userId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"message\": \"userId est requis.\"}")
.build();
}
try {
SocialPost post = socialPostService.sharePost(postId);
SocialPost post = socialPostService.sharePost(postId, userId);
SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post);
return Response.ok(responseDTO).build();
} catch (IllegalArgumentException e) {
@@ -373,7 +406,7 @@ public class SocialPostResource {
public Response getPostsByFriends(
@PathParam("userId") UUID userId,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) {
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération des posts des amis pour l'utilisateur ID : " + userId);
try {

View File

@@ -45,12 +45,15 @@ public class StoryResource {
@GET
@Operation(
summary = "Récupérer toutes les stories actives",
description = "Retourne une liste de toutes les stories actives (non expirées), triées par date de création décroissante")
public Response getAllActiveStories(@QueryParam("viewerId") UUID viewerId) {
LOG.info("[LOG] Récupération de toutes les stories actives");
description = "Retourne une liste paginée de toutes les stories actives (non expirées), triées par date de création décroissante")
public Response getAllActiveStories(
@QueryParam("viewerId") UUID viewerId,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération de toutes les stories actives (page: " + page + ", size: " + size + ")");
try {
List<Story> stories = storyService.getAllActiveStories();
List<Story> stories = storyService.getAllActiveStories(page, size);
List<StoryResponseDTO> responseDTOs = stories.stream()
.map(story -> viewerId != null ? new StoryResponseDTO(story, viewerId) : new StoryResponseDTO(story))
.collect(Collectors.toList());
@@ -109,12 +112,16 @@ public class StoryResource {
@Path("/user/{userId}")
@Operation(
summary = "Récupérer les stories d'un utilisateur",
description = "Retourne toutes les stories actives d'un utilisateur spécifique")
public Response getStoriesByUserId(@PathParam("userId") UUID userId, @QueryParam("viewerId") UUID viewerId) {
LOG.info("[LOG] Récupération des stories pour l'utilisateur ID : " + userId);
description = "Retourne une liste paginée des stories actives d'un utilisateur spécifique")
public Response getStoriesByUserId(
@PathParam("userId") UUID userId,
@QueryParam("viewerId") UUID viewerId,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size) {
LOG.info("[LOG] Récupération des stories pour l'utilisateur ID : " + userId + " (page: " + page + ", size: " + size + ")");
try {
List<Story> stories = storyService.getActiveStoriesByUserId(userId);
List<Story> stories = storyService.getActiveStoriesByUserId(userId, page, size);
List<StoryResponseDTO> responseDTOs = stories.stream()
.map(story -> viewerId != null ? new StoryResponseDTO(story, viewerId) : new StoryResponseDTO(story))
.collect(Collectors.toList());
@@ -140,11 +147,11 @@ public class StoryResource {
summary = "Créer une nouvelle story",
description = "Crée une nouvelle story et retourne ses détails")
public Response createStory(@Valid StoryCreateRequestDTO requestDTO) {
LOG.info("[LOG] Création d'une nouvelle story par l'utilisateur ID : " + requestDTO.getUserId());
LOG.info("[LOG] Création d'une nouvelle story par l'utilisateur ID : " + requestDTO.getCreatorId());
try {
Story story = storyService.createStory(
requestDTO.getUserId(),
requestDTO.getCreatorId(),
requestDTO.getMediaType(),
requestDTO.getMediaUrl(),
requestDTO.getThumbnailUrl(),

View File

@@ -60,7 +60,7 @@ public class UsersResource {
}
/**
* Endpoint pour authentifier un utilisateur.
* Endpoint pour authentifier un utilisateur (v2.0).
*
* @param userAuthenticateRequestDTO Le DTO contenant les informations d'authentification.
* @return Une réponse HTTP indiquant si l'authentification a réussi ou échoué.
@@ -73,10 +73,18 @@ public class UsersResource {
public Response authenticateUser(@Valid @NotNull UserAuthenticateRequestDTO userAuthenticateRequestDTO) {
LOG.info("Tentative d'authentification pour l'utilisateur avec l'email : " + userAuthenticateRequestDTO.getEmail());
Users user = userService.authenticateUser(userAuthenticateRequestDTO.getEmail(), userAuthenticateRequestDTO.getMotDePasse());
// v2.0 - Utiliser getPassword() qui gère la compatibilité v1.0 et v2.0
Users user = userService.authenticateUser(userAuthenticateRequestDTO.getEmail(), userAuthenticateRequestDTO.getPassword());
LOG.info("Authentification réussie pour l'utilisateur : " + user.getEmail());
UserAuthenticateResponseDTO responseDTO = new UserAuthenticateResponseDTO(user.getId(), user.getPrenoms(), user.getNom(), user.getEmail(), user.getRole());
// v2.0 - Utiliser les nouveaux noms de champs
UserAuthenticateResponseDTO responseDTO = new UserAuthenticateResponseDTO(
user.getId(),
user.getFirstName(), // v2.0
user.getLastName(), // v2.0
user.getEmail(),
user.getRole()
);
responseDTO.logResponseDetails();
return Response.ok(responseDTO).build();
}

View File

@@ -0,0 +1,124 @@
package com.lions.dev.service;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.establishment.EstablishmentMedia;
import com.lions.dev.entity.establishment.MediaType;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.EstablishmentMediaRepository;
import com.lions.dev.repository.EstablishmentRepository;
import com.lions.dev.repository.UsersRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.UUID;
/**
* Service de gestion des médias d'établissements.
* Ce service contient la logique métier pour l'upload, la récupération et la suppression des médias.
*/
@ApplicationScoped
public class EstablishmentMediaService {
@Inject
EstablishmentMediaRepository mediaRepository;
@Inject
EstablishmentRepository establishmentRepository;
@Inject
UsersRepository usersRepository;
private static final Logger LOG = Logger.getLogger(EstablishmentMediaService.class);
/**
* Récupère tous les médias d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @return Liste des médias de l'établissement
*/
public List<EstablishmentMedia> getMediaByEstablishmentId(UUID establishmentId) {
LOG.info("Récupération des médias pour l'établissement : " + establishmentId);
return mediaRepository.findByEstablishmentId(establishmentId);
}
/**
* Upload un nouveau média pour un établissement.
*
* @param establishmentId L'ID de l'établissement
* @param mediaUrl L'URL du média
* @param mediaType Le type de média (PHOTO ou VIDEO)
* @param uploadedByUserId L'ID de l'utilisateur qui upload
* @param thumbnailUrl L'URL de la miniature (optionnel, pour les vidéos)
* @param displayOrder L'ordre d'affichage (optionnel, par défaut calculé automatiquement)
* @return Le média créé
*/
@Transactional
public EstablishmentMedia uploadMedia(UUID establishmentId, String mediaUrl, MediaType mediaType, UUID uploadedByUserId, String thumbnailUrl, Integer displayOrder) {
LOG.info("Upload d'un média pour l'établissement : " + establishmentId);
// Vérifier que l'établissement existe
Establishment establishment = establishmentRepository.findById(establishmentId);
if (establishment == null) {
LOG.error("Établissement non trouvé avec l'ID : " + establishmentId);
throw new RuntimeException("Établissement non trouvé");
}
// Vérifier que l'utilisateur existe
Users uploadedBy = usersRepository.findById(uploadedByUserId);
if (uploadedBy == null) {
LOG.error("Utilisateur non trouvé avec l'ID : " + uploadedByUserId);
throw new RuntimeException("Utilisateur non trouvé");
}
// Créer le média
EstablishmentMedia media = new EstablishmentMedia(establishment, mediaUrl, mediaType, uploadedBy);
media.setThumbnailUrl(thumbnailUrl);
// Utiliser le displayOrder fourni, ou calculer automatiquement si non fourni
if (displayOrder != null) {
media.setDisplayOrder(displayOrder);
} else {
// Déterminer l'ordre d'affichage (dernier média = ordre le plus élevé)
List<EstablishmentMedia> existingMedia = mediaRepository.findByEstablishmentId(establishmentId);
int maxOrder = existingMedia.stream()
.mapToInt(EstablishmentMedia::getDisplayOrder)
.max()
.orElse(-1);
media.setDisplayOrder(maxOrder + 1);
}
mediaRepository.persist(media);
LOG.info("Média uploadé avec succès : " + media.getId());
return media;
}
/**
* Supprime un média d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @param mediaId L'ID du média à supprimer
*/
@Transactional
public void deleteMedia(UUID establishmentId, UUID mediaId) {
LOG.info("Suppression du média " + mediaId + " de l'établissement " + establishmentId);
EstablishmentMedia media = mediaRepository.findById(mediaId);
if (media == null) {
LOG.error("Média non trouvé avec l'ID : " + mediaId);
throw new RuntimeException("Média non trouvé");
}
// Vérifier que le média appartient à l'établissement
if (!media.getEstablishment().getId().equals(establishmentId)) {
LOG.error("Le média " + mediaId + " n'appartient pas à l'établissement " + establishmentId);
throw new RuntimeException("Le média n'appartient pas à cet établissement");
}
mediaRepository.delete(media);
LOG.info("Média supprimé avec succès : " + mediaId);
}
}

View File

@@ -0,0 +1,168 @@
package com.lions.dev.service;
import com.lions.dev.dto.request.establishment.EstablishmentRatingRequestDTO;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.establishment.EstablishmentRating;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.EstablishmentRatingRepository;
import com.lions.dev.repository.EstablishmentRepository;
import com.lions.dev.repository.UsersRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.jboss.logging.Logger;
import java.util.Map;
import java.util.UUID;
/**
* Service de gestion des notations d'établissements.
* Ce service contient la logique métier pour soumettre, modifier et récupérer les notes.
*/
@ApplicationScoped
public class EstablishmentRatingService {
@Inject
EstablishmentRatingRepository ratingRepository;
@Inject
EstablishmentRepository establishmentRepository;
@Inject
UsersRepository usersRepository;
private static final Logger LOG = Logger.getLogger(EstablishmentRatingService.class);
/**
* Soumet une nouvelle note pour un établissement.
*
* @param establishmentId L'ID de l'établissement
* @param userId L'ID de l'utilisateur
* @param requestDTO Le DTO contenant la note et le commentaire
* @return La note créée
*/
@Transactional
public EstablishmentRating submitRating(UUID establishmentId, UUID userId, EstablishmentRatingRequestDTO requestDTO) {
LOG.info("Soumission d'une note pour l'établissement " + establishmentId + " par l'utilisateur " + userId);
// Vérifier que l'établissement existe
Establishment establishment = establishmentRepository.findById(establishmentId);
if (establishment == null) {
LOG.error("Établissement non trouvé avec l'ID : " + establishmentId);
throw new RuntimeException("Établissement non trouvé");
}
// Vérifier que l'utilisateur existe
Users user = usersRepository.findById(userId);
if (user == null) {
LOG.error("Utilisateur non trouvé avec l'ID : " + userId);
throw new RuntimeException("Utilisateur non trouvé");
}
// Vérifier qu'il n'y a pas déjà une note de cet utilisateur
EstablishmentRating existingRating = ratingRepository.findByEstablishmentIdAndUserId(establishmentId, userId);
if (existingRating != null) {
LOG.error("L'utilisateur " + userId + " a déjà noté l'établissement " + establishmentId);
throw new RuntimeException("Vous avez déjà noté cet établissement. Utilisez la mise à jour pour modifier votre note.");
}
// Créer la note
EstablishmentRating rating = new EstablishmentRating(establishment, user, requestDTO.getRating());
rating.setComment(requestDTO.getComment());
ratingRepository.persist(rating);
// Mettre à jour les statistiques de l'établissement
updateEstablishmentRatingStats(establishmentId);
LOG.info("Note soumise avec succès : " + rating.getId());
return rating;
}
/**
* Met à jour une note existante.
*
* @param establishmentId L'ID de l'établissement
* @param userId L'ID de l'utilisateur
* @param requestDTO Le DTO contenant la nouvelle note et le commentaire
* @return La note mise à jour
*/
@Transactional
public EstablishmentRating updateRating(UUID establishmentId, UUID userId, EstablishmentRatingRequestDTO requestDTO) {
LOG.info("Mise à jour de la note pour l'établissement " + establishmentId + " par l'utilisateur " + userId);
// Récupérer la note existante
EstablishmentRating rating = ratingRepository.findByEstablishmentIdAndUserId(establishmentId, userId);
if (rating == null) {
LOG.error("Note non trouvée pour l'établissement " + establishmentId + " et l'utilisateur " + userId);
throw new RuntimeException("Note non trouvée. Utilisez la soumission pour créer une nouvelle note.");
}
// Mettre à jour la note
rating.updateRating(requestDTO.getRating(), requestDTO.getComment());
ratingRepository.persist(rating);
// Mettre à jour les statistiques de l'établissement
updateEstablishmentRatingStats(establishmentId);
LOG.info("Note mise à jour avec succès : " + rating.getId());
return rating;
}
/**
* Récupère la note d'un utilisateur pour un établissement.
*
* @param establishmentId L'ID de l'établissement
* @param userId L'ID de l'utilisateur
* @return La note de l'utilisateur ou null si pas encore noté
*/
public EstablishmentRating getUserRating(UUID establishmentId, UUID userId) {
LOG.info("Récupération de la note de l'utilisateur " + userId + " pour l'établissement " + establishmentId);
return ratingRepository.findByEstablishmentIdAndUserId(establishmentId, userId);
}
/**
* Récupère les statistiques de notation d'un établissement.
*
* @param establishmentId L'ID de l'établissement
* @return Map contenant averageRating, totalRatings et distribution
*/
public Map<String, Object> getRatingStats(UUID establishmentId) {
LOG.info("Récupération des statistiques de notation pour l'établissement " + establishmentId);
Double averageRating = ratingRepository.calculateAverageRating(establishmentId);
Long totalRatings = ratingRepository.countByEstablishmentId(establishmentId);
Map<Integer, Integer> distribution = ratingRepository.calculateRatingDistribution(establishmentId);
return Map.of(
"averageRating", averageRating,
"totalRatingsCount", totalRatings.intValue(),
"ratingDistribution", distribution
);
}
/**
* Met à jour les statistiques de notation d'un établissement.
* Cette méthode est appelée après chaque création/modification de note.
* Note: Cette méthode est appelée depuis des méthodes déjà transactionnelles,
* donc pas besoin de l'annotation @Transactional ici.
*
* @param establishmentId L'ID de l'établissement
*/
private void updateEstablishmentRatingStats(UUID establishmentId) {
Establishment establishment = establishmentRepository.findById(establishmentId);
if (establishment == null) {
return;
}
Double averageRating = ratingRepository.calculateAverageRating(establishmentId);
Long totalRatings = ratingRepository.countByEstablishmentId(establishmentId);
establishment.setAverageRating(averageRating);
// v2.0 - Renommé depuis setTotalRatingsCount
establishment.setTotalReviewsCount(totalRatings.intValue());
establishmentRepository.persist(establishment);
LOG.info("Statistiques mises à jour pour l'établissement " + establishmentId + " : moyenne = " + averageRating + ", total = " + totalRatings);
}
}

View File

@@ -0,0 +1,207 @@
package com.lions.dev.service;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.EstablishmentRepository;
import com.lions.dev.repository.UsersRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.util.List;
import java.util.UUID;
import org.jboss.logging.Logger;
/**
* Service de gestion des établissements.
* Ce service contient la logique métier pour la création, récupération,
* mise à jour et suppression des établissements.
* Seuls les responsables d'établissement peuvent créer et gérer des établissements.
*/
@ApplicationScoped
public class EstablishmentService {
@Inject
EstablishmentRepository establishmentRepository;
@Inject
UsersRepository usersRepository;
private static final Logger LOG = Logger.getLogger(EstablishmentService.class);
/**
* Crée un nouvel établissement.
*
* @param establishment L'établissement à créer.
* @param managerId L'ID du responsable de l'établissement.
* @return L'établissement créé.
*/
@Transactional
public Establishment createEstablishment(Establishment establishment, UUID managerId) {
LOG.info("[LOG] Création d'un nouvel établissement : " + establishment.getName());
// Vérifier que le manager existe
Users manager = usersRepository.findById(managerId);
if (manager == null) {
LOG.error("[ERROR] Responsable non trouvé avec l'ID : " + managerId);
throw new RuntimeException("Responsable non trouvé avec l'ID fourni");
}
// Vérifier que l'utilisateur a le rôle de responsable d'établissement
String role = manager.getRole() != null ? manager.getRole().toUpperCase() : "";
if (!role.equals("ESTABLISHMENT_MANAGER") &&
!role.equals("MANAGER") &&
!role.equals("ADMIN")) {
LOG.error("[ERROR] L'utilisateur " + managerId + " n'a pas les droits pour créer un établissement. Rôle : " + role);
throw new RuntimeException("Seuls les responsables d'établissement peuvent créer des établissements");
}
establishment.setManager(manager);
establishmentRepository.persist(establishment);
LOG.info("[LOG] Établissement créé avec succès : " + establishment.getName());
return establishment;
}
/**
* Récupère tous les établissements.
* Charge également les médias pour chaque établissement pour inclure l'image principale.
*
* @return Une liste de tous les établissements.
*/
public List<Establishment> getAllEstablishments() {
LOG.info("[LOG] Récupération de tous les établissements");
// Utiliser une requête avec fetch join pour charger les médias en une seule requête
List<Establishment> establishments = establishmentRepository.getEntityManager()
.createQuery(
"SELECT DISTINCT e FROM Establishment e " +
"LEFT JOIN FETCH e.medias m " +
"LEFT JOIN FETCH e.manager " +
"ORDER BY e.name ASC",
Establishment.class
)
.getResultList();
LOG.info("[LOG] Nombre d'établissements trouvés : " + establishments.size());
return establishments;
}
/**
* Récupère un établissement par son ID.
*
* @param id L'ID de l'établissement.
* @return L'établissement trouvé.
*/
public Establishment getEstablishmentById(UUID id) {
LOG.info("[LOG] Récupération de l'établissement avec l'ID : " + id);
Establishment establishment = establishmentRepository.findById(id);
if (establishment == null) {
LOG.warn("[WARN] Établissement non trouvé avec l'ID : " + id);
throw new RuntimeException("Établissement non trouvé avec l'ID : " + id);
}
return establishment;
}
/**
* Met à jour un établissement existant.
*
* @param id L'ID de l'établissement à mettre à jour.
* @param updatedEstablishment L'établissement avec les nouvelles données.
* @return L'établissement mis à jour.
*/
@Transactional
public Establishment updateEstablishment(UUID id, Establishment updatedEstablishment) {
LOG.info("[LOG] Mise à jour de l'établissement avec l'ID : " + id);
Establishment establishment = establishmentRepository.findById(id);
if (establishment == null) {
LOG.error("[ERROR] Établissement non trouvé avec l'ID : " + id);
throw new RuntimeException("Établissement non trouvé avec l'ID : " + id);
}
// v2.0 - Mettre à jour les champs
establishment.setName(updatedEstablishment.getName());
establishment.setType(updatedEstablishment.getType());
establishment.setAddress(updatedEstablishment.getAddress());
establishment.setCity(updatedEstablishment.getCity());
establishment.setPostalCode(updatedEstablishment.getPostalCode());
establishment.setDescription(updatedEstablishment.getDescription());
establishment.setPhoneNumber(updatedEstablishment.getPhoneNumber());
establishment.setWebsite(updatedEstablishment.getWebsite());
establishment.setPriceRange(updatedEstablishment.getPriceRange());
// v2.0 - Nouveau champ
if (updatedEstablishment.getVerificationStatus() != null) {
establishment.setVerificationStatus(updatedEstablishment.getVerificationStatus());
}
establishment.setLatitude(updatedEstablishment.getLatitude());
establishment.setLongitude(updatedEstablishment.getLongitude());
// v2.0 - Champs supprimés (email, imageUrl, rating, capacity, amenities, openingHours)
// Ces champs ne sont plus mis à jour car ils sont dépréciés
establishmentRepository.persist(establishment);
LOG.info("[LOG] Établissement mis à jour avec succès : " + establishment.getName());
return establishment;
}
/**
* Supprime un établissement.
*
* @param id L'ID de l'établissement à supprimer.
*/
@Transactional
public void deleteEstablishment(UUID id) {
LOG.info("[LOG] Suppression de l'établissement avec l'ID : " + id);
Establishment establishment = establishmentRepository.findById(id);
if (establishment == null) {
LOG.error("[ERROR] Établissement non trouvé avec l'ID : " + id);
throw new RuntimeException("Établissement non trouvé avec l'ID : " + id);
}
establishmentRepository.delete(establishment);
LOG.info("[LOG] Établissement supprimé avec succès : " + establishment.getName());
}
/**
* Récupère les établissements gérés par un responsable.
*
* @param managerId L'ID du responsable.
* @return Une liste d'établissements gérés par ce responsable.
*/
public List<Establishment> getEstablishmentsByManager(UUID managerId) {
LOG.info("[LOG] Récupération des établissements du responsable : " + managerId);
List<Establishment> establishments = establishmentRepository.findByManagerId(managerId);
LOG.info("[LOG] Nombre d'établissements trouvés : " + establishments.size());
return establishments;
}
/**
* Recherche des établissements par nom ou ville.
*
* @param query Le terme de recherche.
* @return Une liste d'établissements correspondant à la recherche.
*/
public List<Establishment> searchEstablishments(String query) {
LOG.info("[LOG] Recherche d'établissements avec la requête : " + query);
List<Establishment> establishments = establishmentRepository.searchByNameOrCity(query);
LOG.info("[LOG] Nombre d'établissements trouvés : " + establishments.size());
return establishments;
}
/**
* Filtre les établissements par type, fourchette de prix et/ou ville.
*
* @param type Le type d'établissement (optionnel).
* @param priceRange La fourchette de prix (optionnel).
* @param city La ville (optionnel).
* @return Une liste d'établissements correspondant aux filtres.
*/
public List<Establishment> filterEstablishments(String type, String priceRange, String city) {
LOG.info("[LOG] Filtrage des établissements : type=" + type + ", priceRange=" + priceRange + ", city=" + city);
List<Establishment> allEstablishments = establishmentRepository.listAll();
return allEstablishments.stream()
.filter(e -> type == null || e.getType().equalsIgnoreCase(type))
.filter(e -> priceRange == null || (e.getPriceRange() != null && e.getPriceRange().equalsIgnoreCase(priceRange)))
.filter(e -> city == null || e.getCity().equalsIgnoreCase(city))
.toList();
}
}

View File

@@ -12,6 +12,10 @@ import com.lions.dev.exception.UserNotFoundException;
import com.lions.dev.repository.EventsRepository;
import com.lions.dev.repository.FriendshipRepository;
import com.lions.dev.repository.UsersRepository;
import com.lions.dev.repository.EstablishmentRepository;
import com.lions.dev.dto.events.NotificationEvent;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.time.LocalDateTime;
@@ -21,6 +25,10 @@ import java.util.stream.Collectors;
/**
* Service de gestion des événements.
*
* Version 2.0 - Architecture refactorée avec nommage standardisé.
* Conforme à l'architecture de données AfterWork v2.0 (Ultra-Compétitive).
*
* Ce service contient la logique métier pour la création, récupération, mise à jour et suppression des événements.
* Chaque méthode est loguée pour assurer une traçabilité exhaustive des actions effectuées.
*/
@@ -36,13 +44,20 @@ public class EventService {
@Inject
UsersRepository usersRepository;
@Inject
EstablishmentRepository establishmentRepository; // v2.0
@Inject
NotificationService notificationService;
@Inject
@Channel("notifications")
Emitter<NotificationEvent> notificationEmitter; // v2.0 - Publie dans Kafka
private static final Logger logger = LoggerFactory.getLogger(EventService.class);
/**
* Crée un nouvel événement dans le système.
* Crée un nouvel événement dans le système (v2.0).
*
* @param eventCreateRequestDTO Le DTO contenant les informations de l'événement à créer.
* @param creator L'utilisateur créateur de l'événement.
@@ -55,21 +70,52 @@ public class EventService {
event.setDescription(eventCreateRequestDTO.getDescription());
event.setStartDate(eventCreateRequestDTO.getStartDate());
event.setEndDate(eventCreateRequestDTO.getEndDate());
event.setLocation(eventCreateRequestDTO.getLocation());
// v2.0 - Établissement au lieu de location
if (eventCreateRequestDTO.getEstablishmentId() != null) {
com.lions.dev.entity.establishment.Establishment establishment =
establishmentRepository.findById(eventCreateRequestDTO.getEstablishmentId());
if (establishment != null) {
event.setEstablishment(establishment);
} else {
logger.warn("[WARN] Établissement non trouvé avec l'ID : {}", eventCreateRequestDTO.getEstablishmentId());
}
}
event.setCategory(eventCreateRequestDTO.getCategory());
event.setLink(eventCreateRequestDTO.getLink());
event.setImageUrl(eventCreateRequestDTO.getImageUrl());
event.setMaxParticipants(eventCreateRequestDTO.getMaxParticipants());
event.setTags(eventCreateRequestDTO.getTags());
event.setOrganizer(eventCreateRequestDTO.getOrganizer());
event.setParticipationFee(eventCreateRequestDTO.getParticipationFee());
// v2.0 - Nouveaux champs
if (eventCreateRequestDTO.getIsPrivate() != null) {
event.setIsPrivate(eventCreateRequestDTO.getIsPrivate());
}
if (eventCreateRequestDTO.getWaitlistEnabled() != null) {
event.setWaitlistEnabled(eventCreateRequestDTO.getWaitlistEnabled());
}
event.setPrivacyRules(eventCreateRequestDTO.getPrivacyRules());
event.setTransportInfo(eventCreateRequestDTO.getTransportInfo());
event.setAccommodationInfo(eventCreateRequestDTO.getAccommodationInfo());
event.setAccessibilityInfo(eventCreateRequestDTO.getAccessibilityInfo());
event.setParkingInfo(eventCreateRequestDTO.getParkingInfo());
event.setSecurityProtocol(eventCreateRequestDTO.getSecurityProtocol());
event.setCreator(creator);
event.setStatus("ouvert");
event.setStatus("OPEN"); // v2.0 - Statut standardisé
// Persiste l'événement dans la base de données
eventsRepository.persist(event);
logger.info("[logger] Événement créé avec succès : {}", event.getTitle());
// Créer des notifications pour tous les amis
// Créer des notifications pour tous les amis (v2.0 - avec Kafka)
try {
List<Friendship> friendships = friendshipRepository.findFriendsByUser(creator, 0, Integer.MAX_VALUE);
String creatorName = creator.getPrenoms() + " " + creator.getNom();
// v2.0 - Utiliser les nouveaux noms de champs
String creatorName = creator.getFirstName() + " " + creator.getLastName();
for (Friendship friendship : friendships) {
Users friend = friendship.getUser().equals(creator)
@@ -79,6 +125,7 @@ public class EventService {
String notificationTitle = "Nouvel événement de " + creatorName;
String notificationMessage = creatorName + " a créé un nouvel événement : " + event.getTitle();
// Créer notification en base
notificationService.createNotification(
notificationTitle,
notificationMessage,
@@ -86,6 +133,28 @@ public class EventService {
friend.getId(),
event.getId()
);
// TEMPS RÉEL: Publier dans Kafka (v2.0)
try {
java.util.Map<String, Object> notificationData = new java.util.HashMap<>();
notificationData.put("eventId", event.getId().toString());
notificationData.put("eventTitle", event.getTitle());
notificationData.put("creatorId", creator.getId().toString());
notificationData.put("creatorName", creatorName);
notificationData.put("startDate", event.getStartDate().toString());
NotificationEvent kafkaEvent = new NotificationEvent(
friend.getId().toString(), // userId destinataire
"event_created",
notificationData
);
notificationEmitter.send(kafkaEvent);
logger.debug("[logger] Événement event_created publié dans Kafka pour: {}", friend.getId());
} catch (Exception kafkaEx) {
logger.error("[ERROR] Erreur publication Kafka: {}", kafkaEx.getMessage());
// Ne pas bloquer si Kafka échoue
}
}
logger.info("[logger] Notifications créées pour {} ami(s)", friendships.size());
} catch (Exception e) {
@@ -132,33 +201,60 @@ public class EventService {
* Supprime un événement par son ID.
*
* @param id L'ID de l'événement à supprimer.
* @param userId L'ID de l'utilisateur qui tente de supprimer l'événement.
* @return true si l'événement a été supprimé, false sinon.
* @throws EventNotFoundException Si l'événement n'est pas trouvé.
* @throws SecurityException Si l'utilisateur n'est pas le créateur de l'événement.
*/
@Transactional
public boolean deleteEvent(UUID id) {
logger.info("[logger] Tentative de suppression de l'événement avec l'ID : {}", id);
public boolean deleteEvent(UUID id, UUID userId) {
logger.info("[logger] Tentative de suppression de l'événement avec l'ID : {} par l'utilisateur : {}", id, userId);
Events event = eventsRepository.findById(id);
if (event == null) {
logger.warn("[logger] Échec de la suppression : événement avec l'ID {} introuvable.", id);
throw new EventNotFoundException(id);
}
// Vérifier que l'utilisateur est le créateur
if (!canModifyEvent(event, userId)) {
logger.error("[ERROR] L'utilisateur {} n'a pas les permissions pour supprimer l'événement {}", userId, id);
throw new SecurityException("Vous n'avez pas les permissions pour supprimer cet événement");
}
boolean deleted = eventsRepository.deleteById(id);
if (deleted) {
logger.info("[logger] Événement avec l'ID {} supprimé avec succès.", id);
} else {
logger.warn("[logger] Échec de la suppression : événement avec l'ID {} introuvable.", id);
throw new EventNotFoundException(id);
}
return deleted;
}
/**
* Vérifie si un utilisateur peut modifier un événement.
*
* @param event L'événement à vérifier.
* @param userId L'ID de l'utilisateur.
* @return true si l'utilisateur peut modifier l'événement, false sinon.
*/
public boolean canModifyEvent(Events event, UUID userId) {
if (event == null || event.getCreator() == null) {
return false;
}
return event.getCreator().getId().equals(userId);
}
/**
* Met à jour un événement dans le système.
*
* @param event L'événement contenant les détails mis à jour.
* @param userId L'ID de l'utilisateur qui tente de mettre à jour l'événement.
* @return L'événement mis à jour.
* @throws EventNotFoundException Si l'événement n'est pas trouvé.
* @throws SecurityException Si l'utilisateur n'est pas le créateur de l'événement.
*/
@Transactional
public Events updateEvent(Events event) {
logger.info("[logger] Tentative de mise à jour de l'événement avec l'ID : {}", event.getId());
public Events updateEvent(Events event, UUID userId) {
logger.info("[logger] Tentative de mise à jour de l'événement avec l'ID : {} par l'utilisateur : {}", event.getId(), userId);
Events existingEvent = eventsRepository.findById(event.getId());
if (existingEvent == null) {
@@ -166,15 +262,45 @@ public class EventService {
throw new EventNotFoundException(event.getId());
}
// Mettre à jour les détails de l'événement
// Vérifier que l'utilisateur est le créateur
if (!canModifyEvent(existingEvent, userId)) {
logger.error("[ERROR] L'utilisateur {} n'a pas les permissions pour modifier l'événement {}", userId, event.getId());
throw new SecurityException("Vous n'avez pas les permissions pour modifier cet événement");
}
// v2.0 - Mettre à jour les détails de l'événement
existingEvent.setTitle(event.getTitle());
existingEvent.setDescription(event.getDescription());
existingEvent.setStartDate(event.getStartDate());
existingEvent.setEndDate(event.getEndDate());
existingEvent.setLocation(event.getLocation());
// v2.0 - Établissement au lieu de location
if (event.getEstablishment() != null) {
existingEvent.setEstablishment(event.getEstablishment());
}
existingEvent.setCategory(event.getCategory());
existingEvent.setLink(event.getLink());
existingEvent.setImageUrl(event.getImageUrl());
existingEvent.setMaxParticipants(event.getMaxParticipants());
existingEvent.setTags(event.getTags());
existingEvent.setOrganizer(event.getOrganizer());
existingEvent.setParticipationFee(event.getParticipationFee());
// v2.0 - Nouveaux champs
if (event.getIsPrivate() != null) {
existingEvent.setIsPrivate(event.getIsPrivate());
}
if (event.getWaitlistEnabled() != null) {
existingEvent.setWaitlistEnabled(event.getWaitlistEnabled());
}
existingEvent.setPrivacyRules(event.getPrivacyRules());
existingEvent.setTransportInfo(event.getTransportInfo());
existingEvent.setAccommodationInfo(event.getAccommodationInfo());
existingEvent.setAccessibilityInfo(event.getAccessibilityInfo());
existingEvent.setParkingInfo(event.getParkingInfo());
existingEvent.setSecurityProtocol(event.getSecurityProtocol());
existingEvent.setStatus(event.getStatus());
// Persiste les modifications dans la base de données
@@ -216,8 +342,22 @@ public class EventService {
* @return La liste des événements auxquels l'utilisateur participe.
*/
public List<Events> findEventsByUser(Users user) {
logger.info("[logger] Récupération des événements pour l'utilisateur avec l'ID : {}", user.getId());
List<Events> events = eventsRepository.find("participants", user).list();
return findEventsByUser(user, 0, Integer.MAX_VALUE);
}
/**
* Récupère les événements auxquels un utilisateur participe avec pagination.
*
* @param user L'utilisateur pour lequel récupérer les événements.
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return La liste paginée des événements auxquels l'utilisateur participe.
*/
public List<Events> findEventsByUser(Users user, int page, int size) {
logger.info("[logger] Récupération des événements pour l'utilisateur avec l'ID : {} (page: {}, size: {})", user.getId(), page, size);
List<Events> events = eventsRepository.find("participants", user)
.page(page, size)
.list();
logger.info("[logger] Nombre d'événements pour l'utilisateur avec l'ID {} : {}", user.getId(), events.size());
return events;
}
@@ -319,7 +459,11 @@ public class EventService {
*/
public List<Events> recommendEventsForUser(Users user) {
logger.info("[logger] Recommandation d'événements pour l'utilisateur : " + user.getEmail());
List<Events> events = eventsRepository.find("category", user.getPreferredCategory()).list();
// v2.0 - Utiliser preferences pour preferredCategory
String preferredCategory = user.getPreferredCategory(); // Méthode qui utilise preferences JSONB
List<Events> events = preferredCategory != null
? eventsRepository.find("category", preferredCategory).list()
: eventsRepository.findAll().list();
logger.info("[logger] Nombre d'événements recommandés pour l'utilisateur : " + events.size());
return events;
}

View File

@@ -13,7 +13,9 @@ import com.lions.dev.exception.FriendshipNotFoundException;
import com.lions.dev.exception.UserNotFoundException;
import com.lions.dev.repository.FriendshipRepository;
import com.lions.dev.repository.UsersRepository;
import com.lions.dev.websocket.NotificationWebSocket;
import com.lions.dev.dto.events.NotificationEvent;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@@ -40,6 +42,10 @@ public class FriendshipService {
@Inject
NotificationService notificationService; // Injecte le service de notifications
@Inject
@Channel("notifications")
Emitter<NotificationEvent> notificationEmitter; // v2.0 - Publie dans Kafka
private static final Logger logger = Logger.getLogger(FriendshipService.class);
/**
@@ -79,24 +85,26 @@ public class FriendshipService {
Friendship friendship = new Friendship(user, friend, FriendshipStatus.PENDING);
friendshipRepository.persist(friendship);
// TEMPS RÉEL: Notifier le destinataire via WebSocket
// TEMPS RÉEL: Publier dans Kafka (v2.0)
try {
Map<String, Object> notificationData = new HashMap<>();
notificationData.put("requestId", friendship.getId().toString());
notificationData.put("senderId", user.getId().toString());
notificationData.put("senderName", user.getPrenoms() + " " + user.getNom());
// v2.0 - Utiliser les nouveaux noms de champs
notificationData.put("senderName", user.getFirstName() + " " + user.getLastName());
notificationData.put("senderProfileImage", user.getProfileImageUrl() != null ? user.getProfileImageUrl() : "");
NotificationWebSocket.sendNotificationToUser(
friend.getId(),
NotificationEvent event = new NotificationEvent(
friend.getId().toString(), // userId destinataire (clé Kafka)
"friend_request_received",
notificationData
);
logger.info("[LOG] Notification WebSocket envoyée au destinataire : " + friend.getId());
notificationEmitter.send(event);
logger.info("[LOG] Événement friend_request_received publié dans Kafka pour : " + friend.getId());
} catch (Exception e) {
logger.error("[ERROR] Erreur lors de l'envoi de la notification WebSocket : " + e.getMessage(), e);
// Ne pas bloquer la demande d'amitié si le WebSocket échoue
logger.error("[ERROR] Erreur lors de la publication dans Kafka : " + e.getMessage(), e);
// Ne pas bloquer la demande d'amitié si Kafka échoue
}
logger.info("[LOG] Demande d'amitié envoyée avec succès.");
@@ -139,11 +147,12 @@ public class FriendshipService {
// Log de succès
logger.info(String.format("[LOG] Demande d'amitié acceptée avec succès pour l'ID: %s", friendshipId)); // Correctement formaté
// TEMPS RÉEL: Notifier l'émetteur de la demande via WebSocket
// TEMPS RÉEL: Publier dans Kafka (v2.0)
try {
Users user = friendship.getUser();
Users friend = friendship.getFriend();
String friendName = friend.getPrenoms() + " " + friend.getNom();
// v2.0 - Utiliser les nouveaux noms de champs
String friendName = friend.getFirstName() + " " + friend.getLastName();
Map<String, Object> notificationData = new HashMap<>();
notificationData.put("acceptedBy", friendName);
@@ -151,24 +160,26 @@ public class FriendshipService {
notificationData.put("accepterId", friend.getId().toString());
notificationData.put("accepterProfileImage", friend.getProfileImageUrl() != null ? friend.getProfileImageUrl() : "");
NotificationWebSocket.sendNotificationToUser(
user.getId(),
NotificationEvent event = new NotificationEvent(
user.getId().toString(), // userId émetteur (destinataire de la notification)
"friend_request_accepted",
notificationData
);
logger.info("[LOG] Notification WebSocket d'acceptation envoyée à : " + user.getId());
notificationEmitter.send(event);
logger.info("[LOG] Événement friend_request_accepted publié dans Kafka pour : " + user.getId());
} catch (Exception e) {
logger.error("[ERROR] Erreur lors de l'envoi de la notification WebSocket d'acceptation : " + e.getMessage(), e);
// Ne pas bloquer l'acceptation si le WebSocket échoue
logger.error("[ERROR] Erreur lors de la publication dans Kafka : " + e.getMessage(), e);
// Ne pas bloquer l'acceptation si Kafka échoue
}
// Créer des notifications pour les deux utilisateurs
try {
Users user = friendship.getUser();
Users friend = friendship.getFriend();
String userName = user.getPrenoms() + " " + user.getNom();
String friendName = friend.getPrenoms() + " " + friend.getNom();
// v2.0 - Utiliser les nouveaux noms de champs
String userName = user.getFirstName() + " " + user.getLastName();
String friendName = friend.getFirstName() + " " + friend.getLastName();
// Notification pour l'utilisateur qui a envoyé la demande
notificationService.createNotification(
@@ -212,7 +223,7 @@ public class FriendshipService {
friendship.setStatus(FriendshipStatus.REJECTED);
friendshipRepository.persist(friendship);
// TEMPS RÉEL: Notifier l'émetteur de la demande via WebSocket (optionnel selon UX)
// TEMPS RÉEL: Publier dans Kafka (v2.0)
try {
Users user = friendship.getUser();
@@ -220,16 +231,17 @@ public class FriendshipService {
notificationData.put("friendshipId", friendshipId.toString());
notificationData.put("rejectedAt", System.currentTimeMillis());
NotificationWebSocket.sendNotificationToUser(
user.getId(),
NotificationEvent event = new NotificationEvent(
user.getId().toString(), // userId émetteur (destinataire de la notification)
"friend_request_rejected",
notificationData
);
logger.info("[LOG] Notification WebSocket de rejet envoyée à : " + user.getId());
notificationEmitter.send(event);
logger.info("[LOG] Événement friend_request_rejected publié dans Kafka pour : " + user.getId());
} catch (Exception e) {
logger.error("[ERROR] Erreur lors de l'envoi de la notification WebSocket de rejet : " + e.getMessage(), e);
// Ne pas bloquer le rejet si le WebSocket échoue
logger.error("[ERROR] Erreur lors de la publication dans Kafka : " + e.getMessage(), e);
// Ne pas bloquer le rejet si Kafka échoue
}
logger.info("[LOG] Demande d'amitié rejetée.");
@@ -288,11 +300,12 @@ public class FriendshipService {
+ user.getId() + ", ami ID : " + friend.getId());
// Création du DTO de réponse à partir des informations de l'ami et de la relation
// v2.0 - Utiliser les nouveaux noms de champs
return new FriendshipReadFriendDetailsResponseDTO(
user.getId(), // ID de l'utilisateur
friend.getId(), // ID de l'ami
friend.getNom(), // Nom de l'ami
friend.getPrenoms(),
friend.getLastName(), // Nom de famille de l'ami (v2.0)
friend.getFirstName(), // Prénom de l'ami (v2.0)
friend.getEmail(), // Email de l'ami
friend.getProfileImageUrl(), // URL de l'image de profil de l'ami
friendship.getStatus(), // Statut de la relation
@@ -325,11 +338,12 @@ public class FriendshipService {
return friendships.stream()
.map(friendship -> {
Users friend = friendship.getUser().equals(user) ? friendship.getFriend() : friendship.getUser();
// v2.0 - Utiliser les nouveaux noms de champs
return new FriendshipReadFriendDetailsResponseDTO(
user.getId(),
friend.getId(),
friend.getNom(),
friend.getPrenoms(),
friend.getLastName(), // v2.0
friend.getFirstName(), // v2.0
friend.getEmail(),
friend.getProfileImageUrl(),
friendship.getStatus(),

View File

@@ -7,7 +7,10 @@ import com.lions.dev.exception.UserNotFoundException;
import com.lions.dev.repository.ConversationRepository;
import com.lions.dev.repository.MessageRepository;
import com.lions.dev.repository.UsersRepository;
import com.lions.dev.websocket.ChatWebSocket;
import com.lions.dev.dto.events.ChatMessageEvent;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import io.smallrye.reactive.messaging.kafka.api.OutgoingKafkaRecordMetadata;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@@ -40,6 +43,10 @@ public class MessageService {
@Inject
NotificationService notificationService;
@Inject
@Channel("chat-messages")
Emitter<ChatMessageEvent> chatMessageEmitter; // v2.0 - Publie dans Kafka
/**
* Envoie un message d'un utilisateur à un autre.
*
@@ -92,7 +99,8 @@ public class MessageService {
// Créer une notification pour le destinataire
try {
String senderName = sender.getPrenoms() + " " + sender.getNom();
// v2.0 - Utiliser les nouveaux noms de champs
String senderName = sender.getFirstName() + " " + sender.getLastName();
String notificationMessage = content.length() > 50
? content.substring(0, 50) + "..."
: content;
@@ -109,43 +117,78 @@ public class MessageService {
System.out.println("[ERROR] Erreur lors de la création de la notification : " + e.getMessage());
}
// TEMPS RÉEL : Envoyer le message via WebSocket au destinataire
// TEMPS RÉEL : Publier dans Kafka (v2.0)
try {
Map<String, Object> messageData = new HashMap<>();
messageData.put("id", message.getId().toString());
messageData.put("conversationId", conversation.getId().toString());
messageData.put("senderId", senderId.toString());
messageData.put("senderFirstName", sender.getPrenoms());
messageData.put("senderLastName", sender.getNom());
messageData.put("senderProfileImageUrl", sender.getProfileImageUrl() != null ? sender.getProfileImageUrl() : "");
messageData.put("content", content);
messageData.put("timestamp", message.getCreatedAt().toString());
messageData.put("isRead", message.isRead());
messageData.put("attachmentUrl", mediaUrl != null ? mediaUrl : "");
messageData.put("attachmentType", messageType != null ? messageType : "text");
// Créer l'événement pour Kafka
ChatMessageEvent event = new ChatMessageEvent();
event.setConversationId(conversation.getId().toString());
event.setSenderId(senderId.toString());
event.setRecipientId(recipientId.toString());
event.setContent(content);
event.setMessageId(message.getId().toString());
event.setEventType("message");
event.setTimestamp(System.currentTimeMillis());
// Métadonnées additionnelles
Map<String, Object> eventMetadata = new HashMap<>();
eventMetadata.put("senderFirstName", sender.getFirstName());
eventMetadata.put("senderLastName", sender.getLastName());
eventMetadata.put("senderProfileImageUrl", sender.getProfileImageUrl() != null ? sender.getProfileImageUrl() : "");
eventMetadata.put("isRead", message.isRead());
eventMetadata.put("attachmentUrl", mediaUrl != null ? mediaUrl : "");
eventMetadata.put("attachmentType", messageType != null ? messageType : "text");
event.setMetadata(eventMetadata);
// Envoyer au destinataire via ChatWebSocket
ChatWebSocket.sendMessageToUser(recipientId, messageData);
// Publier dans Kafka (utiliser conversationId comme clé pour garantir l'ordre)
OutgoingKafkaRecordMetadata kafkaMetadata =
OutgoingKafkaRecordMetadata.builder()
.withKey(conversation.getId().toString())
.build();
chatMessageEmitter.send(org.eclipse.microprofile.reactive.messaging.Message.of(
event,
() -> java.util.concurrent.CompletableFuture.completedFuture(null), // ack
throwable -> {
System.out.println("[ERROR] Erreur envoi Kafka: " + throwable.getMessage());
return java.util.concurrent.CompletableFuture.completedFuture(null); // nack
}
).addMetadata(kafkaMetadata));
System.out.println("[LOG] Message envoyé via WebSocket au destinataire : " + recipientId);
System.out.println("[LOG] Message publié dans Kafka: " + message.getId());
// Envoyer confirmation de délivrance à l'expéditeur
// Envoyer confirmation de délivrance à l'expéditeur (via Kafka aussi)
try {
Map<String, Object> deliveryConfirmation = new HashMap<>();
deliveryConfirmation.put("messageId", message.getId().toString());
deliveryConfirmation.put("isDelivered", true);
deliveryConfirmation.put("timestamp", System.currentTimeMillis());
ChatMessageEvent deliveryEvent = new ChatMessageEvent();
deliveryEvent.setConversationId(conversation.getId().toString());
deliveryEvent.setSenderId(recipientId.toString()); // Le destinataire confirme
deliveryEvent.setRecipientId(senderId.toString()); // À l'expéditeur
deliveryEvent.setMessageId(message.getId().toString());
deliveryEvent.setEventType("delivery_confirmation");
deliveryEvent.setTimestamp(System.currentTimeMillis());
Map<String, Object> deliveryEventMetadata = new HashMap<>();
deliveryEventMetadata.put("isDelivered", true);
deliveryEvent.setMetadata(deliveryEventMetadata);
ChatWebSocket.sendDeliveryConfirmation(senderId, deliveryConfirmation);
OutgoingKafkaRecordMetadata deliveryKafkaMetadata =
OutgoingKafkaRecordMetadata.builder()
.withKey(conversation.getId().toString())
.build();
chatMessageEmitter.send(org.eclipse.microprofile.reactive.messaging.Message.of(
deliveryEvent,
() -> java.util.concurrent.CompletableFuture.completedFuture(null),
throwable -> java.util.concurrent.CompletableFuture.completedFuture(null)
).addMetadata(deliveryKafkaMetadata));
System.out.println("[LOG] Confirmation de délivrance envoyée à l'expéditeur : " + senderId);
System.out.println("[LOG] Confirmation de délivrance publiée dans Kafka pour : " + senderId);
} catch (Exception deliveryEx) {
System.out.println("[ERROR] Erreur envoi confirmation délivrance : " + deliveryEx.getMessage());
System.out.println("[ERROR] Erreur publication confirmation délivrance : " + deliveryEx.getMessage());
// Ne pas bloquer si la confirmation échoue
}
} catch (Exception e) {
System.out.println("[ERROR] Erreur lors de l'envoi du message via WebSocket : " + e.getMessage());
// Ne pas bloquer l'envoi du message si WebSocket échoue
System.out.println("[ERROR] Erreur lors de la publication dans Kafka : " + e.getMessage());
// Ne pas bloquer l'envoi du message si Kafka échoue
}
return message;
@@ -240,18 +283,36 @@ public class MessageService {
? conversation.getUser2().getId()
: conversation.getUser1().getId();
Map<String, Object> readConfirmation = new HashMap<>();
readConfirmation.put("messageId", message.getId().toString());
readConfirmation.put("userId", recipientId.toString());
readConfirmation.put("timestamp", java.time.LocalDateTime.now().toString());
// Publier confirmation de lecture dans Kafka (v2.0)
try {
ChatMessageEvent readEvent = new ChatMessageEvent();
readEvent.setConversationId(conversation.getId().toString());
readEvent.setSenderId(recipientId.toString()); // Celui qui a lu
readEvent.setRecipientId(message.getSender().getId().toString()); // L'expéditeur
readEvent.setMessageId(message.getId().toString());
readEvent.setEventType("read_confirmation");
readEvent.setTimestamp(System.currentTimeMillis());
Map<String, Object> readEventMetadata = new HashMap<>();
readEventMetadata.put("readBy", recipientId.toString());
readEventMetadata.put("readAt", System.currentTimeMillis());
readEvent.setMetadata(readEventMetadata);
// Envoyer via ChatWebSocket avec type "read"
com.lions.dev.websocket.ChatWebSocket.sendReadConfirmation(
message.getSender().getId(),
readConfirmation
);
OutgoingKafkaRecordMetadata readKafkaMetadata =
OutgoingKafkaRecordMetadata.builder()
.withKey(conversation.getId().toString())
.build();
chatMessageEmitter.send(org.eclipse.microprofile.reactive.messaging.Message.of(
readEvent,
() -> java.util.concurrent.CompletableFuture.completedFuture(null),
throwable -> java.util.concurrent.CompletableFuture.completedFuture(null)
).addMetadata(readKafkaMetadata));
System.out.println("[LOG] Confirmation de lecture envoyée à l'expéditeur : " + message.getSender().getId());
System.out.println("[LOG] Confirmation de lecture publiée dans Kafka pour : " + message.getSender().getId());
} catch (Exception e) {
System.out.println("[ERROR] Erreur publication confirmation lecture : " + e.getMessage());
}
} catch (Exception e) {
System.out.println("[ERROR] Erreur envoi confirmation lecture : " + e.getMessage());
}

View File

@@ -1,13 +1,18 @@
package com.lions.dev.service;
import com.lions.dev.dto.events.PresenceEvent;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.UsersRepository;
import com.lions.dev.websocket.NotificationWebSocket;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.control.ActivateRequestContext;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
/**
@@ -16,7 +21,10 @@ import java.util.*;
* Ce service gère:
* - Le marquage des utilisateurs comme en ligne/hors ligne
* - Le heartbeat pour maintenir le statut online
* - La diffusion de la présence aux amis via WebSocket
* - La diffusion de la présence aux amis via Kafka → WebSocket
*
* Architecture v2.0:
* PresenceService → Kafka Topic (presence.updates) → PresenceKafkaBridge → WebSocket → Client
*/
@ApplicationScoped
public class PresenceService {
@@ -24,6 +32,10 @@ public class PresenceService {
@Inject
UsersRepository usersRepository;
@Inject
@Channel("presence")
Emitter<PresenceEvent> presenceEmitter; // v2.0 - Publie dans Kafka
/**
* Marque un utilisateur comme en ligne et broadcast sa présence.
*
@@ -64,9 +76,11 @@ public class PresenceService {
/**
* Met à jour le heartbeat d'un utilisateur (keep-alive).
* Cette méthode est appelée depuis un thread worker, donc elle doit activer le contexte de requête.
*
* @param userId L'ID de l'utilisateur
*/
@ActivateRequestContext
@Transactional
public void heartbeat(UUID userId) {
Users user = usersRepository.findById(userId);
@@ -78,7 +92,8 @@ public class PresenceService {
}
/**
* Broadcast la présence d'un utilisateur à tous les utilisateurs connectés via WebSocket.
* Broadcast la présence d'un utilisateur via Kafka (v2.0).
* Le PresenceKafkaBridge consommera depuis Kafka et enverra via WebSocket.
*
* @param userId L'ID de l'utilisateur
* @param isOnline Le statut online
@@ -86,18 +101,25 @@ public class PresenceService {
*/
private void broadcastPresenceToAll(UUID userId, boolean isOnline, LocalDateTime lastSeen) {
try {
Map<String, Object> presenceData = new HashMap<>();
presenceData.put("userId", userId.toString());
presenceData.put("isOnline", isOnline);
presenceData.put("lastSeen", lastSeen != null ? lastSeen.toString() : null);
presenceData.put("timestamp", System.currentTimeMillis());
// Envoyer via NotificationWebSocket
NotificationWebSocket.broadcastPresenceUpdate(presenceData);
System.out.println("[PRESENCE] Broadcast de la présence de " + userId + " : " + isOnline);
// Convertir LocalDateTime en timestamp (milliseconds)
Long lastSeenTimestamp = lastSeen != null
? lastSeen.toInstant(ZoneOffset.UTC).toEpochMilli()
: null;
// Créer l'événement de présence
PresenceEvent presenceEvent = new PresenceEvent(
userId.toString(),
isOnline ? "online" : "offline",
lastSeenTimestamp
);
// Publier dans Kafka (le bridge s'occupera de l'envoi WebSocket)
presenceEmitter.send(presenceEvent);
Log.debug("[PRESENCE] Événement de présence publié dans Kafka: " + userId + " -> " +
(isOnline ? "online" : "offline"));
} catch (Exception e) {
System.out.println("[ERROR] Erreur lors du broadcast de présence : " + e.getMessage());
Log.error("[PRESENCE] Erreur lors de la publication de présence dans Kafka", e);
}
}

View File

@@ -8,10 +8,15 @@ import com.lions.dev.exception.UserNotFoundException;
import com.lions.dev.repository.FriendshipRepository;
import com.lions.dev.repository.SocialPostRepository;
import com.lions.dev.repository.UsersRepository;
import com.lions.dev.dto.events.ReactionEvent;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -38,6 +43,10 @@ public class SocialPostService {
@Inject
NotificationService notificationService;
@Inject
@Channel("reactions")
Emitter<ReactionEvent> reactionEmitter; // v2.0 - Publie dans Kafka
/**
* Récupère tous les posts avec pagination.
*
@@ -99,7 +108,8 @@ public class SocialPostService {
// Créer des notifications pour tous les amis
try {
List<Friendship> friendships = friendshipRepository.findFriendsByUser(user, 0, Integer.MAX_VALUE);
String userName = user.getPrenoms() + " " + user.getNom();
// v2.0 - Utiliser les nouveaux noms de champs
String userName = user.getFirstName() + " " + user.getLastName();
for (Friendship friendship : friendships) {
Users friend = friendship.getUser().equals(user)
@@ -208,11 +218,12 @@ public class SocialPostService {
* Like un post (incrémente le compteur de likes).
*
* @param postId L'ID du post
* @param userId L'ID de l'utilisateur qui like (v2.0)
* @return Le post mis à jour
*/
@Transactional
public SocialPost likePost(UUID postId) {
System.out.println("[LOG] Like du post ID : " + postId);
public SocialPost likePost(UUID postId, UUID userId) {
System.out.println("[LOG] Like du post ID : " + postId + " par utilisateur : " + userId);
SocialPost post = socialPostRepository.findById(postId);
if (post == null) {
@@ -222,6 +233,31 @@ public class SocialPostService {
post.incrementLikes();
socialPostRepository.persist(post);
// TEMPS RÉEL: Publier dans Kafka (v2.0)
try {
Map<String, Object> reactionData = new HashMap<>();
reactionData.put("ownerId", post.getUser().getId().toString()); // Propriétaire du post
reactionData.put("likesCount", post.getLikesCount());
reactionData.put("postTitle", post.getContent().length() > 50
? post.getContent().substring(0, 50) + "..."
: post.getContent());
ReactionEvent event = new ReactionEvent(
postId.toString(), // targetId
"post", // targetType
userId.toString(), // userId qui réagit
"like", // reactionType
reactionData
);
reactionEmitter.send(event);
System.out.println("[LOG] Réaction like publiée dans Kafka pour post: " + postId);
} catch (Exception e) {
System.out.println("[ERROR] Erreur publication Kafka: " + e.getMessage());
// Ne pas bloquer le like si Kafka échoue
}
return post;
}
@@ -229,11 +265,13 @@ public class SocialPostService {
* Ajoute un commentaire à un post (incrémente le compteur de commentaires).
*
* @param postId L'ID du post
* @param userId L'ID de l'utilisateur qui commente (v2.0)
* @param commentContent Le contenu du commentaire (v2.0)
* @return Le post mis à jour
*/
@Transactional
public SocialPost addComment(UUID postId) {
System.out.println("[LOG] Ajout de commentaire au post ID : " + postId);
public SocialPost addComment(UUID postId, UUID userId, String commentContent) {
System.out.println("[LOG] Ajout de commentaire au post ID : " + postId + " par utilisateur : " + userId);
SocialPost post = socialPostRepository.findById(postId);
if (post == null) {
@@ -243,6 +281,35 @@ public class SocialPostService {
post.incrementComments();
socialPostRepository.persist(post);
// TEMPS RÉEL: Publier dans Kafka (v2.0)
try {
Users commenter = usersRepository.findById(userId);
Map<String, Object> reactionData = new HashMap<>();
reactionData.put("ownerId", post.getUser().getId().toString()); // Propriétaire du post
reactionData.put("commentsCount", post.getCommentsCount());
reactionData.put("commentContent", commentContent != null && commentContent.length() > 100
? commentContent.substring(0, 100) + "..."
: commentContent);
reactionData.put("commenterName", commenter != null
? commenter.getFirstName() + " " + commenter.getLastName()
: "Utilisateur");
ReactionEvent event = new ReactionEvent(
postId.toString(), // targetId
"post", // targetType
userId.toString(), // userId qui commente
"comment", // reactionType
reactionData
);
reactionEmitter.send(event);
System.out.println("[LOG] Réaction comment publiée dans Kafka pour post: " + postId);
} catch (Exception e) {
System.out.println("[ERROR] Erreur publication Kafka: " + e.getMessage());
// Ne pas bloquer le commentaire si Kafka échoue
}
return post;
}
@@ -250,11 +317,12 @@ public class SocialPostService {
* Partage un post (incrémente le compteur de partages).
*
* @param postId L'ID du post
* @param userId L'ID de l'utilisateur qui partage (v2.0)
* @return Le post mis à jour
*/
@Transactional
public SocialPost sharePost(UUID postId) {
System.out.println("[LOG] Partage du post ID : " + postId);
public SocialPost sharePost(UUID postId, UUID userId) {
System.out.println("[LOG] Partage du post ID : " + postId + " par utilisateur : " + userId);
SocialPost post = socialPostRepository.findById(postId);
if (post == null) {
@@ -264,6 +332,28 @@ public class SocialPostService {
post.incrementShares();
socialPostRepository.persist(post);
// TEMPS RÉEL: Publier dans Kafka (v2.0)
try {
Map<String, Object> reactionData = new HashMap<>();
reactionData.put("ownerId", post.getUser().getId().toString()); // Propriétaire du post
reactionData.put("sharesCount", post.getSharesCount());
ReactionEvent event = new ReactionEvent(
postId.toString(), // targetId
"post", // targetType
userId.toString(), // userId qui partage
"share", // reactionType
reactionData
);
reactionEmitter.send(event);
System.out.println("[LOG] Réaction share publiée dans Kafka pour post: " + postId);
} catch (Exception e) {
System.out.println("[ERROR] Erreur publication Kafka: " + e.getMessage());
// Ne pas bloquer le partage si Kafka échoue
}
return post;
}

View File

@@ -35,8 +35,19 @@ public class StoryService {
* @return Liste des stories actives
*/
public List<Story> getAllActiveStories() {
System.out.println("[LOG] Récupération de toutes les stories actives");
return storyRepository.findAllActive();
return getAllActiveStories(0, Integer.MAX_VALUE);
}
/**
* Récupère toutes les stories actives avec pagination.
*
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des stories actives
*/
public List<Story> getAllActiveStories(int page, int size) {
System.out.println("[LOG] Récupération de toutes les stories actives (page: " + page + ", size: " + size + ")");
return storyRepository.findAllActive(page, size);
}
/**
@@ -47,7 +58,20 @@ public class StoryService {
* @throws UserNotFoundException Si l'utilisateur n'existe pas
*/
public List<Story> getActiveStoriesByUserId(UUID userId) {
System.out.println("[LOG] Récupération des stories actives pour l'utilisateur ID : " + userId);
return getActiveStoriesByUserId(userId, 0, Integer.MAX_VALUE);
}
/**
* Récupère toutes les stories actives d'un utilisateur avec pagination.
*
* @param userId L'ID de l'utilisateur
* @param page Le numéro de la page (0-indexé)
* @param size La taille de la page
* @return Liste paginée des stories actives de l'utilisateur
* @throws UserNotFoundException Si l'utilisateur n'existe pas
*/
public List<Story> getActiveStoriesByUserId(UUID userId, int page, int size) {
System.out.println("[LOG] Récupération des stories actives pour l'utilisateur ID : " + userId + " (page: " + page + ", size: " + size + ")");
Users user = usersRepository.findById(userId);
if (user == null) {
@@ -55,7 +79,7 @@ public class StoryService {
throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + userId);
}
return storyRepository.findActiveByUserId(userId);
return storyRepository.findActiveByUserId(userId, page, size);
}
/**

View File

@@ -23,7 +23,7 @@ public class UsersService {
UsersRepository usersRepository;
/**
* Crée un nouvel utilisateur dans le système.
* Crée un nouvel utilisateur dans le système (v2.0).
*
* @param userCreateRequestDTO Le DTO contenant les informations de l'utilisateur à créer.
* @return L'utilisateur créé.
@@ -36,16 +36,28 @@ public class UsersService {
}
Users user = new Users();
user.setNom(userCreateRequestDTO.getNom());
user.setPrenoms(userCreateRequestDTO.getPrenoms());
// v2.0 - Utiliser les nouveaux noms de champs avec compatibilité v1.0
user.setFirstName(userCreateRequestDTO.getFirstName()); // v2.0
user.setLastName(userCreateRequestDTO.getLastName()); // v2.0
user.setEmail(userCreateRequestDTO.getEmail());
user.setMotDePasse(userCreateRequestDTO.getMotDePasse()); // Hachage automatique
user.setPassword(userCreateRequestDTO.getPassword()); // v2.0 - Hachage automatique
// v2.0 - Nouveaux champs
if (userCreateRequestDTO.getBio() != null) {
user.setBio(userCreateRequestDTO.getBio());
}
if (userCreateRequestDTO.getLoyaltyPoints() != null) {
user.setLoyaltyPoints(userCreateRequestDTO.getLoyaltyPoints());
}
if (userCreateRequestDTO.getPreferences() != null) {
user.setPreferences(userCreateRequestDTO.getPreferences());
}
// Logique pour l'image et le rôle par défaut.
user.setProfileImageUrl(
userCreateRequestDTO.getProfileImageUrl() != null
? userCreateRequestDTO.getProfileImageUrl()
: "https://via.placeholder.com/150"
: "https://placehold.co/150x150.png"
);
user.setRole(
userCreateRequestDTO.getRole() != null
@@ -59,7 +71,7 @@ public class UsersService {
}
/**
* Met à jour un utilisateur existant dans le système.
* Met à jour un utilisateur existant dans le système (v2.0).
*
* @param id L'ID de l'utilisateur à mettre à jour.
* @param userCreateRequestDTO Les nouvelles informations de l'utilisateur.
@@ -74,13 +86,35 @@ public class UsersService {
throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + id);
}
// Mettre à jour les champs de l'utilisateur existant
existingUser.setNom(userCreateRequestDTO.getNom());
existingUser.setPrenoms(userCreateRequestDTO.getPrenoms());
existingUser.setEmail(userCreateRequestDTO.getEmail());
existingUser.setMotDePasse(userCreateRequestDTO.getMotDePasse()); // Hachage automatique si nécessaire
existingUser.setRole(userCreateRequestDTO.getRole());
existingUser.setProfileImageUrl(userCreateRequestDTO.getProfileImageUrl());
// v2.0 - Mettre à jour les champs avec les nouveaux noms
if (userCreateRequestDTO.getFirstName() != null) {
existingUser.setFirstName(userCreateRequestDTO.getFirstName());
}
if (userCreateRequestDTO.getLastName() != null) {
existingUser.setLastName(userCreateRequestDTO.getLastName());
}
if (userCreateRequestDTO.getEmail() != null) {
existingUser.setEmail(userCreateRequestDTO.getEmail());
}
if (userCreateRequestDTO.getPassword() != null) {
existingUser.setPassword(userCreateRequestDTO.getPassword()); // v2.0 - Hachage automatique
}
if (userCreateRequestDTO.getRole() != null) {
existingUser.setRole(userCreateRequestDTO.getRole());
}
if (userCreateRequestDTO.getProfileImageUrl() != null) {
existingUser.setProfileImageUrl(userCreateRequestDTO.getProfileImageUrl());
}
// v2.0 - Nouveaux champs
if (userCreateRequestDTO.getBio() != null) {
existingUser.setBio(userCreateRequestDTO.getBio());
}
if (userCreateRequestDTO.getLoyaltyPoints() != null) {
existingUser.setLoyaltyPoints(userCreateRequestDTO.getLoyaltyPoints());
}
if (userCreateRequestDTO.getPreferences() != null) {
existingUser.setPreferences(userCreateRequestDTO.getPreferences());
}
usersRepository.persist(existingUser);
System.out.println("[LOG] Utilisateur mis à jour avec succès : " + existingUser.getEmail());
@@ -122,16 +156,16 @@ public class UsersService {
}
/**
* Authentifie un utilisateur avec son email et son mot de passe.
* Authentifie un utilisateur avec son email et son mot de passe (v2.0).
*
* @param email L'email de l'utilisateur.
* @param motDePasse Le mot de passe de l'utilisateur.
* @param password Le mot de passe de l'utilisateur (v2.0).
* @return L'utilisateur authentifié.
* @throws UserNotFoundException Si l'utilisateur n'est pas trouvé ou si le mot de passe est incorrect.
*/
public Users authenticateUser(String email, String motDePasse) {
public Users authenticateUser(String email, String password) {
Optional<Users> userOptional = usersRepository.findByEmail(email);
if (userOptional.isEmpty() || !userOptional.get().verifierMotDePasse(motDePasse)) {
if (userOptional.isEmpty() || !userOptional.get().verifyPassword(password)) { // v2.0
System.out.println("[ERROR] Échec de l'authentification pour l'email : " + email);
throw new UserNotFoundException("Utilisateur ou mot de passe incorrect.");
}
@@ -139,6 +173,15 @@ public class UsersService {
return userOptional.get();
}
/**
* Méthode de compatibilité v1.0 (dépréciée).
* @deprecated Utiliser {@link #authenticateUser(String, String)} avec password à la place.
*/
@Deprecated
public Users authenticateUser(String email, String motDePasse, boolean deprecated) {
return authenticateUser(email, motDePasse);
}
/**
* Récupère un utilisateur par son ID.
*
@@ -157,7 +200,7 @@ public class UsersService {
}
/**
* Réinitialise le mot de passe d'un utilisateur.
* Réinitialise le mot de passe d'un utilisateur (v2.0).
*
* @param id L'ID de l'utilisateur.
* @param newPassword Le nouveau mot de passe à définir.
@@ -171,7 +214,7 @@ public class UsersService {
throw new UserNotFoundException("Utilisateur non trouvé.");
}
user.setMotDePasse(newPassword); // Hachage automatique
user.setPassword(newPassword); // v2.0 - Hachage automatique
usersRepository.persist(user);
System.out.println("[LOG] Mot de passe réinitialisé pour l'utilisateur : " + user.getEmail());
}

View File

@@ -1,361 +0,0 @@
package com.lions.dev.websocket;
import com.lions.dev.dto.request.chat.SendMessageRequestDTO;
import com.lions.dev.dto.response.chat.MessageResponseDTO;
import com.lions.dev.entity.chat.Message;
import com.lions.dev.service.MessageService;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.websocket.*;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* WebSocket endpoint pour le chat en temps réel.
*
* Ce endpoint gère:
* - La connexion/déconnexion des utilisateurs
* - L'envoi et la réception de messages en temps réel
* - Les indicateurs de frappe (typing indicators)
* - Les confirmations de lecture (read receipts)
*
* URL: ws://localhost:8080/chat/ws/{userId}
*/
@ServerEndpoint("/chat/ws/{userId}")
@ApplicationScoped
public class ChatWebSocket {
@Inject
MessageService messageService;
// Map pour stocker les sessions WebSocket des utilisateurs connectés
private static final Map<UUID, Session> sessions = new ConcurrentHashMap<>();
/**
* Appelé lorsqu'un utilisateur se connecte.
*
* @param session La session WebSocket
* @param userId L'ID de l'utilisateur
*/
@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId) {
try {
UUID userUUID = UUID.fromString(userId);
sessions.put(userUUID, session);
Log.info("[LOG] WebSocket ouvert pour l'utilisateur ID : " + userId);
// Envoyer un message de confirmation
sendToUser(userUUID, "{\"type\":\"connected\",\"message\":\"Connecté au chat\"}");
} catch (Exception e) {
Log.error("[ERROR] Erreur lors de la connexion WebSocket : " + e.getMessage(), e);
}
}
/**
* Appelé lorsqu'un message est reçu.
*
* @param message Le message reçu (au format JSON)
* @param userId L'ID de l'utilisateur qui envoie le message
*/
@OnMessage
public void onMessage(String message, @PathParam("userId") String userId) {
try {
Log.info("[LOG] Message reçu de l'utilisateur " + userId + " : " + message);
// Parser le message JSON
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> messageData = mapper.readValue(message, Map.class);
String type = (String) messageData.get("type");
switch (type) {
case "message":
handleChatMessage(messageData, userId);
break;
case "typing":
handleTypingIndicator(messageData, userId);
break;
case "read":
handleReadReceipt(messageData, userId);
break;
default:
Log.warn("[WARN] Type de message inconnu : " + type);
}
} catch (Exception e) {
Log.error("[ERROR] Erreur lors du traitement du message : " + e.getMessage(), e);
}
}
/**
* Appelé lorsqu'une erreur se produit.
*
* @param session La session WebSocket
* @param error L'erreur
*/
@OnError
public void onError(Session session, Throwable error) {
Log.error("[ERROR] Erreur WebSocket : " + error.getMessage(), error);
}
/**
* Appelé lorsqu'un utilisateur se déconnecte.
*
* @param session La session WebSocket
* @param userId L'ID de l'utilisateur
*/
@OnClose
public void onClose(Session session, @PathParam("userId") String userId) {
try {
UUID userUUID = UUID.fromString(userId);
sessions.remove(userUUID);
Log.info("[LOG] WebSocket fermé pour l'utilisateur ID : " + userId);
} catch (Exception e) {
Log.error("[ERROR] Erreur lors de la fermeture WebSocket : " + e.getMessage(), e);
}
}
/**
* Gère l'envoi d'un message de chat.
*/
private void handleChatMessage(Map<String, Object> messageData, String senderId) {
try {
UUID senderUUID = UUID.fromString(senderId);
UUID recipientUUID = UUID.fromString((String) messageData.get("recipientId"));
String content = (String) messageData.get("content");
String messageType = messageData.getOrDefault("messageType", "text").toString();
String mediaUrl = (String) messageData.get("mediaUrl");
// Enregistrer le message dans la base de données
Message message = messageService.sendMessage(
senderUUID,
recipientUUID,
content,
messageType,
mediaUrl
);
// Créer le DTO de réponse
MessageResponseDTO response = new MessageResponseDTO(message);
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
String responseJson = mapper.writeValueAsString(Map.of(
"type", "message",
"data", response
));
// Envoyer le message au destinataire s'il est connecté
sendToUser(recipientUUID, responseJson);
// Envoyer une confirmation à l'expéditeur
sendToUser(senderUUID, responseJson);
Log.info("[LOG] Message envoyé de " + senderId + " à " + recipientUUID);
} catch (Exception e) {
Log.error("[ERROR] Erreur lors de l'envoi du message : " + e.getMessage(), e);
}
}
/**
* Gère les indicateurs de frappe.
*/
private void handleTypingIndicator(Map<String, Object> messageData, String userId) {
try {
UUID recipientUUID = UUID.fromString((String) messageData.get("recipientId"));
boolean isTyping = (boolean) messageData.getOrDefault("isTyping", false);
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
String response = mapper.writeValueAsString(Map.of(
"type", "typing",
"userId", userId,
"isTyping", isTyping
));
sendToUser(recipientUUID, response);
Log.info("[LOG] Indicateur de frappe envoyé de " + userId + " à " + recipientUUID);
} catch (Exception e) {
Log.error("[ERROR] Erreur lors de l'envoi de l'indicateur de frappe : " + e.getMessage(), e);
}
}
/**
* Gère les confirmations de lecture.
*/
private void handleReadReceipt(Map<String, Object> messageData, String userId) {
try {
UUID messageUUID = UUID.fromString((String) messageData.get("messageId"));
// Marquer le message comme lu
Message message = messageService.markMessageAsRead(messageUUID);
// Notifier l'expéditeur que le message a été lu
UUID senderUUID = message.getSender().getId();
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
String response = mapper.writeValueAsString(Map.of(
"type", "read",
"messageId", messageUUID.toString(),
"readBy", userId
));
sendToUser(senderUUID, response);
Log.info("[LOG] Confirmation de lecture envoyée pour le message " + messageUUID);
} catch (Exception e) {
Log.error("[ERROR] Erreur lors de l'envoi de la confirmation de lecture : " + e.getMessage(), e);
}
}
/**
* Envoie un message à un utilisateur spécifique.
*
* @param userId L'ID de l'utilisateur
* @param message Le message à envoyer
*/
private void sendToUser(UUID userId, String message) {
Session session = sessions.get(userId);
if (session != null && session.isOpen()) {
try {
session.getAsyncRemote().sendText(message);
} catch (Exception e) {
Log.error("[ERROR] Erreur lors de l'envoi du message à l'utilisateur " + userId + " : " + e.getMessage(), e);
}
} else {
Log.warn("[WARN] Utilisateur " + userId + " non connecté ou session fermée");
}
}
/**
* Diffuse un message à tous les utilisateurs connectés.
*
* @param message Le message à diffuser
*/
public void broadcast(String message) {
sessions.values().forEach(session -> {
if (session.isOpen()) {
try {
session.getAsyncRemote().sendText(message);
} catch (Exception e) {
Log.error("[ERROR] Erreur lors de la diffusion : " + e.getMessage(), e);
}
}
});
}
/**
* Envoie un message chat à un utilisateur spécifique via WebSocket.
*
* Cette méthode est statique pour permettre son appel depuis les services
* (comme MessageService) sans nécessiter une instance de ChatWebSocket.
*
* @param userId L'ID de l'utilisateur destinataire
* @param messageData Les données du message (id, conversationId, content, etc.)
*/
public static void sendMessageToUser(UUID userId, Map<String, Object> messageData) {
Session session = sessions.get(userId);
if (session == null || !session.isOpen()) {
Log.warn("[CHAT-WS] Utilisateur " + userId + " non connecté, message non envoyé");
return;
}
try {
// Construire le message JSON au format attendu par le frontend
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> envelope = Map.of(
"type", "message",
"data", messageData
);
String json = mapper.writeValueAsString(envelope);
session.getAsyncRemote().sendText(json);
Log.info("[CHAT-WS] Message envoyé à l'utilisateur " + userId);
} catch (Exception e) {
Log.error("[CHAT-WS] Erreur lors de l'envoi du message à " + userId + " : " + e.getMessage(), e);
}
}
/**
* Envoie une confirmation de délivrance à l'expéditeur via WebSocket.
*
* Cette méthode est appelée lorsqu'un message est délivré au destinataire
* pour notifier l'expéditeur que le message a bien été reçu.
*
* @param userId L'ID de l'utilisateur (expéditeur) à notifier
* @param deliveryData Les données de confirmation (messageId, isDelivered, timestamp)
*/
public static void sendDeliveryConfirmation(UUID userId, Map<String, Object> deliveryData) {
Session session = sessions.get(userId);
if (session == null || !session.isOpen()) {
Log.warn("[CHAT-WS] Utilisateur " + userId + " non connecté, confirmation de délivrance non envoyée");
return;
}
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> envelope = Map.of(
"type", "delivered",
"data", deliveryData
);
String json = mapper.writeValueAsString(envelope);
session.getAsyncRemote().sendText(json);
Log.info("[CHAT-WS] Confirmation de délivrance envoyée à l'utilisateur " + userId);
} catch (Exception e) {
Log.error("[CHAT-WS] Erreur lors de l'envoi de la confirmation de délivrance : " + e.getMessage(), e);
}
}
/**
* Envoie une confirmation de lecture à l'expéditeur via WebSocket.
*
* @param userId L'ID de l'utilisateur expéditeur
* @param readData Les données de confirmation de lecture
*/
public static void sendReadConfirmation(UUID userId, Map<String, Object> readData) {
Session session = sessions.get(userId);
if (session == null || !session.isOpen()) {
Log.warn("[CHAT-WS] Utilisateur " + userId + " non connecté, confirmation de lecture non envoyée");
return;
}
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> envelope = Map.of(
"type", "read",
"data", readData
);
String json = mapper.writeValueAsString(envelope);
session.getAsyncRemote().sendText(json);
Log.info("[CHAT-WS] Confirmation de lecture envoyée à l'utilisateur " + userId);
} catch (Exception e) {
Log.error("[CHAT-WS] Erreur lors de l'envoi de la confirmation de lecture : " + e.getMessage(), e);
}
}
/**
* Récupère le nombre d'utilisateurs connectés.
*
* @return Le nombre d'utilisateurs connectés
*/
public static int getConnectedUsersCount() {
return sessions.size();
}
}

View File

@@ -0,0 +1,301 @@
package com.lions.dev.websocket;
import com.lions.dev.dto.response.chat.MessageResponseDTO;
import com.lions.dev.entity.chat.Message;
import com.lions.dev.service.MessageService;
import io.quarkus.logging.Log;
import io.quarkus.websockets.next.OnClose;
import io.quarkus.websockets.next.OnOpen;
import io.quarkus.websockets.next.OnTextMessage;
import io.quarkus.websockets.next.WebSocket;
import io.quarkus.websockets.next.WebSocketConnection;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* WebSocket endpoint pour le chat en temps réel (WebSockets Next).
*
* Architecture v2.0:
* Client → WebSocket → MessageService → Kafka → Bridge → WebSocket → Destinataire
*
* Gère:
* - La connexion/déconnexion des utilisateurs
* - L'envoi et la réception de messages en temps réel
* - Les indicateurs de frappe (typing indicators)
* - Les confirmations de lecture (read receipts)
* - Les confirmations de délivrance
*
* URL: ws://localhost:8080/chat/{userId}
*/
@WebSocket(path = "/chat/{userId}")
@ApplicationScoped
public class ChatWebSocketNext {
@Inject
MessageService messageService;
// Map pour stocker les sessions WebSocket des utilisateurs connectés
private static final Map<UUID, WebSocketConnection> sessions = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(WebSocketConnection connection) {
String userId = connection.pathParam("userId");
try {
UUID userUUID = UUID.fromString(userId);
sessions.put(userUUID, connection);
Log.info("[CHAT-WS-NEXT] WebSocket ouvert pour l'utilisateur ID : " + userId);
// Envoyer un message de confirmation
String confirmation = buildJsonMessage("connected",
Map.of("message", "Connecté au chat"));
connection.sendText(confirmation);
} catch (IllegalArgumentException e) {
Log.error("[CHAT-WS-NEXT] UUID invalide: " + userId, e);
connection.close();
} catch (Exception e) {
Log.error("[CHAT-WS-NEXT] Erreur lors de la connexion", e);
connection.close();
}
}
@OnClose
public void onClose(WebSocketConnection connection) {
try {
String userId = connection.pathParam("userId");
UUID userUUID = UUID.fromString(userId);
sessions.remove(userUUID);
Log.info("[CHAT-WS-NEXT] WebSocket fermé pour l'utilisateur ID : " + userId);
} catch (Exception e) {
Log.error("[CHAT-WS-NEXT] Erreur lors de la fermeture", e);
}
}
@OnTextMessage
public void onMessage(String message, WebSocketConnection connection) {
try {
String userId = connection.pathParam("userId");
Log.debug("[CHAT-WS-NEXT] Message reçu de " + userId + ": " + message);
// Parser le message JSON
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> messageData = mapper.readValue(message, Map.class);
String type = (String) messageData.get("type");
switch (type) {
case "message":
handleChatMessage(messageData, userId);
break;
case "typing":
handleTypingIndicator(messageData, userId);
break;
case "read":
handleReadReceipt(messageData, userId);
break;
default:
Log.warn("[CHAT-WS-NEXT] Type de message inconnu: " + type);
}
} catch (Exception e) {
Log.error("[CHAT-WS-NEXT] Erreur lors du traitement du message", e);
}
}
/**
* Gère l'envoi d'un message de chat.
* Le message est traité par MessageService qui publiera dans Kafka.
*/
private void handleChatMessage(Map<String, Object> messageData, String senderId) {
try {
UUID senderUUID = UUID.fromString(senderId);
UUID recipientUUID = UUID.fromString((String) messageData.get("recipientId"));
String content = (String) messageData.get("content");
String messageType = messageData.getOrDefault("messageType", "text").toString();
String mediaUrl = (String) messageData.get("mediaUrl");
// Enregistrer le message dans la base de données
// MessageService publiera automatiquement dans Kafka
Message message = messageService.sendMessage(
senderUUID,
recipientUUID,
content,
messageType,
mediaUrl
);
// Créer le DTO de réponse
MessageResponseDTO response = new MessageResponseDTO(message);
String responseJson = buildJsonMessage("message",
Map.of("message", response));
// Envoyer confirmation à l'expéditeur
sendToUser(senderUUID, responseJson);
Log.info("[CHAT-WS-NEXT] Message traité de " + senderId + " à " + recipientUUID);
} catch (Exception e) {
Log.error("[CHAT-WS-NEXT] Erreur lors de l'envoi du message", e);
}
}
/**
* Gère les indicateurs de frappe.
*/
private void handleTypingIndicator(Map<String, Object> messageData, String userId) {
try {
UUID recipientUUID = UUID.fromString((String) messageData.get("recipientId"));
boolean isTyping = (boolean) messageData.getOrDefault("isTyping", false);
String response = buildJsonMessage("typing", Map.of(
"userId", userId,
"isTyping", isTyping
));
sendToUser(recipientUUID, response);
Log.debug("[CHAT-WS-NEXT] Indicateur de frappe envoyé de " + userId + " à " + recipientUUID);
} catch (Exception e) {
Log.error("[CHAT-WS-NEXT] Erreur lors de l'envoi de l'indicateur de frappe", e);
}
}
/**
* Gère les confirmations de lecture.
*/
private void handleReadReceipt(Map<String, Object> messageData, String userId) {
try {
UUID messageUUID = UUID.fromString((String) messageData.get("messageId"));
// Marquer le message comme lu
Message message = messageService.markMessageAsRead(messageUUID);
if (message != null) {
// Envoyer confirmation de lecture à l'expéditeur via WebSocket
// (sera aussi publié dans Kafka par MessageService)
UUID senderUUID = message.getSender().getId();
String response = buildJsonMessage("read_receipt", Map.of(
"messageId", messageUUID.toString(),
"readBy", userId,
"readAt", System.currentTimeMillis()
));
sendToUser(senderUUID, response);
Log.info("[CHAT-WS-NEXT] Confirmation de lecture envoyée pour message " + messageUUID);
}
} catch (Exception e) {
Log.error("[CHAT-WS-NEXT] Erreur lors du traitement de la confirmation de lecture", e);
}
}
/**
* Envoie un message chat à un utilisateur spécifique via WebSocket.
* Appelé par le bridge Kafka → WebSocket.
*
* @param userId ID de l'utilisateur destinataire
* @param message Message JSON à envoyer
*/
public static void sendMessageToUser(UUID userId, String message) {
WebSocketConnection connection = sessions.get(userId);
if (connection == null || !connection.isOpen()) {
Log.debug("[CHAT-WS-NEXT] Utilisateur " + userId + " non connecté");
return;
}
try {
connection.sendText(message);
Log.debug("[CHAT-WS-NEXT] Message envoyé à l'utilisateur: " + userId);
} catch (Exception e) {
Log.error("[CHAT-WS-NEXT] Erreur lors de l'envoi à " + userId, e);
}
}
/**
* Envoie une confirmation de délivrance à l'expéditeur via WebSocket.
*/
public static void sendDeliveryConfirmation(UUID senderId, Map<String, Object> confirmationData) {
WebSocketConnection connection = sessions.get(senderId);
if (connection == null || !connection.isOpen()) {
Log.debug("[CHAT-WS-NEXT] Expéditeur " + senderId + " non connecté pour confirmation");
return;
}
try {
String response = buildJsonMessage("delivery_confirmation", confirmationData);
connection.sendText(response);
Log.debug("[CHAT-WS-NEXT] Confirmation de délivrance envoyée à: " + senderId);
} catch (Exception e) {
Log.error("[CHAT-WS-NEXT] Erreur envoi confirmation à " + senderId, e);
}
}
/**
* Envoie une confirmation de lecture à l'expéditeur via WebSocket.
*/
public static void sendReadConfirmation(UUID senderId, Map<String, Object> readData) {
WebSocketConnection connection = sessions.get(senderId);
if (connection == null || !connection.isOpen()) {
Log.debug("[CHAT-WS-NEXT] Expéditeur " + senderId + " non connecté pour confirmation de lecture");
return;
}
try {
String response = buildJsonMessage("read_confirmation", readData);
connection.sendText(response);
Log.debug("[CHAT-WS-NEXT] Confirmation de lecture envoyée à: " + senderId);
} catch (Exception e) {
Log.error("[CHAT-WS-NEXT] Erreur envoi confirmation lecture à " + senderId, e);
}
}
/**
* Envoie un message à un utilisateur (méthode privée pour usage interne).
*/
private void sendToUser(UUID userId, String message) {
WebSocketConnection connection = sessions.get(userId);
if (connection != null && connection.isOpen()) {
try {
connection.sendText(message);
} catch (Exception e) {
Log.error("[CHAT-WS-NEXT] Erreur lors de l'envoi à " + userId, e);
}
}
}
/**
* Construit un message JSON.
*/
private static String buildJsonMessage(String type, Map<String, Object> data) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> message = Map.of(
"type", type,
"data", data,
"timestamp", System.currentTimeMillis()
);
return mapper.writeValueAsString(message);
} catch (Exception e) {
Log.error("[CHAT-WS-NEXT] Erreur construction JSON", e);
return "{\"type\":\"error\",\"data\":{\"message\":\"Erreur de construction\"}}";
}
}
/**
* Récupère le nombre d'utilisateurs connectés au chat.
*/
public static int getConnectedUsersCount() {
return sessions.size();
}
}

View File

@@ -1,365 +0,0 @@
package com.lions.dev.websocket;
import com.lions.dev.service.NotificationService;
import com.lions.dev.service.FriendshipService;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.websocket.*;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* WebSocket endpoint pour les notifications en temps réel.
*
* Ce endpoint gère:
* - Les notifications de demandes d'amitié (envoi, acceptation, rejet)
* - Les notifications système (événements, rappels)
* - Les alertes de messages
*
* URL: ws://localhost:8080/notifications/ws/{userId}
*/
@ServerEndpoint("/notifications/ws/{userId}")
@ApplicationScoped
public class NotificationWebSocket {
@Inject
NotificationService notificationService;
@Inject
FriendshipService friendshipService;
@Inject
com.lions.dev.service.PresenceService presenceService;
// Map pour stocker les sessions WebSocket par utilisateur
// Support de plusieurs sessions par utilisateur (multi-device)
private static final Map<UUID, Set<Session>> userSessions = new ConcurrentHashMap<>();
/**
* Appelé lorsqu'un utilisateur se connecte.
*
* @param session La session WebSocket
* @param userId L'ID de l'utilisateur
*/
@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId) {
try {
UUID userUUID = UUID.fromString(userId);
// Ajouter la session à l'ensemble des sessions de l'utilisateur
userSessions.computeIfAbsent(userUUID, k -> ConcurrentHashMap.newKeySet()).add(session);
Log.info("[NOTIFICATION-WS] Connexion ouverte pour l'utilisateur ID : " + userId +
" (Total sessions: " + userSessions.get(userUUID).size() + ")");
// Envoyer un message de confirmation
String confirmationMessage = buildNotificationJson("connected",
Map.of("message", "Connecté au service de notifications en temps réel"));
session.getAsyncRemote().sendText(confirmationMessage);
// Marquer l'utilisateur comme en ligne
presenceService.setUserOnline(userUUID);
} catch (IllegalArgumentException e) {
Log.error("[NOTIFICATION-WS] UUID invalide : " + userId, e);
try {
session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, "UUID invalide"));
} catch (IOException ioException) {
Log.error("[NOTIFICATION-WS] Erreur lors de la fermeture de session", ioException);
}
} catch (Exception e) {
Log.error("[NOTIFICATION-WS] Erreur lors de la connexion : " + e.getMessage(), e);
}
}
/**
* Appelé lorsqu'un message est reçu.
*
* Gère les messages de type ping, ack, etc.
*
* @param message Le message reçu (au format JSON)
* @param userId L'ID de l'utilisateur qui envoie le message
*/
@OnMessage
public void onMessage(String message, @PathParam("userId") String userId) {
try {
Log.info("[NOTIFICATION-WS] Message reçu de l'utilisateur " + userId + " : " + message);
// Parser le message JSON
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> messageData = mapper.readValue(message, Map.class);
String type = (String) messageData.get("type");
switch (type) {
case "ping":
handlePing(userId);
break;
case "ack":
handleAcknowledgement(messageData, userId);
break;
default:
Log.warn("[NOTIFICATION-WS] Type de message inconnu : " + type);
}
} catch (Exception e) {
Log.error("[NOTIFICATION-WS] Erreur lors du traitement du message : " + e.getMessage(), e);
}
}
/**
* Appelé lorsqu'une erreur se produit.
*
* @param session La session WebSocket
* @param error L'erreur
*/
@OnError
public void onError(Session session, Throwable error) {
Log.error("[NOTIFICATION-WS] Erreur WebSocket : " + error.getMessage(), error);
}
/**
* Appelé lorsqu'un utilisateur se déconnecte.
*
* @param session La session WebSocket
* @param userId L'ID de l'utilisateur
*/
@OnClose
public void onClose(Session session, @PathParam("userId") String userId) {
try {
UUID userUUID = UUID.fromString(userId);
// Supprimer la session de l'ensemble
Set<Session> sessions = userSessions.get(userUUID);
if (sessions != null) {
sessions.remove(session);
// Si l'utilisateur n'a plus de sessions, supprimer l'entrée et marquer hors ligne
if (sessions.isEmpty()) {
userSessions.remove(userUUID);
presenceService.setUserOffline(userUUID);
Log.info("[NOTIFICATION-WS] Toutes les sessions fermées pour l'utilisateur ID : " + userId);
} else {
Log.info("[NOTIFICATION-WS] Session fermée pour l'utilisateur ID : " + userId +
" (Sessions restantes: " + sessions.size() + ")");
}
}
} catch (Exception e) {
Log.error("[NOTIFICATION-WS] Erreur lors de la fermeture : " + e.getMessage(), e);
}
}
/**
* Gère les messages de type ping (keep-alive).
*/
private void handlePing(String userId) {
try {
UUID userUUID = UUID.fromString(userId);
// Mettre à jour le heartbeat de présence
presenceService.heartbeat(userUUID);
String pongMessage = buildNotificationJson("pong", Map.of("timestamp", System.currentTimeMillis()));
sendToUser(userUUID, pongMessage);
Log.debug("[NOTIFICATION-WS] Pong envoyé à l'utilisateur : " + userId);
} catch (Exception e) {
Log.error("[NOTIFICATION-WS] Erreur lors de l'envoi du pong : " + e.getMessage(), e);
}
}
/**
* Gère les accusés de réception des notifications.
*/
private void handleAcknowledgement(Map<String, Object> messageData, String userId) {
try {
String notificationId = (String) messageData.get("notificationId");
Log.info("[NOTIFICATION-WS] ACK reçu pour la notification " + notificationId + " de l'utilisateur " + userId);
// Optionnel: marquer la notification comme délivrée en base de données
} catch (Exception e) {
Log.error("[NOTIFICATION-WS] Erreur lors du traitement de l'ACK : " + e.getMessage(), e);
}
}
/**
* Envoie une notification à toutes les sessions d'un utilisateur spécifique.
*
* Cette méthode est statique pour permettre son appel depuis les services
* sans nécessiter une instance de NotificationWebSocket.
*
* @param userId L'ID de l'utilisateur
* @param notificationType Le type de notification
* @param data Les données de la notification
*/
public static void sendNotificationToUser(UUID userId, String notificationType, Map<String, Object> data) {
Set<Session> sessions = userSessions.get(userId);
if (sessions == null || sessions.isEmpty()) {
Log.warn("[NOTIFICATION-WS] Utilisateur " + userId + " non connecté ou aucune session active");
return;
}
String json = buildNotificationJson(notificationType, data);
int successCount = 0;
int failCount = 0;
for (Session session : sessions) {
if (session.isOpen()) {
try {
session.getAsyncRemote().sendText(json);
successCount++;
} catch (Exception e) {
failCount++;
Log.error("[NOTIFICATION-WS] Erreur lors de l'envoi à une session de l'utilisateur " + userId + " : " + e.getMessage(), e);
}
} else {
failCount++;
}
}
Log.info("[NOTIFICATION-WS] Notification " + notificationType + " envoyée à l'utilisateur " + userId +
" (Succès: " + successCount + ", Échec: " + failCount + ")");
}
/**
* Envoie un message à toutes les sessions d'un utilisateur.
*
* Version privée pour usage interne (ping/pong, etc.)
*
* @param userId L'ID de l'utilisateur
* @param message Le message à envoyer
*/
private void sendToUser(UUID userId, String message) {
Set<Session> sessions = userSessions.get(userId);
if (sessions != null) {
for (Session session : sessions) {
if (session.isOpen()) {
try {
session.getAsyncRemote().sendText(message);
} catch (Exception e) {
Log.error("[NOTIFICATION-WS] Erreur lors de l'envoi à l'utilisateur " + userId + " : " + e.getMessage(), e);
}
}
}
}
}
/**
* Diffuse une notification à tous les utilisateurs connectés.
*
* @param notificationType Le type de notification
* @param data Les données de la notification
*/
public static void broadcastNotification(String notificationType, Map<String, Object> data) {
String json = buildNotificationJson(notificationType, data);
int totalSessions = 0;
int successCount = 0;
for (Set<Session> sessions : userSessions.values()) {
for (Session session : sessions) {
totalSessions++;
if (session.isOpen()) {
try {
session.getAsyncRemote().sendText(json);
successCount++;
} catch (Exception e) {
Log.error("[NOTIFICATION-WS] Erreur lors de la diffusion : " + e.getMessage(), e);
}
}
}
}
Log.info("[NOTIFICATION-WS] Notification diffusée à " + successCount + " sessions sur " + totalSessions);
}
/**
* Construit un message JSON pour les notifications.
*
* Format: {"type": "notification_type", "data": {...}}
*
* @param type Le type de notification
* @param data Les données de la notification
* @return Le JSON sous forme de String
*/
private static String buildNotificationJson(String type, Map<String, Object> data) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> message = Map.of(
"type", type,
"data", data,
"timestamp", System.currentTimeMillis()
);
return mapper.writeValueAsString(message);
} catch (Exception e) {
Log.error("[NOTIFICATION-WS] Erreur lors de la construction du JSON : " + e.getMessage(), e);
return "{\"type\":\"error\",\"data\":{\"message\":\"Erreur de construction du message\"}}";
}
}
/**
* Récupère le nombre total d'utilisateurs connectés.
*
* @return Le nombre d'utilisateurs connectés
*/
public static int getConnectedUsersCount() {
return userSessions.size();
}
/**
* Récupère le nombre total de sessions actives.
*
* @return Le nombre total de sessions
*/
public static int getTotalSessionsCount() {
return userSessions.values().stream()
.mapToInt(Set::size)
.sum();
}
/**
* Broadcast une mise à jour de présence à tous les utilisateurs connectés.
*
* @param presenceData Les données de présence (userId, isOnline, lastSeen)
*/
public static void broadcastPresenceUpdate(Map<String, Object> presenceData) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> envelope = Map.of(
"type", "presence",
"data", presenceData
);
String json = mapper.writeValueAsString(envelope);
// Envoyer à tous les utilisateurs connectés
for (Set<Session> sessions : userSessions.values()) {
for (Session session : sessions) {
if (session.isOpen()) {
try {
session.getAsyncRemote().sendText(json);
} catch (Exception e) {
Log.error("[NOTIFICATION-WS] Erreur broadcast présence : " + e.getMessage());
}
}
}
}
Log.debug("[NOTIFICATION-WS] Présence broadcastée : " + presenceData.get("userId"));
} catch (Exception e) {
Log.error("[NOTIFICATION-WS] Erreur lors du broadcast de présence : " + e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,282 @@
package com.lions.dev.websocket;
import io.quarkus.logging.Log;
import io.quarkus.websockets.next.OnClose;
import io.quarkus.websockets.next.OnOpen;
import io.quarkus.websockets.next.OnTextMessage;
import io.quarkus.websockets.next.WebSocket;
import io.quarkus.websockets.next.WebSocketConnection;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import com.lions.dev.service.PresenceService;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* WebSocket endpoint pour les notifications en temps réel (WebSockets Next).
*
* Architecture v2.0:
* Services métier → Kafka Topic → Kafka Bridge → WebSocket → Client
*
* Avantages:
* - Scalabilité horizontale (plusieurs instances Quarkus)
* - Durabilité (événements persistés dans Kafka)
* - Découplage (services indépendants des WebSockets)
*
* URL: ws://localhost:8080/notifications/{userId}
*/
@WebSocket(path = "/notifications/{userId}")
@ApplicationScoped
public class NotificationWebSocketNext {
@Inject
PresenceService presenceService;
// Stockage des connexions actives par utilisateur (multi-device support)
private static final Map<UUID, Set<WebSocketConnection>> userConnections = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(WebSocketConnection connection) {
String userId = connection.pathParam("userId");
try {
UUID userUUID = UUID.fromString(userId);
// Ajouter la connexion à l'ensemble des connexions de l'utilisateur
userConnections.computeIfAbsent(userUUID, k -> ConcurrentHashMap.newKeySet())
.add(connection);
Log.info("[WS-NEXT] Connexion ouverte pour l'utilisateur: " + userId +
" (Total: " + userConnections.get(userUUID).size() + ")");
// Envoyer confirmation
String confirmation = buildJsonMessage("connected",
Map.of("message", "Connecté au service de notifications en temps réel"));
connection.sendText(confirmation);
// Marquer l'utilisateur comme en ligne
presenceService.setUserOnline(userUUID);
} catch (IllegalArgumentException e) {
Log.error("[WS-NEXT] UUID invalide: " + userId, e);
connection.close();
} catch (Exception e) {
Log.error("[WS-NEXT] Erreur lors de la connexion", e);
connection.close();
}
}
@OnClose
public void onClose(WebSocketConnection connection) {
try {
String userId = connection.pathParam("userId");
UUID userUUID = UUID.fromString(userId);
Set<WebSocketConnection> connections = userConnections.get(userUUID);
if (connections != null) {
connections.removeIf(conn -> !conn.isOpen());
if (connections.isEmpty()) {
userConnections.remove(userUUID);
presenceService.setUserOffline(userUUID);
Log.info("[WS-NEXT] Toutes les connexions fermées pour: " + userId);
} else {
Log.info("[WS-NEXT] Connexion fermée pour: " + userId +
" (Restantes: " + connections.size() + ")");
}
}
} catch (Exception e) {
Log.error("[WS-NEXT] Erreur lors de la fermeture", e);
}
}
@OnTextMessage
public void onMessage(String message, WebSocketConnection connection) {
try {
String userId = connection.pathParam("userId");
Log.debug("[WS-NEXT] Message reçu de " + userId + ": " + message);
// Parser le message JSON
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> messageData = mapper.readValue(message, Map.class);
String type = (String) messageData.get("type");
switch (type) {
case "ping":
handlePing(userId);
break;
case "ack":
handleAck(messageData, userId);
break;
default:
Log.warn("[WS-NEXT] Type de message inconnu: " + type);
}
} catch (Exception e) {
Log.error("[WS-NEXT] Erreur traitement message", e);
}
}
private void handlePing(String userId) {
try {
UUID userUUID = UUID.fromString(userId);
// Mettre à jour le heartbeat de présence
presenceService.heartbeat(userUUID);
// Envoyer pong
String pong = buildJsonMessage("pong",
Map.of("timestamp", System.currentTimeMillis()));
sendToUser(userUUID, pong);
Log.debug("[WS-NEXT] Pong envoyé à: " + userId);
} catch (Exception e) {
Log.error("[WS-NEXT] Erreur lors de l'envoi du pong", e);
}
}
private void handleAck(Map<String, Object> messageData, String userId) {
try {
String notificationId = (String) messageData.get("notificationId");
Log.debug("[WS-NEXT] ACK reçu pour notification " + notificationId +
" de " + userId);
// Optionnel: marquer la notification comme délivrée en base
} catch (Exception e) {
Log.error("[WS-NEXT] Erreur lors du traitement de l'ACK", e);
}
}
/**
* Envoie une notification à un utilisateur spécifique.
* Appelé par le bridge Kafka → WebSocket.
*
* @param userId ID de l'utilisateur destinataire
* @param message Message JSON à envoyer
*/
public static void sendToUser(UUID userId, String message) {
Set<WebSocketConnection> connections = userConnections.get(userId);
if (connections == null || connections.isEmpty()) {
Log.debug("[WS-NEXT] Utilisateur " + userId + " non connecté");
return;
}
int success = 0;
int failed = 0;
for (WebSocketConnection conn : connections) {
if (conn.isOpen()) {
try {
conn.sendText(message);
success++;
} catch (Exception e) {
failed++;
Log.error("[WS-NEXT] Erreur envoi à " + userId, e);
}
} else {
failed++;
}
}
Log.info("[WS-NEXT] Notification envoyée à " + userId +
" (Succès: " + success + ", Échec: " + failed + ")");
}
/**
* Diffuse une notification à tous les utilisateurs connectés.
*
* @param notificationType Type de notification
* @param data Données de la notification
*/
public static void broadcastNotification(String notificationType, Map<String, Object> data) {
String json = buildJsonMessage(notificationType, data);
int totalSessions = 0;
int successCount = 0;
for (Set<WebSocketConnection> sessions : userConnections.values()) {
for (WebSocketConnection session : sessions) {
totalSessions++;
if (session.isOpen()) {
try {
session.sendText(json);
successCount++;
} catch (Exception e) {
Log.error("[WS-NEXT] Erreur lors de la diffusion", e);
}
}
}
}
Log.info("[WS-NEXT] Notification diffusée à " + successCount +
" sessions sur " + totalSessions);
}
/**
* Broadcast une mise à jour de présence à tous les utilisateurs connectés.
*
* @param presenceData Données de présence (userId, isOnline, lastSeen)
*/
public static void broadcastPresenceUpdate(Map<String, Object> presenceData) {
try {
String json = buildJsonMessage("presence", presenceData);
for (Set<WebSocketConnection> sessions : userConnections.values()) {
for (WebSocketConnection session : sessions) {
if (session.isOpen()) {
try {
session.sendText(json);
} catch (Exception e) {
Log.error("[WS-NEXT] Erreur broadcast présence", e);
}
}
}
}
Log.debug("[WS-NEXT] Présence broadcastée: " + presenceData.get("userId"));
} catch (Exception e) {
Log.error("[WS-NEXT] Erreur lors du broadcast de présence", e);
}
}
/**
* Construit un message JSON pour les notifications.
*
* Format: {"type": "notification_type", "data": {...}, "timestamp": ...}
*/
private static String buildJsonMessage(String type, Map<String, Object> data) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> message = Map.of(
"type", type,
"data", data,
"timestamp", System.currentTimeMillis()
);
return mapper.writeValueAsString(message);
} catch (Exception e) {
Log.error("[WS-NEXT] Erreur construction JSON", e);
return "{\"type\":\"error\",\"data\":{\"message\":\"Erreur de construction du message\"}}";
}
}
/**
* Récupère le nombre total d'utilisateurs connectés.
*/
public static int getConnectedUsersCount() {
return userConnections.size();
}
/**
* Récupère le nombre total de sessions actives.
*/
public static int getTotalSessionsCount() {
return userConnections.values().stream()
.mapToInt(Set::size)
.sum();
}
}

View File

@@ -0,0 +1,90 @@
package com.lions.dev.websocket.bridge;
import com.lions.dev.dto.events.ChatMessageEvent;
import com.lions.dev.websocket.ChatWebSocketNext;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message;
import java.util.UUID;
import java.util.Map;
import java.util.concurrent.CompletionStage;
/**
* Bridge qui consomme depuis Kafka et envoie via WebSocket pour le chat.
*
* Architecture:
* MessageService → Kafka Topic (chat.messages) → Bridge → WebSocket → Client
*/
@ApplicationScoped
public class ChatKafkaBridge {
/**
* Consomme les messages chat depuis Kafka et les route vers WebSocket.
*
* @param message Message Kafka contenant un ChatMessageEvent
* @return CompletionStage pour gérer l'ack/nack asynchrone
*/
@Incoming("kafka-chat")
public CompletionStage<Void> processChatMessage(Message<ChatMessageEvent> message) {
try {
ChatMessageEvent event = message.getPayload();
Log.debug("[CHAT-BRIDGE] Message reçu: " + event.getEventType() +
" de " + event.getSenderId() + " à " + event.getRecipientId());
UUID recipientId = UUID.fromString(event.getRecipientId());
// Construire le message JSON pour WebSocket
String wsMessage = buildWebSocketMessage(event);
// Envoyer via WebSocket au destinataire
ChatWebSocketNext.sendMessageToUser(recipientId, wsMessage);
Log.debug("[CHAT-BRIDGE] Message routé vers WebSocket pour: " + event.getRecipientId());
// Acknowledger le message Kafka
return message.ack();
} catch (IllegalArgumentException e) {
Log.error("[CHAT-BRIDGE] UUID invalide dans l'événement", e);
return message.nack(e);
} catch (Exception e) {
Log.error("[CHAT-BRIDGE] Erreur traitement événement", e);
return message.nack(e);
}
}
/**
* Construit le message JSON pour WebSocket à partir de l'événement Kafka.
*/
private String buildWebSocketMessage(ChatMessageEvent event) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
Map<String, Object> messageData = new java.util.HashMap<>();
messageData.put("id", event.getMessageId());
messageData.put("conversationId", event.getConversationId());
messageData.put("senderId", event.getSenderId());
messageData.put("recipientId", event.getRecipientId());
messageData.put("content", event.getContent());
messageData.put("timestamp", event.getTimestamp());
if (event.getMetadata() != null) {
messageData.putAll(event.getMetadata());
}
java.util.Map<String, Object> wsMessage = java.util.Map.of(
"type", event.getEventType() != null ? event.getEventType() : "message",
"data", messageData,
"timestamp", event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis()
);
return mapper.writeValueAsString(wsMessage);
} catch (Exception e) {
Log.error("[CHAT-BRIDGE] Erreur construction message WebSocket", e);
return "{\"type\":\"error\",\"data\":{\"message\":\"Erreur de traitement\"}}";
}
}
}

View File

@@ -0,0 +1,84 @@
package com.lions.dev.websocket.bridge;
import com.lions.dev.dto.events.NotificationEvent;
import com.lions.dev.websocket.NotificationWebSocketNext;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
/**
* Bridge qui consomme depuis Kafka et envoie via WebSocket pour les notifications.
*
* Architecture:
* Services métier → Kafka Topic (notifications) → Bridge → WebSocket → Client
*
* Avantages:
* - Découplage: Services ne connaissent pas les WebSockets
* - Scalabilité: Plusieurs instances peuvent consommer depuis Kafka
* - Durabilité: Messages persistés dans Kafka même si client déconnecté
*/
@ApplicationScoped
public class NotificationKafkaBridge {
/**
* Consomme les événements depuis Kafka et les route vers WebSocket.
*
* @param message Message Kafka contenant un NotificationEvent
* @return CompletionStage pour gérer l'ack/nack asynchrone
*/
@Incoming("kafka-notifications")
public CompletionStage<Void> processNotification(Message<NotificationEvent> message) {
try {
NotificationEvent event = message.getPayload();
Log.debug("[KAFKA-BRIDGE] Événement reçu: " + event.getType() +
" pour utilisateur: " + event.getUserId());
UUID userId = UUID.fromString(event.getUserId());
// Construire le message JSON pour WebSocket
String wsMessage = buildWebSocketMessage(event);
// Envoyer via WebSocket
NotificationWebSocketNext.sendToUser(userId, wsMessage);
Log.debug("[KAFKA-BRIDGE] Notification routée vers WebSocket pour: " + event.getUserId());
// Acknowledger le message Kafka
return message.ack();
} catch (IllegalArgumentException e) {
Log.error("[KAFKA-BRIDGE] UUID invalide dans l'événement", e);
return message.nack(e);
} catch (Exception e) {
Log.error("[KAFKA-BRIDGE] Erreur traitement événement", e);
return message.nack(e);
}
}
/**
* Construit le message JSON pour WebSocket à partir de l'événement Kafka.
*/
private String buildWebSocketMessage(NotificationEvent event) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
java.util.Map<String, Object> wsMessage = java.util.Map.of(
"type", event.getType(),
"data", event.getData() != null ? event.getData() : java.util.Map.of(),
"timestamp", event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis()
);
return mapper.writeValueAsString(wsMessage);
} catch (Exception e) {
Log.error("[KAFKA-BRIDGE] Erreur construction message WebSocket", e);
return "{\"type\":\"error\",\"data\":{\"message\":\"Erreur de traitement\"}}";
}
}
}

View File

@@ -0,0 +1,72 @@
package com.lions.dev.websocket.bridge;
import com.lions.dev.dto.events.PresenceEvent;
import com.lions.dev.websocket.NotificationWebSocketNext;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletionStage;
/**
* Bridge qui consomme depuis Kafka et envoie via WebSocket pour les événements de présence.
*
* Architecture:
* PresenceService → Kafka Topic (presence.updates) → Bridge → WebSocket → Client
*
* Les événements de présence (online/offline) sont diffusés à tous les utilisateurs connectés
* pour mettre à jour leur liste d'amis en temps réel.
*/
@ApplicationScoped
public class PresenceKafkaBridge {
/**
* Consomme les événements de présence depuis Kafka et les route vers WebSocket.
*
* @param message Message Kafka contenant un PresenceEvent
* @return CompletionStage pour gérer l'ack/nack asynchrone
*/
@Incoming("kafka-presence")
public CompletionStage<Void> processPresence(Message<PresenceEvent> message) {
try {
PresenceEvent event = message.getPayload();
Log.debug("[PRESENCE-BRIDGE] Événement de présence reçu: " + event.getStatus() +
" pour utilisateur: " + event.getUserId());
// Broadcast à tous les utilisateurs connectés (pas seulement le propriétaire)
// Car la présence doit être visible par tous les amis
NotificationWebSocketNext.broadcastPresenceUpdate(buildPresenceData(event));
Log.debug("[PRESENCE-BRIDGE] Présence routée vers WebSocket pour: " + event.getUserId());
// Acknowledger le message Kafka
return message.ack();
} catch (IllegalArgumentException e) {
Log.error("[PRESENCE-BRIDGE] UUID invalide dans l'événement", e);
return message.nack(e);
} catch (Exception e) {
Log.error("[PRESENCE-BRIDGE] Erreur traitement événement", e);
return message.nack(e);
}
}
/**
* Construit les données de présence au format attendu par NotificationWebSocketNext.
*/
private Map<String, Object> buildPresenceData(PresenceEvent event) {
Map<String, Object> presenceData = new HashMap<>();
presenceData.put("userId", event.getUserId());
presenceData.put("isOnline", "online".equals(event.getStatus()));
if (event.getLastSeen() != null) {
// Convertir timestamp en ISO string si nécessaire
presenceData.put("lastSeen", new java.util.Date(event.getLastSeen()).toString());
}
presenceData.put("timestamp", event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis());
return presenceData;
}
}

View File

@@ -0,0 +1,97 @@
package com.lions.dev.websocket.bridge;
import com.lions.dev.dto.events.ReactionEvent;
import com.lions.dev.websocket.NotificationWebSocketNext;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
/**
* Bridge qui consomme depuis Kafka et envoie via WebSocket pour les réactions.
*
* Architecture:
* SocialPostService/EventService → Kafka Topic (reactions) → Bridge → WebSocket → Client
*
* Les réactions (likes, comments) sont notifiées en temps réel aux propriétaires
* des posts/stories/événements.
*/
@ApplicationScoped
public class ReactionKafkaBridge {
/**
* Consomme les réactions depuis Kafka et les route vers WebSocket.
*
* @param message Message Kafka contenant un ReactionEvent
* @return CompletionStage pour gérer l'ack/nack asynchrone
*/
@Incoming("kafka-reactions")
public CompletionStage<Void> processReaction(Message<ReactionEvent> message) {
try {
ReactionEvent event = message.getPayload();
Log.debug("[REACTION-BRIDGE] Réaction reçue: " + event.getReactionType() +
" sur " + event.getTargetType() + ":" + event.getTargetId());
// Pour les réactions, on doit notifier le propriétaire du post/story/event
// L'ID du propriétaire doit être dans event.getData() sous la clé "ownerId"
if (event.getData() != null && event.getData().containsKey("ownerId")) {
String ownerId = event.getData().get("ownerId").toString();
UUID ownerUUID = UUID.fromString(ownerId);
// Construire le message JSON pour WebSocket
String wsMessage = buildWebSocketMessage(event);
// Envoyer via WebSocket au propriétaire
NotificationWebSocketNext.sendToUser(ownerUUID, wsMessage);
Log.debug("[REACTION-BRIDGE] Réaction routée vers WebSocket pour propriétaire: " + ownerId);
} else {
Log.warn("[REACTION-BRIDGE] ownerId manquant dans les données de l'événement");
}
// Acknowledger le message Kafka
return message.ack();
} catch (IllegalArgumentException e) {
Log.error("[REACTION-BRIDGE] UUID invalide dans l'événement", e);
return message.nack(e);
} catch (Exception e) {
Log.error("[REACTION-BRIDGE] Erreur traitement événement", e);
return message.nack(e);
}
}
/**
* Construit le message JSON pour WebSocket à partir de l'événement Kafka.
*/
private String buildWebSocketMessage(ReactionEvent event) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
java.util.Map<String, Object> reactionData = new java.util.HashMap<>();
reactionData.put("targetId", event.getTargetId());
reactionData.put("targetType", event.getTargetType());
reactionData.put("userId", event.getUserId());
reactionData.put("reactionType", event.getReactionType());
if (event.getData() != null) {
reactionData.putAll(event.getData());
}
java.util.Map<String, Object> wsMessage = java.util.Map.of(
"type", "reaction",
"data", reactionData,
"timestamp", event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis()
);
return mapper.writeValueAsString(wsMessage);
} catch (Exception e) {
Log.error("[REACTION-BRIDGE] Erreur construction message WebSocket", e);
return "{\"type\":\"error\",\"data\":{\"message\":\"Erreur de traitement\"}}";
}
}
}

View File

@@ -0,0 +1,31 @@
# ====================================================================
# AfterWork Server - Configuration DÉVELOPPEMENT
# ====================================================================
# Ce fichier est automatiquement chargé avec: mvn quarkus:dev
# Les configurations ici surchargent celles de application.properties
# ====================================================================
# Base de données H2 (en mémoire)
# ====================================================================
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
quarkus.datasource.devservices.enabled=false
# ====================================================================
# Hibernate ORM
# ====================================================================
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.log.sql=true
quarkus.hibernate-orm.format_sql=true
quarkus.hibernate-orm.packages=com.lions.dev.entity
# Forcer la création du schéma au démarrage
quarkus.hibernate-orm.schema-generation.scripts.action=drop-and-create
# ====================================================================
# Logging
# ====================================================================
quarkus.log.level=DEBUG
quarkus.log.category."com.lions.dev".level=DEBUG

View File

@@ -1,70 +0,0 @@
# ====================================================================
# AfterWork Server - Configuration de Production
# ====================================================================
# IMPORTANT: Les propriétés build-time (app.name, root-path, compression)
# sont définies dans application.properties et ne peuvent pas être changées ici
# HTTP Configuration (runtime only)
quarkus.http.host=0.0.0.0
quarkus.http.port=8080
# Base de données PostgreSQL (Production)
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:postgresql}:${DB_PORT:5432}/${DB_NAME:mic-after-work-server-impl-quarkus-main}
quarkus.datasource.username=${DB_USERNAME:lionsuser}
quarkus.datasource.password=${DB_PASSWORD:LionsUser2025!}
quarkus.datasource.jdbc.driver=org.postgresql.Driver
quarkus.datasource.jdbc.max-size=20
quarkus.datasource.jdbc.min-size=5
# Hibernate
quarkus.hibernate-orm.database.generation=update
quarkus.hibernate-orm.log.sql=false
quarkus.hibernate-orm.sql-load-script=no-file
quarkus.hibernate-orm.jdbc.statement-batch-size=20
# CORS - Production strict
quarkus.http.cors=true
quarkus.http.cors.origins=https://afterwork.lions.dev
quarkus.http.cors.methods=GET,POST,PUT,DELETE,OPTIONS,PATCH
quarkus.http.cors.headers=accept,authorization,content-type,x-requested-with
quarkus.http.cors.exposed-headers=content-disposition
quarkus.http.cors.access-control-max-age=24H
quarkus.http.cors.access-control-allow-credentials=true
# Logging
quarkus.log.level=INFO
quarkus.log.console.enable=true
quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{3.}] (%t) %s%e%n
quarkus.log.console.json=false
quarkus.log.category."com.lions.dev".level=INFO
quarkus.log.category."org.hibernate".level=WARN
quarkus.log.category."io.quarkus".level=INFO
# OpenAPI/Swagger - Configuration build-time dans application.properties
# Health checks - Utilise les valeurs par défaut de Quarkus
# Métriques - Configuration build-time dans application.properties
# WebSocket
quarkus.websocket.max-frame-size=65536
# Upload de fichiers
quarkus.http.body.uploads-directory=/tmp/uploads
# Compression HTTP - Configuration build-time dans application.properties
# SSL/TLS (géré par le reverse proxy)
quarkus.http.ssl.certificate.files=
quarkus.http.ssl.certificate.key-files=
quarkus.http.insecure-requests=enabled
# Performance
quarkus.thread-pool.core-threads=2
quarkus.thread-pool.max-threads=16
quarkus.thread-pool.queue-size=100
# Timezone
quarkus.locales=fr-FR,en-US
quarkus.default-locale=fr-FR

View File

@@ -0,0 +1,91 @@
# ====================================================================
# AfterWork Server - Configuration PRODUCTION
# ====================================================================
# Ce fichier est automatiquement chargé avec: java -jar app.jar
# Les configurations ici surchargent celles de application.properties
# ====================================================================
# HTTP - Chemin de base de l'API
# ====================================================================
# Permet d'accéder à l'API via https://api.lions.dev/afterwork
quarkus.http.root-path=/afterwork
# ====================================================================
# Swagger/OpenAPI (Production)
# ====================================================================
# Configuration pour que Swagger UI fonctionne avec root-path
quarkus.swagger-ui.enable=true
quarkus.swagger-ui.always-include=true
quarkus.swagger-ui.path=/q/swagger-ui
# Configuration explicite de l'URL OpenAPI pour Swagger UI
# Avec root-path=/afterwork, l'OpenAPI spec est à /afterwork/openapi
quarkus.swagger-ui.urls.default=/afterwork/openapi
quarkus.smallrye-openapi.path=/openapi
# ====================================================================
# Base de données PostgreSQL
# ====================================================================
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:postgresql}:${DB_PORT:5432}/${DB_NAME:mic-after-work-server-impl-quarkus-main}
quarkus.datasource.username=${DB_USERNAME:lionsuser}
quarkus.datasource.password=${DB_PASSWORD:LionsUser2025!}
quarkus.datasource.jdbc.driver=org.postgresql.Driver
quarkus.datasource.jdbc.max-size=20
quarkus.datasource.jdbc.min-size=5
quarkus.datasource.devservices.enabled=false
# ====================================================================
# Hibernate ORM
# ====================================================================
quarkus.hibernate-orm.database.generation=update
quarkus.hibernate-orm.log.sql=false
quarkus.hibernate-orm.sql-load-script=no-file
quarkus.hibernate-orm.jdbc.statement-batch-size=20
# ====================================================================
# CORS (Cross-Origin Resource Sharing)
# ====================================================================
quarkus.http.cors=true
quarkus.http.cors.origins=https://afterwork.lions.dev
quarkus.http.cors.methods=GET,POST,PUT,DELETE,OPTIONS,PATCH
quarkus.http.cors.headers=accept,authorization,content-type,x-requested-with
quarkus.http.cors.exposed-headers=content-disposition
quarkus.http.cors.access-control-max-age=24H
quarkus.http.cors.access-control-allow-credentials=true
# ====================================================================
# Logging
# ====================================================================
quarkus.log.level=INFO
quarkus.log.console.enable=true
quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{3.}] (%t) %s%e%n
quarkus.log.console.json=false
quarkus.log.category."com.lions.dev".level=INFO
quarkus.log.category."org.hibernate".level=WARN
quarkus.log.category."io.quarkus".level=INFO
# ====================================================================
# WebSocket
# ====================================================================
# Note: La propriété quarkus.websocket.max-frame-size n'existe pas dans Quarkus 3.16
# Les WebSockets Next utilisent une configuration différente si nécessaire
# ====================================================================
# SSL/TLS (géré par le reverse proxy)
# ====================================================================
quarkus.http.ssl.certificate.files=
quarkus.http.ssl.certificate.key-files=
quarkus.http.insecure-requests=enabled
# ====================================================================
# Performance
# ====================================================================
quarkus.thread-pool.core-threads=2
quarkus.thread-pool.max-threads=16
quarkus.thread-pool.queue-size=100
# ====================================================================
# Localisation
# ====================================================================
quarkus.locales=fr-FR,en-US
quarkus.default-locale=fr-FR

View File

@@ -1,63 +1,125 @@
# Configuration Swagger UI
# ====================================================================
# AfterWork Server - Configuration Commune
# ====================================================================
# Ce fichier contient les configurations partagées par tous les environnements.
# Les configurations spécifiques sont dans :
# - application-dev.properties (développement)
# - application-prod.properties (production)
# - application-production.properties (production - profil "production")
#
# NOTE: Configuration datasource par défaut pour les tests
# Les profils dev/prod/production surchargent cette configuration
# ====================================================================
# Swagger/OpenAPI (commun à tous les environnements)
# ====================================================================
quarkus.swagger-ui.always-include=true
quarkus.swagger-ui.path=/q/swagger-ui
quarkus.smallrye-openapi.path=/openapi
# Configuration datasource par défaut (PostgreSQL) pour le build
# Les valeurs seront remplacées au runtime par les variables d'environnement
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.url=jdbc:postgresql://postgresql:5432/mic-after-work-server-impl-quarkus-main
quarkus.datasource.username=lionsuser
quarkus.datasource.password=LionsUser2025!
quarkus.datasource.jdbc.driver=org.postgresql.Driver
quarkus.hibernate-orm.database.generation=update
# ====================================================================
# HTTP (commun à tous les environnements)
# ====================================================================
quarkus.http.host=0.0.0.0
quarkus.http.port=8080
# ====================================================================
# Base de données (configuration par défaut pour les tests)
# ====================================================================
# Cette configuration est utilisée par défaut si aucun profil n'est spécifié
# Les profils dev/prod/production surchargent cette configuration
quarkus.datasource.db-kind=h2
quarkus.datasource.jdbc.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
quarkus.datasource.username=sa
quarkus.datasource.password=
quarkus.datasource.jdbc.driver=org.h2.Driver
quarkus.datasource.devservices.enabled=false
# Hibernate ORM (par défaut)
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.log.sql=false
# Configuration de la base de données H2 (en mémoire) pour Quarkus en développement
%dev.quarkus.datasource.db-kind=h2
%dev.quarkus.datasource.jdbc.url=jdbc:h2:mem:afterwork_db;DB_CLOSE_DELAY=-1
%dev.quarkus.datasource.username=sa
%dev.quarkus.datasource.password=
%dev.quarkus.hibernate-orm.database.generation=drop-and-create
%dev.quarkus.hibernate-orm.log.sql=true
%dev.quarkus.datasource.devservices.enabled=false
# Configuration PostgreSQL (production) - commentée pour les tests
# %dev.quarkus.datasource.db-kind=postgresql
# %dev.quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/afterwork_db
# %dev.quarkus.datasource.username=afterwork
# %dev.quarkus.datasource.password=@ft3rw0rk
# %dev.quarkus.datasource.jdbc.driver=org.postgresql.Driver
# Configuration de la base de données PostgreSQL pour Quarkus en production
%prod.quarkus.datasource.db-kind=postgresql
%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:postgresql}:${DB_PORT:5432}/${DB_NAME:mic-after-work-server-impl-quarkus-main}
%prod.quarkus.datasource.username=${DB_USERNAME:lionsuser}
%prod.quarkus.datasource.password=${DB_PASSWORD:LionsUser2025!}
%prod.quarkus.datasource.jdbc.driver=org.postgresql.Driver
%prod.quarkus.hibernate-orm.database.generation=update
%prod.quarkus.hibernate-orm.log.sql=false
%prod.quarkus.datasource.devservices.enabled=false
# Niveau de logging pour Quarkus en développement
%dev.quarkus.log.level=DEBUG
# Niveau de logging pour Quarkus en production
%prod.quarkus.log.level=INFO
# Configuration de la signature JWT (désactivée pour l'instant)
# mp.jwt.verify.publickey.location=META-INF/resources/publicKey.pem
# mp.jwt.verify.issuer=https://issuer.example.com
# mp.jwt.token.header=Authorization
# mp.jwt.token.schemes=Bearer
# smallrye.jwt.sign.key.location=META-INF/resources/privateKey.pem
# smallrye.jwt.sign.key.algorithm=RS256
# smallrye.jwt.token.lifetime=3600
# Activer le support multipart pour l'upload de fichiers
# ====================================================================
# Upload de fichiers (commun à tous les environnements)
# ====================================================================
quarkus.http.body.uploads-directory=/tmp/uploads
quarkus.http.limits.max-body-size=10M
# Écouter sur toutes les interfaces réseau (0.0.0.0) pour être accessible depuis le Samsung
quarkus.http.host=0.0.0.0
quarkus.http.port=8080
# ====================================================================
# WebSockets Next (commun à tous les environnements)
# ====================================================================
# Note: WebSockets Next est activé par défaut si la dépendance est présente
# La propriété quarkus.websockets-next.server.enabled n'existe pas dans Quarkus 3.16
# WebSockets Next est automatiquement activé quand quarkus-websockets-next est dans les dépendances
# ====================================================================
# Kafka Configuration (commun à tous les environnements)
# ====================================================================
kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
# ====================================================================
# Kafka Topics - Outgoing (Services → Kafka)
# ====================================================================
# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter<EventType>
# Topic: Notifications
mp.messaging.outgoing.notifications.connector=smallrye-kafka
mp.messaging.outgoing.notifications.topic=notifications
mp.messaging.outgoing.notifications.key.serializer=org.apache.kafka.common.serialization.StringSerializer
# value.serializer omis - Quarkus génère automatiquement depuis Emitter<NotificationEvent>
# Topic: Chat Messages
mp.messaging.outgoing.chat-messages.connector=smallrye-kafka
mp.messaging.outgoing.chat-messages.topic=chat.messages
mp.messaging.outgoing.chat-messages.key.serializer=org.apache.kafka.common.serialization.StringSerializer
# value.serializer omis - Quarkus génère automatiquement depuis Emitter<ChatMessageEvent>
# Topic: Reactions (likes, comments, shares)
mp.messaging.outgoing.reactions.connector=smallrye-kafka
mp.messaging.outgoing.reactions.topic=reactions
mp.messaging.outgoing.reactions.key.serializer=org.apache.kafka.common.serialization.StringSerializer
# value.serializer omis - Quarkus génère automatiquement depuis Emitter<ReactionEvent>
# Topic: Presence Updates
mp.messaging.outgoing.presence.connector=smallrye-kafka
mp.messaging.outgoing.presence.topic=presence.updates
mp.messaging.outgoing.presence.key.serializer=org.apache.kafka.common.serialization.StringSerializer
# value.serializer omis - Quarkus génère automatiquement depuis Emitter<PresenceEvent>
# ====================================================================
# Kafka Topics - Incoming (Kafka → WebSocket Bridge)
# ====================================================================
# Consommer depuis Kafka et router vers WebSocket pour notifications
# Note: Quarkus génère automatiquement les deserializers Jackson basés sur le type générique Message<NotificationEvent>
mp.messaging.incoming.kafka-notifications.connector=smallrye-kafka
mp.messaging.incoming.kafka-notifications.topic=notifications
mp.messaging.incoming.kafka-notifications.group.id=websocket-notifications-bridge
mp.messaging.incoming.kafka-notifications.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
# value.deserializer omis - Quarkus génère automatiquement depuis Message<NotificationEvent>
mp.messaging.incoming.kafka-notifications.enable.auto.commit=true
# Consommer depuis Kafka et router vers WebSocket pour chat
# Note: Quarkus génère automatiquement les deserializers Jackson basés sur le type générique Message<ChatMessageEvent>
mp.messaging.incoming.kafka-chat.connector=smallrye-kafka
mp.messaging.incoming.kafka-chat.topic=chat.messages
mp.messaging.incoming.kafka-chat.group.id=websocket-chat-bridge
mp.messaging.incoming.kafka-chat.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
# value.deserializer omis - Quarkus génère automatiquement depuis Message<ChatMessageEvent>
mp.messaging.incoming.kafka-chat.enable.auto.commit=true
# Consommer depuis Kafka et router vers WebSocket pour réactions
# Note: Quarkus génère automatiquement les deserializers Jackson basés sur le type générique Message<ReactionEvent>
mp.messaging.incoming.kafka-reactions.connector=smallrye-kafka
mp.messaging.incoming.kafka-reactions.topic=reactions
mp.messaging.incoming.kafka-reactions.group.id=websocket-reactions-bridge
mp.messaging.incoming.kafka-reactions.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
# value.deserializer omis - Quarkus génère automatiquement depuis Message<ReactionEvent>
mp.messaging.incoming.kafka-reactions.enable.auto.commit=true
# Consommer depuis Kafka et router vers WebSocket pour présence
# Note: Quarkus génère automatiquement les deserializers Jackson basés sur le type générique Message<PresenceEvent>
mp.messaging.incoming.kafka-presence.connector=smallrye-kafka
mp.messaging.incoming.kafka-presence.topic=presence.updates
mp.messaging.incoming.kafka-presence.group.id=websocket-presence-bridge
mp.messaging.incoming.kafka-presence.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
# value.deserializer omis - Quarkus génère automatiquement depuis Message<PresenceEvent>
mp.messaging.incoming.kafka-presence.enable.auto.commit=true

View File

@@ -0,0 +1,81 @@
-- Migration V10: Création de la table bookings pour remplacer/étendre reservations
-- Date: 2026-01-15
-- Description: Nouvelle table pour les réservations avec plus de détails (v2.0)
-- Note: Cette migration crée une nouvelle table "bookings" qui remplace/étend "reservations"
-- La table reservations peut être conservée pour compatibilité ou migrée progressivement
CREATE TABLE IF NOT EXISTS bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
establishment_id UUID NOT NULL,
user_id UUID NOT NULL,
reservation_time TIMESTAMP NOT NULL, -- Date et heure de la réservation
guest_count INTEGER NOT NULL DEFAULT 1, -- Nombre de convives
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, CONFIRMED, CANCELLED, COMPLETED
special_requests TEXT, -- Demandes spéciales (allergies, préférences, etc.)
table_number VARCHAR(20), -- Numéro de table assigné (si applicable)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_bookings_establishment
FOREIGN KEY (establishment_id)
REFERENCES establishments(id)
ON DELETE CASCADE,
CONSTRAINT fk_bookings_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT chk_bookings_guest_count
CHECK (guest_count > 0),
CONSTRAINT chk_bookings_status
CHECK (status IN ('PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED'))
);
-- Créer les index pour améliorer les performances
CREATE INDEX IF NOT EXISTS idx_bookings_establishment
ON bookings(establishment_id);
CREATE INDEX IF NOT EXISTS idx_bookings_user
ON bookings(user_id);
CREATE INDEX IF NOT EXISTS idx_bookings_status
ON bookings(status);
CREATE INDEX IF NOT EXISTS idx_bookings_reservation_time
ON bookings(reservation_time);
CREATE INDEX IF NOT EXISTS idx_bookings_establishment_time
ON bookings(establishment_id, reservation_time);
-- Index pour les requêtes de réservations à venir
CREATE INDEX IF NOT EXISTS idx_bookings_upcoming
ON bookings(establishment_id, reservation_time)
WHERE reservation_time >= CURRENT_TIMESTAMP AND status IN ('PENDING', 'CONFIRMED');
-- Créer un trigger pour mettre à jour updated_at automatiquement
CREATE OR REPLACE FUNCTION update_bookings_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_bookings_updated_at
BEFORE UPDATE ON bookings
FOR EACH ROW
EXECUTE FUNCTION update_bookings_updated_at();
-- Commentaires pour documentation
COMMENT ON TABLE bookings IS 'Réservations d''établissements (v2.0 - remplace/étend reservations)';
COMMENT ON COLUMN bookings.establishment_id IS 'Établissement concerné par la réservation';
COMMENT ON COLUMN bookings.user_id IS 'Utilisateur qui a fait la réservation';
COMMENT ON COLUMN bookings.reservation_time IS 'Date et heure de la réservation';
COMMENT ON COLUMN bookings.guest_count IS 'Nombre de convives';
COMMENT ON COLUMN bookings.status IS 'Statut: PENDING, CONFIRMED, CANCELLED, COMPLETED';
COMMENT ON COLUMN bookings.special_requests IS 'Demandes spéciales (allergies, préférences, etc.)';
COMMENT ON COLUMN bookings.table_number IS 'Numéro de table assigné (si applicable)';

View File

@@ -0,0 +1,80 @@
-- Migration V11: Création de la table promotions pour les offres spéciales
-- Date: 2026-01-15
-- Description: Table pour gérer les promotions et happy hours des établissements (v2.0)
CREATE TABLE IF NOT EXISTS promotions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
establishment_id UUID NOT NULL,
title VARCHAR(200) NOT NULL, -- Titre de la promotion (ex: "Happy Hour", "Menu du jour")
description TEXT, -- Description détaillée de la promotion
promo_code VARCHAR(50) UNIQUE, -- Code promo optionnel (ex: "HAPPY2024")
discount_type VARCHAR(20) NOT NULL, -- PERCENTAGE, FIXED_AMOUNT, FREE_ITEM
discount_value NUMERIC(10,2) NOT NULL, -- Valeur de la réduction (pourcentage, montant fixe, etc.)
valid_from TIMESTAMP NOT NULL, -- Date de début de validité
valid_until TIMESTAMP NOT NULL, -- Date de fin de validité
is_active BOOLEAN DEFAULT true NOT NULL, -- Indique si la promotion est active
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_promotions_establishment
FOREIGN KEY (establishment_id)
REFERENCES establishments(id)
ON DELETE CASCADE,
CONSTRAINT chk_promotions_discount_type
CHECK (discount_type IN ('PERCENTAGE', 'FIXED_AMOUNT', 'FREE_ITEM')),
CONSTRAINT chk_promotions_discount_value
CHECK (discount_value >= 0),
CONSTRAINT chk_promotions_valid_dates
CHECK (valid_until > valid_from)
);
-- Créer les index pour améliorer les performances
CREATE INDEX IF NOT EXISTS idx_promotions_establishment
ON promotions(establishment_id);
CREATE INDEX IF NOT EXISTS idx_promotions_active
ON promotions(establishment_id, is_active)
WHERE is_active = true;
CREATE INDEX IF NOT EXISTS idx_promotions_valid_dates
ON promotions(establishment_id, valid_from, valid_until);
-- Index pour les promotions actives et valides
CREATE INDEX IF NOT EXISTS idx_promotions_active_valid
ON promotions(establishment_id, valid_from, valid_until)
WHERE is_active = true AND valid_from <= CURRENT_TIMESTAMP AND valid_until >= CURRENT_TIMESTAMP;
-- Index pour les recherches par code promo
CREATE INDEX IF NOT EXISTS idx_promotions_promo_code
ON promotions(promo_code)
WHERE promo_code IS NOT NULL;
-- Créer un trigger pour mettre à jour updated_at automatiquement
CREATE OR REPLACE FUNCTION update_promotions_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_promotions_updated_at
BEFORE UPDATE ON promotions
FOR EACH ROW
EXECUTE FUNCTION update_promotions_updated_at();
-- Commentaires pour documentation
COMMENT ON TABLE promotions IS 'Promotions et offres spéciales des établissements (v2.0)';
COMMENT ON COLUMN promotions.establishment_id IS 'Établissement qui propose la promotion';
COMMENT ON COLUMN promotions.title IS 'Titre de la promotion';
COMMENT ON COLUMN promotions.description IS 'Description détaillée de la promotion';
COMMENT ON COLUMN promotions.promo_code IS 'Code promo optionnel pour activer la promotion';
COMMENT ON COLUMN promotions.discount_type IS 'Type de réduction: PERCENTAGE, FIXED_AMOUNT, FREE_ITEM';
COMMENT ON COLUMN promotions.discount_value IS 'Valeur de la réduction (pourcentage, montant fixe, etc.)';
COMMENT ON COLUMN promotions.valid_from IS 'Date de début de validité';
COMMENT ON COLUMN promotions.valid_until IS 'Date de fin de validité';
COMMENT ON COLUMN promotions.is_active IS 'Indique si la promotion est active';

View File

@@ -0,0 +1,46 @@
-- Migration pour ajouter les champs supplémentaires à la table events
-- Date: 2026-01-12
-- Description: Ajout des champs manquants pour la création complète d'événements
-- Ajouter la colonne max_participants
ALTER TABLE events ADD COLUMN IF NOT EXISTS max_participants INTEGER;
-- Ajouter la colonne tags
ALTER TABLE events ADD COLUMN IF NOT EXISTS tags VARCHAR(500);
-- Ajouter la colonne organizer
ALTER TABLE events ADD COLUMN IF NOT EXISTS organizer VARCHAR(200);
-- Ajouter la colonne participation_fee
ALTER TABLE events ADD COLUMN IF NOT EXISTS participation_fee INTEGER;
-- Ajouter la colonne privacy_rules
ALTER TABLE events ADD COLUMN IF NOT EXISTS privacy_rules VARCHAR(1000);
-- Ajouter la colonne transport_info
ALTER TABLE events ADD COLUMN IF NOT EXISTS transport_info VARCHAR(1000);
-- Ajouter la colonne accommodation_info
ALTER TABLE events ADD COLUMN IF NOT EXISTS accommodation_info VARCHAR(1000);
-- Ajouter la colonne accessibility_info
ALTER TABLE events ADD COLUMN IF NOT EXISTS accessibility_info VARCHAR(1000);
-- Ajouter la colonne parking_info
ALTER TABLE events ADD COLUMN IF NOT EXISTS parking_info VARCHAR(1000);
-- Ajouter la colonne security_protocol
ALTER TABLE events ADD COLUMN IF NOT EXISTS security_protocol VARCHAR(1000);
-- Commentaires pour documentation
COMMENT ON COLUMN events.max_participants IS 'Nombre maximum de participants autorisés';
COMMENT ON COLUMN events.tags IS 'Tags/mots-clés associés à l''événement (séparés par des virgules)';
COMMENT ON COLUMN events.organizer IS 'Nom de l''organisateur de l''événement';
COMMENT ON COLUMN events.participation_fee IS 'Frais de participation en centimes';
COMMENT ON COLUMN events.privacy_rules IS 'Règles de confidentialité de l''événement';
COMMENT ON COLUMN events.transport_info IS 'Informations sur les transports disponibles';
COMMENT ON COLUMN events.accommodation_info IS 'Informations sur l''hébergement';
COMMENT ON COLUMN events.accessibility_info IS 'Informations sur l''accessibilité';
COMMENT ON COLUMN events.parking_info IS 'Informations sur le parking';
COMMENT ON COLUMN events.security_protocol IS 'Protocole de sécurité de l''événement';

View File

@@ -0,0 +1,48 @@
-- Migration V3: Migration de la table users vers l'architecture v2.0
-- Date: 2026-01-15
-- Description: Renommage des colonnes et ajout des nouveaux champs pour l'entité User
-- Renommer les colonnes existantes
ALTER TABLE users RENAME COLUMN nom TO first_name;
ALTER TABLE users RENAME COLUMN prenoms TO last_name;
ALTER TABLE users RENAME COLUMN mot_de_passe TO password_hash;
-- Ajouter les nouvelles colonnes
ALTER TABLE users ADD COLUMN IF NOT EXISTS bio VARCHAR(500);
ALTER TABLE users ADD COLUMN IF NOT EXISTS loyalty_points INTEGER DEFAULT 0 NOT NULL;
-- Ajouter la colonne preferences en JSONB (PostgreSQL)
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences JSONB DEFAULT '{}'::jsonb NOT NULL;
-- Migrer les données de preferred_category vers preferences si nécessaire
-- Note: Cette migration préserve les données existantes
UPDATE users
SET preferences = jsonb_build_object('preferred_category', preferred_category)
WHERE preferred_category IS NOT NULL
AND (preferences IS NULL OR preferences = '{}'::jsonb);
-- Supprimer la colonne obsolète preferred_category
-- Note: On vérifie d'abord si la colonne existe pour éviter les erreurs
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'users'
AND column_name = 'preferred_category'
) THEN
ALTER TABLE users DROP COLUMN preferred_category;
END IF;
END $$;
-- Créer un index sur preferences si nécessaire (pour les requêtes JSONB)
CREATE INDEX IF NOT EXISTS idx_users_preferences ON users USING GIN (preferences);
-- Commentaires pour documentation
COMMENT ON COLUMN users.first_name IS 'Prénom de l''utilisateur (v2.0)';
COMMENT ON COLUMN users.last_name IS 'Nom de famille de l''utilisateur (v2.0)';
COMMENT ON COLUMN users.password_hash IS 'Mot de passe hashé avec BCrypt (v2.0)';
COMMENT ON COLUMN users.bio IS 'Biographie courte de l''utilisateur (v2.0)';
COMMENT ON COLUMN users.loyalty_points IS 'Points de fidélité accumulés (v2.0)';
COMMENT ON COLUMN users.preferences IS 'Préférences utilisateur en JSONB (v2.0)';

View File

@@ -0,0 +1,74 @@
-- Migration V4: Migration de la table establishments vers l'architecture v2.0
-- Date: 2026-01-15
-- Description: Renommage des colonnes, ajout de verification_status, suppression des colonnes obsolètes
-- Renommer la colonne total_ratings_count vers total_reviews_count
ALTER TABLE establishments RENAME COLUMN total_ratings_count TO total_reviews_count;
-- Ajouter la colonne verification_status
ALTER TABLE establishments ADD COLUMN IF NOT EXISTS verification_status VARCHAR(20) DEFAULT 'PENDING' NOT NULL;
-- Supprimer les colonnes obsolètes (avec vérification pour éviter les erreurs)
DO $$
BEGIN
-- Supprimer rating (déprécié, utiliser average_rating)
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'establishments' AND column_name = 'rating'
) THEN
ALTER TABLE establishments DROP COLUMN rating;
END IF;
-- Supprimer email (délégué à la table users via manager_id)
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'establishments' AND column_name = 'email'
) THEN
ALTER TABLE establishments DROP COLUMN email;
END IF;
-- Supprimer image_url (utiliser establishment_media à la place)
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'establishments' AND column_name = 'image_url'
) THEN
ALTER TABLE establishments DROP COLUMN image_url;
END IF;
-- Supprimer capacity (non utilisé dans v2.0)
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'establishments' AND column_name = 'capacity'
) THEN
ALTER TABLE establishments DROP COLUMN capacity;
END IF;
-- Supprimer amenities (délégué à la table establishment_amenities)
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'establishments' AND column_name = 'amenities'
) THEN
-- Optionnel: Migrer les données vers establishment_amenities avant suppression
-- (à implémenter si nécessaire)
ALTER TABLE establishments DROP COLUMN amenities;
END IF;
-- Supprimer opening_hours (délégué à la table business_hours)
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'establishments' AND column_name = 'opening_hours'
) THEN
-- Optionnel: Migrer les données vers business_hours avant suppression
-- (à implémenter si nécessaire)
ALTER TABLE establishments DROP COLUMN opening_hours;
END IF;
END $$;
-- Créer un index sur verification_status pour les requêtes de filtrage
CREATE INDEX IF NOT EXISTS idx_establishments_verification_status
ON establishments(verification_status);
-- Commentaires pour documentation
COMMENT ON COLUMN establishments.total_reviews_count IS 'Nombre total d''avis (v2.0 - renommé depuis total_ratings_count)';
COMMENT ON COLUMN establishments.verification_status IS 'Statut de vérification: PENDING, VERIFIED, REJECTED (v2.0)';

View File

@@ -0,0 +1,64 @@
-- Migration V5: Création de la table business_hours pour gérer les horaires d'ouverture
-- Date: 2026-01-15
-- Description: Table dédiée pour les horaires d'ouverture des établissements (v2.0)
CREATE TABLE IF NOT EXISTS business_hours (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
establishment_id UUID NOT NULL,
day_of_week VARCHAR(20) NOT NULL, -- MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
open_time VARCHAR(5) NOT NULL, -- Format HH:MM (ex: "09:00")
close_time VARCHAR(5) NOT NULL, -- Format HH:MM (ex: "18:00")
is_closed BOOLEAN DEFAULT false NOT NULL,
is_exception BOOLEAN DEFAULT false NOT NULL, -- Pour les jours exceptionnels (fermetures temporaires)
exception_date TIMESTAMP, -- Date de l'exception (si is_exception = true)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_business_hours_establishment
FOREIGN KEY (establishment_id)
REFERENCES establishments(id)
ON DELETE CASCADE,
CONSTRAINT chk_business_hours_time_format
CHECK (open_time ~ '^([0-1][0-9]|2[0-3]):[0-5][0-9]$'
AND close_time ~ '^([0-1][0-9]|2[0-3]):[0-5][0-9]$'),
CONSTRAINT chk_business_hours_day_of_week
CHECK (day_of_week IN ('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'))
);
-- Créer les index pour améliorer les performances
CREATE INDEX IF NOT EXISTS idx_business_hours_establishment
ON business_hours(establishment_id);
CREATE INDEX IF NOT EXISTS idx_business_hours_day_of_week
ON business_hours(day_of_week);
CREATE INDEX IF NOT EXISTS idx_business_hours_exception
ON business_hours(establishment_id, exception_date)
WHERE is_exception = true;
-- Créer un trigger pour mettre à jour updated_at automatiquement
CREATE OR REPLACE FUNCTION update_business_hours_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_business_hours_updated_at
BEFORE UPDATE ON business_hours
FOR EACH ROW
EXECUTE FUNCTION update_business_hours_updated_at();
-- Commentaires pour documentation
COMMENT ON TABLE business_hours IS 'Horaires d''ouverture des établissements (v2.0)';
COMMENT ON COLUMN business_hours.establishment_id IS 'Référence vers l''établissement';
COMMENT ON COLUMN business_hours.day_of_week IS 'Jour de la semaine (MONDAY à SUNDAY)';
COMMENT ON COLUMN business_hours.open_time IS 'Heure d''ouverture (format HH:MM)';
COMMENT ON COLUMN business_hours.close_time IS 'Heure de fermeture (format HH:MM)';
COMMENT ON COLUMN business_hours.is_closed IS 'Indique si l''établissement est fermé ce jour';
COMMENT ON COLUMN business_hours.is_exception IS 'Indique si c''est un jour exceptionnel';
COMMENT ON COLUMN business_hours.exception_date IS 'Date de l''exception (si is_exception = true)';

View File

@@ -0,0 +1,65 @@
-- Migration V6: Création de la table establishment_amenities pour gérer les équipements
-- Date: 2026-01-15
-- Description: Table de liaison pour les équipements des établissements (v2.0)
-- Créer d'abord une table de référence pour les types d'équipements (optionnel mais recommandé)
CREATE TABLE IF NOT EXISTS amenity_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE, -- Ex: "WiFi", "Parking", "Terrasse", "Climatisation"
category VARCHAR(50), -- Ex: "Comfort", "Accessibility", "Entertainment"
icon VARCHAR(50), -- Nom de l'icône à utiliser dans l'UI
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT chk_amenity_types_name_not_empty CHECK (LENGTH(TRIM(name)) > 0)
);
-- Créer la table de liaison establishment_amenities
CREATE TABLE IF NOT EXISTS establishment_amenities (
establishment_id UUID NOT NULL,
amenity_id UUID NOT NULL,
details VARCHAR(500), -- Détails supplémentaires (ex: "Parking gratuit pour 20 voitures")
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (establishment_id, amenity_id),
CONSTRAINT fk_establishment_amenities_establishment
FOREIGN KEY (establishment_id)
REFERENCES establishments(id)
ON DELETE CASCADE,
CONSTRAINT fk_establishment_amenities_amenity
FOREIGN KEY (amenity_id)
REFERENCES amenity_types(id)
ON DELETE CASCADE
);
-- Créer les index pour améliorer les performances
CREATE INDEX IF NOT EXISTS idx_establishment_amenities_establishment
ON establishment_amenities(establishment_id);
CREATE INDEX IF NOT EXISTS idx_establishment_amenities_amenity
ON establishment_amenities(amenity_id);
-- Insérer quelques types d'équipements courants (optionnel)
INSERT INTO amenity_types (name, category, icon) VALUES
('WiFi', 'Comfort', 'wifi'),
('Parking', 'Accessibility', 'parking'),
('Terrasse', 'Comfort', 'terrace'),
('Climatisation', 'Comfort', 'ac'),
('Chauffage', 'Comfort', 'heating'),
('Accessible PMR', 'Accessibility', 'accessible'),
('Animaux acceptés', 'Comfort', 'pets'),
('Réservation en ligne', 'Service', 'online_booking'),
('Service de livraison', 'Service', 'delivery'),
('Bar', 'Entertainment', 'bar'),
('Musique live', 'Entertainment', 'live_music'),
('Écrans TV', 'Entertainment', 'tv')
ON CONFLICT (name) DO NOTHING; -- Éviter les doublons
-- Commentaires pour documentation
COMMENT ON TABLE amenity_types IS 'Types d''équipements disponibles pour les établissements (v2.0)';
COMMENT ON TABLE establishment_amenities IS 'Liaison entre établissements et équipements (v2.0)';
COMMENT ON COLUMN establishment_amenities.establishment_id IS 'Référence vers l''établissement';
COMMENT ON COLUMN establishment_amenities.amenity_id IS 'Référence vers le type d''équipement';
COMMENT ON COLUMN establishment_amenities.details IS 'Détails supplémentaires sur l''équipement';

View File

@@ -0,0 +1,55 @@
-- Migration V7: Migration de la table events vers l'architecture v2.0
-- Date: 2026-01-15
-- Description: Ajout de establishment_id, is_private, waitlist_enabled et suppression de location
-- Ajouter la colonne establishment_id (FK vers establishments)
ALTER TABLE events ADD COLUMN IF NOT EXISTS establishment_id UUID;
-- Créer la contrainte de clé étrangère pour establishment_id
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_events_establishment'
) THEN
ALTER TABLE events
ADD CONSTRAINT fk_events_establishment
FOREIGN KEY (establishment_id)
REFERENCES establishments(id)
ON DELETE SET NULL; -- Si l'établissement est supprimé, mettre NULL plutôt que supprimer l'événement
END IF;
END $$;
-- Ajouter la colonne is_private
ALTER TABLE events ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT false NOT NULL;
-- Ajouter la colonne waitlist_enabled
ALTER TABLE events ADD COLUMN IF NOT EXISTS waitlist_enabled BOOLEAN DEFAULT false NOT NULL;
-- Créer un index sur establishment_id pour améliorer les performances
CREATE INDEX IF NOT EXISTS idx_events_establishment
ON events(establishment_id);
-- Créer un index sur is_private pour les requêtes de filtrage
CREATE INDEX IF NOT EXISTS idx_events_is_private
ON events(is_private);
-- Supprimer la colonne location (déléguée à Establishment via establishment_id)
DO $$
BEGIN
-- Optionnel: Migrer les données de location vers establishment_id si possible
-- (nécessite une logique de mapping personnalisée)
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'events' AND column_name = 'location'
) THEN
ALTER TABLE events DROP COLUMN location;
END IF;
END $$;
-- Commentaires pour documentation
COMMENT ON COLUMN events.establishment_id IS 'Référence vers l''établissement où se déroule l''événement (v2.0)';
COMMENT ON COLUMN events.is_private IS 'Indique si l''événement est privé (v2.0)';
COMMENT ON COLUMN events.waitlist_enabled IS 'Indique si la liste d''attente est activée (v2.0)';

View File

@@ -0,0 +1,97 @@
-- Migration V8: Création de la table reviews pour remplacer establishment_ratings
-- Date: 2026-01-15
-- Description: Nouvelle table pour les avis détaillés avec critères (v2.0)
-- Note: Cette migration crée une nouvelle table "reviews" qui remplace "establishment_ratings"
-- La table establishment_ratings peut être conservée pour compatibilité ou migrée progressivement
CREATE TABLE IF NOT EXISTS reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
establishment_id UUID NOT NULL,
overall_rating INTEGER NOT NULL, -- Note globale (1-5)
comment TEXT, -- Commentaire libre
criteria_ratings JSONB DEFAULT '{}'::jsonb NOT NULL, -- Notes par critères (ex: {"ambiance": 4, "service": 5, "qualite": 4})
is_verified_visit BOOLEAN DEFAULT false NOT NULL, -- Indique si l'avis est lié à une visite vérifiée (réservation complétée)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_reviews_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT fk_reviews_establishment
FOREIGN KEY (establishment_id)
REFERENCES establishments(id)
ON DELETE CASCADE,
CONSTRAINT chk_reviews_overall_rating_range
CHECK (overall_rating >= 1 AND overall_rating <= 5),
-- Un utilisateur ne peut avoir qu'un seul avis par établissement
CONSTRAINT uq_reviews_user_establishment
UNIQUE (user_id, establishment_id)
);
-- Créer les index pour améliorer les performances
CREATE INDEX IF NOT EXISTS idx_reviews_user
ON reviews(user_id);
CREATE INDEX IF NOT EXISTS idx_reviews_establishment
ON reviews(establishment_id);
CREATE INDEX IF NOT EXISTS idx_reviews_overall_rating
ON reviews(establishment_id, overall_rating);
CREATE INDEX IF NOT EXISTS idx_reviews_created_at
ON reviews(created_at DESC);
-- Index GIN pour les requêtes JSONB sur criteria_ratings
CREATE INDEX IF NOT EXISTS idx_reviews_criteria_ratings
ON reviews USING GIN (criteria_ratings);
-- Index pour les avis vérifiés
CREATE INDEX IF NOT EXISTS idx_reviews_verified_visit
ON reviews(establishment_id, is_verified_visit)
WHERE is_verified_visit = true;
-- Créer un trigger pour mettre à jour updated_at automatiquement
CREATE OR REPLACE FUNCTION update_reviews_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_reviews_updated_at
BEFORE UPDATE ON reviews
FOR EACH ROW
EXECUTE FUNCTION update_reviews_updated_at();
-- Créer une vue pour calculer les statistiques d'avis par établissement
CREATE OR REPLACE VIEW establishment_review_stats AS
SELECT
establishment_id,
COUNT(*) as total_reviews,
AVG(overall_rating)::NUMERIC(3,2) as average_rating,
COUNT(*) FILTER (WHERE is_verified_visit = true) as verified_reviews_count,
COUNT(*) FILTER (WHERE overall_rating = 5) as five_star_count,
COUNT(*) FILTER (WHERE overall_rating = 4) as four_star_count,
COUNT(*) FILTER (WHERE overall_rating = 3) as three_star_count,
COUNT(*) FILTER (WHERE overall_rating = 2) as two_star_count,
COUNT(*) FILTER (WHERE overall_rating = 1) as one_star_count
FROM reviews
GROUP BY establishment_id;
-- Commentaires pour documentation
COMMENT ON TABLE reviews IS 'Avis détaillés sur les établissements (v2.0 - remplace establishment_ratings)';
COMMENT ON COLUMN reviews.user_id IS 'Utilisateur qui a écrit l''avis';
COMMENT ON COLUMN reviews.establishment_id IS 'Établissement concerné par l''avis';
COMMENT ON COLUMN reviews.overall_rating IS 'Note globale sur 5';
COMMENT ON COLUMN reviews.comment IS 'Commentaire libre de l''utilisateur';
COMMENT ON COLUMN reviews.criteria_ratings IS 'Notes par critères en JSONB (ex: {"ambiance": 4, "service": 5})';
COMMENT ON COLUMN reviews.is_verified_visit IS 'Indique si l''avis est lié à une visite vérifiée (réservation complétée)';
COMMENT ON VIEW establishment_review_stats IS 'Statistiques d''avis par établissement (calculées automatiquement)';

View File

@@ -0,0 +1,64 @@
-- Migration V9: Création de la table reactions pour remplacer les compteurs de réactions
-- Date: 2026-01-15
-- Description: Table pour gérer les réactions des utilisateurs (like, love, etc.) sur différents contenus (v2.0)
CREATE TABLE IF NOT EXISTS reactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
reaction_type VARCHAR(20) NOT NULL, -- LIKE, LOVE, HAHA, WOW, SAD, ANGRY
target_type VARCHAR(20) NOT NULL, -- POST, EVENT, COMMENT, REVIEW
target_id UUID NOT NULL, -- ID du contenu ciblé (post_id, event_id, comment_id, review_id)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_reactions_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT chk_reactions_type
CHECK (reaction_type IN ('LIKE', 'LOVE', 'HAHA', 'WOW', 'SAD', 'ANGRY')),
CONSTRAINT chk_reactions_target_type
CHECK (target_type IN ('POST', 'EVENT', 'COMMENT', 'REVIEW')),
-- Un utilisateur ne peut avoir qu'une seule réaction par contenu
-- (mais peut changer de type de réaction)
CONSTRAINT uq_reactions_user_target
UNIQUE (user_id, target_type, target_id)
);
-- Créer les index pour améliorer les performances
CREATE INDEX IF NOT EXISTS idx_reactions_user
ON reactions(user_id);
CREATE INDEX IF NOT EXISTS idx_reactions_target
ON reactions(target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_reactions_type
ON reactions(reaction_type);
CREATE INDEX IF NOT EXISTS idx_reactions_created_at
ON reactions(created_at DESC);
-- Index composite pour les requêtes fréquentes (compter les réactions par type et cible)
CREATE INDEX IF NOT EXISTS idx_reactions_target_type
ON reactions(target_type, target_id, reaction_type);
-- Créer une vue pour compter les réactions par type et cible
CREATE OR REPLACE VIEW reaction_counts AS
SELECT
target_type,
target_id,
reaction_type,
COUNT(*) as count
FROM reactions
GROUP BY target_type, target_id, reaction_type;
-- Commentaires pour documentation
COMMENT ON TABLE reactions IS 'Réactions des utilisateurs sur différents contenus (v2.0 - remplace les compteurs)';
COMMENT ON COLUMN reactions.user_id IS 'Utilisateur qui a réagi';
COMMENT ON COLUMN reactions.reaction_type IS 'Type de réaction: LIKE, LOVE, HAHA, WOW, SAD, ANGRY';
COMMENT ON COLUMN reactions.target_type IS 'Type de contenu ciblé: POST, EVENT, COMMENT, REVIEW';
COMMENT ON COLUMN reactions.target_id IS 'ID du contenu ciblé';
COMMENT ON VIEW reaction_counts IS 'Compteurs de réactions par type et cible (calculés automatiquement)';