feat: v2.0 – réorg docker/scripts, prod, résas, abonnements Wave, Flyway base vierge

This commit is contained in:
dahoud
2026-01-29 00:44:40 +00:00
parent 9d5e388efa
commit ce89face73
66 changed files with 2333 additions and 227 deletions

View File

@@ -0,0 +1,59 @@
package com.lions.dev.config;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.UsersRepository;
import com.lions.dev.util.UserRoles;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
/**
* Crée le super administrateur au démarrage de l'application si aucun n'existe.
* Email et mot de passe configurables (variables d'environnement en production).
*/
@ApplicationScoped
public class SuperAdminStartup {
private static final Logger LOG = Logger.getLogger(SuperAdminStartup.class);
@Inject
UsersRepository usersRepository;
@ConfigProperty(name = "afterwork.super-admin.email", defaultValue = "superadmin@afterwork.lions.dev")
String superAdminEmail;
@ConfigProperty(name = "afterwork.super-admin.password", defaultValue = "SuperAdmin2025!")
String superAdminPassword;
@ConfigProperty(name = "afterwork.super-admin.first-name", defaultValue = "Super")
String superAdminFirstName;
@ConfigProperty(name = "afterwork.super-admin.last-name", defaultValue = "Administrator")
String superAdminLastName;
@Transactional
void onStart(@Observes StartupEvent event) {
if (usersRepository.findByEmail(superAdminEmail).isPresent()) {
LOG.info("Super administrateur déjà présent (email: " + superAdminEmail + "). Aucune création.");
return;
}
Users superAdmin = new Users();
superAdmin.setFirstName(superAdminFirstName);
superAdmin.setLastName(superAdminLastName);
superAdmin.setEmail(superAdminEmail);
superAdmin.setPassword(superAdminPassword);
superAdmin.setRole(UserRoles.SUPER_ADMIN);
superAdmin.setProfileImageUrl("https://placehold.co/150x150.png");
superAdmin.setBio("Super administrateur AfterWork");
superAdmin.setLoyaltyPoints(0);
superAdmin.setVerified(true);
usersRepository.persist(superAdmin);
LOG.info("Super administrateur créé au démarrage : " + superAdminEmail + " (role: " + UserRoles.SUPER_ADMIN + ")");
}
}

View File

@@ -0,0 +1,66 @@
package com.lions.dev.dto.request.booking;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
/**
* DTO de création de réservation (aligné frontend).
*/
public class ReservationCreateRequestDTO {
@NotNull(message = "userId est obligatoire")
private UUID userId;
@NotNull(message = "establishmentId est obligatoire")
private UUID establishmentId;
/** Date/heure de réservation (ISO-8601 ou timestamp). */
private String reservationDate;
@Min(1)
private int numberOfPeople = 1;
private String notes;
public UUID getUserId() {
return userId;
}
public void setUserId(UUID userId) {
this.userId = userId;
}
public UUID getEstablishmentId() {
return establishmentId;
}
public void setEstablishmentId(UUID establishmentId) {
this.establishmentId = establishmentId;
}
public String getReservationDate() {
return reservationDate;
}
public void setReservationDate(String reservationDate) {
this.reservationDate = reservationDate;
}
public int getNumberOfPeople() {
return numberOfPeople;
}
public void setNumberOfPeople(int numberOfPeople) {
this.numberOfPeople = numberOfPeople;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
}

View File

@@ -0,0 +1,25 @@
package com.lions.dev.dto.request.establishment;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Requête pour initier un paiement Wave (droits d'accès établissement).
*/
@Getter
@Setter
@NoArgsConstructor
public class InitiateSubscriptionRequestDTO {
/** Plan : MONTHLY, YEARLY */
@NotBlank(message = "Le plan est obligatoire")
@Pattern(regexp = "MONTHLY|YEARLY", message = "Plan invalide. Valeurs : MONTHLY, YEARLY")
private String plan;
/** Numéro de téléphone client au format international (ex. 221771234567). */
@NotBlank(message = "Le numéro de téléphone client est obligatoire pour Wave")
private String clientPhone;
}

View File

@@ -1,5 +1,6 @@
package com.lions.dev.dto.request.friends;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
@@ -15,7 +16,10 @@ import java.util.UUID;
@NoArgsConstructor
public class FriendshipCreateOneRequestDTO {
@NotNull(message = "L'identifiant de l'utilisateur est requis")
private UUID userId; // ID de l'utilisateur qui envoie la demande
@NotNull(message = "L'identifiant de l'ami est requis")
private UUID friendId; // ID de l'utilisateur qui reçoit la demande
/**

View File

@@ -0,0 +1,26 @@
package com.lions.dev.dto.request.users;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* DTO pour l'attribution d'un rôle à un utilisateur (opération réservée au super admin).
*/
@Getter
@Setter
@NoArgsConstructor
public class AssignRoleRequestDTO {
private static final String ROLE_PATTERN = "SUPER_ADMIN|ADMIN|MANAGER|USER";
@NotBlank(message = "Le rôle est obligatoire")
@Pattern(regexp = ROLE_PATTERN, message = "Rôle invalide. Valeurs autorisées : SUPER_ADMIN, ADMIN, MANAGER, USER")
private String role;
public AssignRoleRequestDTO(String role) {
this.role = role;
}
}

View File

@@ -0,0 +1,20 @@
package com.lions.dev.dto.response.admin;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AdminRevenueResponseDTO {
/** Revenus totaux (abonnements actifs * prix). */
private BigDecimal totalRevenueXof;
/** Nombre d'abonnements actifs. */
private long activeSubscriptionsCount;
}

View File

@@ -0,0 +1,26 @@
package com.lions.dev.dto.response.admin;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ManagerStatsResponseDTO {
private UUID userId;
private String email;
private String firstName;
private String lastName;
/** ACTIVE ou SUSPENDED (isActive). */
private String status;
private LocalDateTime subscriptionExpiresAt;
private UUID establishmentId;
private String establishmentName;
}

View File

@@ -0,0 +1,45 @@
package com.lions.dev.dto.response.booking;
import com.lions.dev.entity.booking.Booking;
import lombok.Getter;
/**
* DTO de réponse pour une réservation (aligné sur le frontend Flutter ReservationModel).
* eventId/eventTitle : pour les réservations d'établissement, eventTitle = nom de l'établissement, eventId = null.
*/
@Getter
public class ReservationResponseDTO {
private final String id;
private final String userId;
private final String userFullName;
private final String eventId; // null pour résa établissement
private final String eventTitle; // nom événement ou établissement
private final String reservationDate; // ISO-8601
private final int numberOfPeople;
private final String status; // PENDING, CONFIRMED, CANCELLED, COMPLETED
private final String establishmentId;
private final String establishmentName;
private final String notes;
private final String createdAt; // ISO-8601
public ReservationResponseDTO(Booking booking) {
this.id = booking.getId() != null ? booking.getId().toString() : null;
this.userId = booking.getUser() != null && booking.getUser().getId() != null
? booking.getUser().getId().toString() : null;
this.userFullName = booking.getUser() != null
? (booking.getUser().getFirstName() + " " + booking.getUser().getLastName()).trim()
: "";
this.eventId = null; // Réservation établissement sans événement
this.eventTitle = booking.getEstablishment() != null ? booking.getEstablishment().getName() : "";
this.reservationDate = booking.getReservationTime() != null
? booking.getReservationTime().toString() : null;
this.numberOfPeople = booking.getGuestCount() != null ? booking.getGuestCount() : 1;
this.status = booking.getStatus() != null ? booking.getStatus().toLowerCase() : "pending";
this.establishmentId = booking.getEstablishment() != null && booking.getEstablishment().getId() != null
? booking.getEstablishment().getId().toString() : null;
this.establishmentName = booking.getEstablishment() != null ? booking.getEstablishment().getName() : null;
this.notes = booking.getSpecialRequests();
this.createdAt = booking.getCreatedAt() != null ? booking.getCreatedAt().toString() : null;
}
}

View File

@@ -0,0 +1,22 @@
package com.lions.dev.dto.response.establishment;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Réponse après initiation d'un paiement Wave (URL de redirection).
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class InitiateSubscriptionResponseDTO {
private String paymentUrl;
private String waveSessionId;
private Integer amountXof;
private String plan;
private String status;
}

View File

@@ -21,6 +21,7 @@ public class UserCreateResponseDTO {
private String lastName; // v2.0 - Nom de famille de l'utilisateur
private String email; // Email de l'utilisateur
private String role; // Rôle de l'utilisateur
private Boolean isActive = true; // false = manager suspendu (abonnement expiré)
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é
@@ -56,6 +57,7 @@ public class UserCreateResponseDTO {
this.lastName = user.getLastName(); // v2.0
this.email = user.getEmail();
this.role = user.getRole();
this.isActive = user.isActive();
this.profileImageUrl = user.getProfileImageUrl();
this.bio = user.getBio(); // v2.0
this.loyaltyPoints = user.getLoyaltyPoints(); // v2.0

View File

@@ -62,6 +62,10 @@ public class Establishment extends BaseEntity {
@Column(name = "verification_status", nullable = false)
private String verificationStatus = "PENDING"; // Statut de vérification: PENDING, VERIFIED, REJECTED (v2.0)
/** true = visible dans l'app ; false = masqué (abonnement inactif / suspension Wave). Par défaut true. */
@Column(name = "is_active", nullable = false)
private Boolean isActive = true;
@Column(name = "latitude")
private Double latitude; // Latitude pour la géolocalisation

View File

@@ -0,0 +1,48 @@
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 java.util.UUID;
/**
* Enregistrement d'un paiement Wave pour un établissement (droits d'accès).
*/
@Entity
@Table(name = "establishment_payments")
@Getter
@Setter
@NoArgsConstructor
public class EstablishmentPayment extends BaseEntity {
@Column(name = "establishment_id", nullable = false)
private UUID establishmentId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "establishment_id", insertable = false, updatable = false)
private Establishment establishment;
@Column(name = "amount_xof", nullable = false)
private Integer amountXof;
@Column(name = "wave_session_id", length = 255)
private String waveSessionId;
/** Statut : PENDING, COMPLETED, FAILED, CANCELLED */
@Column(name = "status", nullable = false, length = 20)
private String status = "PENDING";
@Column(name = "client_phone", length = 30)
private String clientPhone;
@Column(name = "plan", length = 20)
private String plan;
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_COMPLETED = "COMPLETED";
public static final String STATUS_FAILED = "FAILED";
public static final String STATUS_CANCELLED = "CANCELLED";
}

View File

@@ -0,0 +1,56 @@
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 java.time.LocalDateTime;
import java.util.UUID;
/**
* Abonnement / droits d'accès d'un établissement (paiement via Wave).
* Un établissement doit avoir un abonnement actif pour accéder aux fonctionnalités payantes.
*/
@Entity
@Table(name = "establishment_subscriptions")
@Getter
@Setter
@NoArgsConstructor
public class EstablishmentSubscription extends BaseEntity {
@Column(name = "establishment_id", nullable = false)
private UUID establishmentId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "establishment_id", insertable = false, updatable = false)
private Establishment establishment;
/** Plan : MONTHLY, YEARLY */
@Column(name = "plan", nullable = false, length = 20)
private String plan;
/** Statut : PENDING, ACTIVE, EXPIRED, CANCELLED */
@Column(name = "status", nullable = false, length = 20)
private String status = "PENDING";
@Column(name = "wave_session_id", length = 255)
private String waveSessionId;
@Column(name = "amount_xof")
private Integer amountXof;
@Column(name = "paid_at")
private LocalDateTime paidAt;
@Column(name = "expires_at")
private LocalDateTime expiresAt;
public static final String PLAN_MONTHLY = "MONTHLY";
public static final String PLAN_YEARLY = "YEARLY";
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_EXPIRED = "EXPIRED";
public static final String STATUS_CANCELLED = "CANCELLED";
}

View File

@@ -27,9 +27,9 @@ public class SocialPost extends BaseEntity {
@Column(name = "content", nullable = false, length = 2000)
private String content; // Le contenu textuel du post
@ManyToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private Users user; // L'utilisateur créateur du post
private Users user; // L'utilisateur créateur du post (EAGER pour éviter 500 sur mapping DTO)
@Column(name = "image_url", length = 500)
private String imageUrl; // URL de l'image associée (optionnel)

View File

@@ -69,6 +69,10 @@ public class Users extends BaseEntity {
@Column(name = "last_seen")
private java.time.LocalDateTime lastSeen; // Dernière fois que l'utilisateur était en ligne
/** true = compte actif ; false = manager suspendu (abonnement expiré / paiement échoué). Par défaut true. */
@Column(name = "is_active", nullable = false)
private boolean isActive = true;
// Utilisation de BCrypt pour hacher les mots de passe de manière sécurisée
// private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();

View File

@@ -0,0 +1,16 @@
package com.lions.dev.repository;
import com.lions.dev.entity.booking.Booking;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
@ApplicationScoped
public class BookingRepository implements PanacheRepositoryBase<Booking, UUID> {
public List<Booking> findByUserId(UUID userId) {
return list("user.id", userId);
}
}

View File

@@ -0,0 +1,21 @@
package com.lions.dev.repository;
import com.lions.dev.entity.establishment.EstablishmentPayment;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class EstablishmentPaymentRepository implements PanacheRepositoryBase<EstablishmentPayment, UUID> {
public Optional<EstablishmentPayment> findByWaveSessionId(String waveSessionId) {
return find("waveSessionId", waveSessionId).firstResultOptional();
}
public List<EstablishmentPayment> findByEstablishmentId(UUID establishmentId) {
return list("establishmentId", establishmentId);
}
}

View File

@@ -84,5 +84,16 @@ public class EstablishmentRepository implements PanacheRepositoryBase<Establishm
LOG.info("[LOG] Nombre d'établissements trouvés : " + establishments.size());
return establishments;
}
/**
* Compte le nombre d'établissements gérés par un responsable.
* Contrainte métier : un Manager ne peut gérer qu'un seul établissement.
*
* @param managerId L'ID du responsable.
* @return Le nombre d'établissements (0 ou 1).
*/
public long countByManagerId(UUID managerId) {
return count("manager.id = ?1", managerId);
}
}

View File

@@ -0,0 +1,26 @@
package com.lions.dev.repository;
import com.lions.dev.entity.establishment.EstablishmentSubscription;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class EstablishmentSubscriptionRepository implements PanacheRepositoryBase<EstablishmentSubscription, UUID> {
public Optional<EstablishmentSubscription> findByWaveSessionId(String waveSessionId) {
return find("waveSessionId", waveSessionId).firstResultOptional();
}
public List<EstablishmentSubscription> findByEstablishmentId(UUID establishmentId) {
return list("establishmentId", establishmentId);
}
public Optional<EstablishmentSubscription> findActiveByEstablishmentId(UUID establishmentId) {
return find("establishmentId = ?1 and status = ?2", establishmentId, EstablishmentSubscription.STATUS_ACTIVE)
.firstResultOptional();
}
}

View File

@@ -80,4 +80,24 @@ public class EventsRepository implements PanacheRepositoryBase<Events, UUID> {
LOG.info("[LOG] " + events.size() + " événement(s) récupéré(s) des amis");
return events;
}
/**
* Récupère les événements dont l'établissement correspond à la localisation (ville ou adresse).
* v2.0 : la colonne location a été supprimée ; on filtre par establishment.address ou establishment.city.
*
* @param location Fragment de ville ou d'adresse (recherche partielle, insensible à la casse).
* @return Liste des événements dont l'établissement matche la localisation.
*/
public List<Events> findEventsByEstablishmentLocation(String location) {
if (location == null || location.isBlank()) {
return List.of();
}
String pattern = "%" + location.trim().toLowerCase() + "%";
return getEntityManager()
.createQuery(
"SELECT e FROM Events e JOIN e.establishment est WHERE est IS NOT NULL AND (LOWER(est.address) LIKE :loc OR LOWER(est.city) LIKE :loc)",
Events.class)
.setParameter("loc", pattern)
.getResultList();
}
}

View File

@@ -26,8 +26,9 @@ public class FriendshipRepository implements PanacheRepositoryBase<Friendship, U
/**
* Trouver une relation d'amitié entre deux utilisateurs spécifiés.
* Cette méthode recherche une relation d'amitié entre deux utilisateurs donnés.
* Elle peut être utilisée pour vérifier si une demande d'amitié existe déjà.
* Cette méthode recherche une relation d'amitié entre deux utilisateurs donnés,
* dans les deux sens (user→friend ou friend→user) car une demande peut exister
* dans l'une ou l'autre direction.
*
* @param user L'utilisateur qui envoie la demande d'amitié.
* @param friend L'ami qui reçoit la demande.
@@ -36,8 +37,8 @@ public class FriendshipRepository implements PanacheRepositoryBase<Friendship, U
public Optional<Friendship> findByUsers(Users user, Users friend) {
logger.infof("Recherche de la relation d'amitié entre les utilisateurs : %s et %s", user.getId(), friend.getId());
// Requête qui cherche une relation d'amitié entre deux utilisateurs spécifiques
Optional<Friendship> friendship = find("user = ?1 and friend = ?2", user, friend).firstResultOptional();
// Vérifier dans les deux sens : (user, friend) ET (friend, user)
Optional<Friendship> friendship = find("(user = ?1 AND friend = ?2) OR (user = ?2 AND friend = ?1)", user, friend).firstResultOptional();
if (friendship.isPresent()) {
logger.infof("Relation d'amitié trouvée entre %s et %s", user.getId(), friend.getId());

View File

@@ -0,0 +1,69 @@
package com.lions.dev.resource;
import com.lions.dev.dto.response.admin.AdminRevenueResponseDTO;
import com.lions.dev.dto.response.admin.ManagerStatsResponseDTO;
import com.lions.dev.service.AdminStatsService;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
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.Optional;
/**
* Ressource REST pour le tableau de bord Super Admin.
* Requiert le header X-Super-Admin-Key pour toutes les opérations.
*/
@Path("/admin/stats")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Admin Stats", description = "Statistiques et KPIs (Super Admin)")
public class AdminStatsResource {
private static final Logger LOG = Logger.getLogger(AdminStatsResource.class);
private static final String SUPER_ADMIN_KEY_HEADER = "X-Super-Admin-Key";
@Inject
AdminStatsService adminStatsService;
@ConfigProperty(name = "afterwork.super-admin.api-key", defaultValue = "")
Optional<String> superAdminApiKey;
private boolean isAuthorized(String apiKeyHeader) {
if (superAdminApiKey == null || !superAdminApiKey.isPresent() || superAdminApiKey.get().isBlank()) {
return false;
}
return apiKeyHeader != null && apiKeyHeader.equals(superAdminApiKey.get());
}
@GET
@Path("/revenue")
@Operation(summary = "Revenus des abonnements", description = "Total des abonnements actifs * prix. Réservé Super Admin.")
public Response getRevenue(@HeaderParam(SUPER_ADMIN_KEY_HEADER) String apiKeyHeader) {
if (!isAuthorized(apiKeyHeader)) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Clé Super Admin invalide ou absente.\"}")
.build();
}
AdminRevenueResponseDTO dto = adminStatsService.getRevenue();
return Response.ok(dto).build();
}
@GET
@Path("/managers")
@Operation(summary = "Liste des managers", description = "Managers avec statut (Actif/Suspendu) et date d'expiration. Réservé Super Admin.")
public Response getManagers(@HeaderParam(SUPER_ADMIN_KEY_HEADER) String apiKeyHeader) {
if (!isAuthorized(apiKeyHeader)) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Clé Super Admin invalide ou absente.\"}")
.build();
}
List<ManagerStatsResponseDTO> list = adminStatsService.getManagers();
return Response.ok(list).build();
}
}

View File

@@ -0,0 +1,100 @@
package com.lions.dev.resource;
import com.lions.dev.dto.request.booking.ReservationCreateRequestDTO;
import com.lions.dev.dto.response.booking.ReservationResponseDTO;
import com.lions.dev.service.BookingService;
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;
/**
* Ressource REST pour les réservations (bookings).
* Path /reservations pour alignement avec le frontend Flutter (ReservationsScreen).
*/
@Path("/reservations")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Reservations", description = "Réservations d'établissements")
public class BookingResource {
private static final Logger LOG = Logger.getLogger(BookingResource.class);
@Inject
BookingService bookingService;
@GET
@Path("/user/{userId}")
@Operation(summary = "Liste des réservations d'un utilisateur")
public Response getUserReservations(@PathParam("userId") UUID userId) {
List<ReservationResponseDTO> list = bookingService.getReservationsByUserId(userId);
return Response.ok(list).build();
}
@GET
@Path("/{id}")
@Operation(summary = "Détail d'une réservation")
public Response getReservation(@PathParam("id") UUID id) {
ReservationResponseDTO dto = bookingService.getReservationById(id);
if (dto == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(dto).build();
}
@POST
@Transactional
@Operation(summary = "Créer une réservation")
public Response createReservation(@Valid ReservationCreateRequestDTO dto) {
try {
ReservationResponseDTO created = bookingService.createReservation(dto);
return Response.status(Response.Status.CREATED).entity(created).build();
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.BAD_REQUEST).entity(e.getMessage()).build();
}
}
@PUT
@Path("/{id}")
@Transactional
@Operation(summary = "Mettre à jour une réservation")
public Response updateReservation(@PathParam("id") UUID id, @Valid ReservationCreateRequestDTO dto) {
ReservationResponseDTO updated = bookingService.updateReservation(id, dto);
if (updated == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(updated).build();
}
@PUT
@Path("/{id}/cancel")
@Transactional
@Operation(summary = "Annuler une réservation")
public Response cancelReservation(@PathParam("id") UUID id) {
ReservationResponseDTO dto = bookingService.cancelReservation(id);
if (dto == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(dto).build();
}
@DELETE
@Path("/{id}")
@Transactional
@Operation(summary = "Supprimer une réservation")
public Response deleteReservation(@PathParam("id") UUID id) {
if (bookingService.getReservationById(id) == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
bookingService.deleteReservation(id);
return Response.noContent().build();
}
}

View File

@@ -5,8 +5,10 @@ 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.EstablishmentRepository;
import com.lions.dev.repository.UsersRepository;
import com.lions.dev.service.EstablishmentService;
import com.lions.dev.util.UserRoles;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
@@ -38,6 +40,9 @@ public class EstablishmentResource {
@Inject
UsersRepository usersRepository;
@Inject
EstablishmentRepository establishmentRepository;
private static final Logger LOG = Logger.getLogger(EstablishmentResource.class);
// *********** Création d'un établissement ***********
@@ -67,6 +72,26 @@ public class EstablishmentResource {
.build();
}
// Vérification rôle : seuls les Managers (ou ADMIN) peuvent créer un établissement
String role = manager.getRole() != null ? manager.getRole().toUpperCase() : "";
if (!UserRoles.MANAGER.equals(role) && !UserRoles.ADMIN.equals(role) && !UserRoles.SUPER_ADMIN.equals(role)) {
LOG.error("[ERROR] L'utilisateur " + requestDTO.getManagerId() + " n'a pas le rôle Manager. Rôle : " + role);
return Response.status(Response.Status.FORBIDDEN)
.entity("Seuls les Managers peuvent créer des établissements")
.build();
}
// Contrainte : un Manager ne peut gérer qu'un seul établissement (ADMIN/SUPER_ADMIN exclus)
if (UserRoles.MANAGER.equals(role)) {
long count = establishmentRepository.countByManagerId(requestDTO.getManagerId());
if (count >= 1) {
LOG.error("[ERROR] Le Manager " + requestDTO.getManagerId() + " possède déjà un établissement");
return Response.status(Response.Status.FORBIDDEN)
.entity("Un Manager ne peut gérer qu'un seul établissement")
.build();
}
}
// Créer l'établissement
Establishment establishment = new Establishment();
establishment.setName(requestDTO.getName());

View File

@@ -0,0 +1,66 @@
package com.lions.dev.resource;
import com.lions.dev.dto.request.establishment.InitiateSubscriptionRequestDTO;
import com.lions.dev.dto.response.establishment.InitiateSubscriptionResponseDTO;
import com.lions.dev.service.WavePaymentService;
import jakarta.inject.Inject;
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 pour les abonnements / droits d'accès des établissements (paiement Wave).
*/
@Path("/establishments/{establishmentId}/subscriptions")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Establishment Subscriptions", description = "Paiement des droits d'accès via Wave")
public class EstablishmentSubscriptionResource {
private static final Logger LOG = Logger.getLogger(EstablishmentSubscriptionResource.class);
@Inject
WavePaymentService wavePaymentService;
/**
* Initie un paiement Wave pour les droits d'accès d'un établissement.
* Retourne l'URL de redirection vers la page de paiement Wave.
*/
@POST
@Path("/initiate")
@Operation(summary = "Initier un paiement Wave (droits d'accès)",
description = "Crée une session Wave et retourne l'URL de paiement. Le client redirige l'utilisateur vers payment_url.")
public Response initiatePayment(
@PathParam("establishmentId") UUID establishmentId,
@Valid InitiateSubscriptionRequestDTO request) {
try {
InitiateSubscriptionResponseDTO response = wavePaymentService.initiatePayment(
establishmentId,
request.getPlan(),
request.getClientPhone()
);
return Response.ok(response).build();
} catch (IllegalArgumentException e) {
LOG.warn(e.getMessage());
return Response.status(Response.Status.NOT_FOUND).entity("{\"message\": \"" + e.getMessage() + "\"}").build();
}
}
/**
* Vérifie si l'établissement a un abonnement actif.
*/
@GET
@Path("/status")
@Operation(summary = "Statut d'abonnement", description = "Indique si l'établissement a des droits d'accès actifs.")
public Response getSubscriptionStatus(@PathParam("establishmentId") UUID establishmentId) {
boolean active = wavePaymentService.hasActiveSubscription(establishmentId);
return Response.ok(Map.of("hasActiveSubscription", active)).build();
}
}

View File

@@ -9,6 +9,7 @@ import com.lions.dev.dto.response.friends.FriendshipReadStatusResponseDTO;
import com.lions.dev.entity.friends.FriendshipStatus;
import com.lions.dev.exception.UserNotFoundException;
import com.lions.dev.service.FriendshipService;
import jakarta.ws.rs.NotFoundException;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
@@ -82,6 +83,16 @@ public class FriendshipResource {
+ " et "
+ friendshipResponse.getFriendId());
return Response.ok(friendshipResponse).build();
} catch (UserNotFoundException e) {
logger.warn("[WARN] Utilisateur non trouvé : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Utilisateur non trouvé.\"}")
.build();
} catch (IllegalArgumentException e) {
logger.warn("[WARN] Requête invalide : " + e.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
} catch (Exception e) {
logger.error("[ERROR] Erreur lors de l'envoi de la demande d'amitié : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)

View File

@@ -3,6 +3,7 @@ package com.lions.dev.resource;
import com.lions.dev.dto.request.social.SocialPostCreateRequestDTO;
import com.lions.dev.dto.response.social.SocialPostResponseDTO;
import com.lions.dev.entity.social.SocialPost;
import com.lions.dev.exception.UserNotFoundException;
import com.lions.dev.service.SocialPostService;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@@ -121,6 +122,11 @@ public class SocialPostResource {
);
SocialPostResponseDTO responseDTO = new SocialPostResponseDTO(post);
return Response.status(Response.Status.CREATED).entity(responseDTO).build();
} catch (UserNotFoundException e) {
LOG.warn("[WARN] Utilisateur non trouvé : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Utilisateur non trouvé.\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la création du post : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
@@ -382,6 +388,11 @@ public class SocialPostResource {
.collect(Collectors.toList());
return Response.ok(responseDTOs).build();
} catch (UserNotFoundException e) {
LOG.warn("[WARN] Utilisateur non trouvé : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Utilisateur non trouvé.\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des posts : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
@@ -416,6 +427,11 @@ public class SocialPostResource {
.collect(Collectors.toList());
return Response.ok(responseDTOs).build();
} catch (UserNotFoundException e) {
LOG.warn("[WARN] Utilisateur non trouvé : " + e.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Utilisateur non trouvé.\"}")
.build();
} catch (Exception e) {
LOG.error("[ERROR] Erreur lors de la récupération des posts des amis : " + e.getMessage(), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)

View File

@@ -1,6 +1,7 @@
package com.lions.dev.resource;
import com.lions.dev.dto.PasswordResetRequest;
import com.lions.dev.dto.request.users.AssignRoleRequestDTO;
import com.lions.dev.dto.request.users.UserAuthenticateRequestDTO;
import com.lions.dev.dto.request.users.UserCreateRequestDTO;
import com.lions.dev.dto.response.users.UserAuthenticateResponseDTO;
@@ -16,9 +17,11 @@ import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.File;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
@@ -39,8 +42,13 @@ public class UsersResource {
@Inject
UsersService userService;
@ConfigProperty(name = "afterwork.super-admin.api-key", defaultValue = "")
Optional<String> superAdminApiKey;
private static final Logger LOG = Logger.getLogger(UsersResource.class);
private static final String SUPER_ADMIN_KEY_HEADER = "X-Super-Admin-Key";
/**
* Endpoint pour créer un nouvel utilisateur.
*
@@ -296,11 +304,11 @@ public class UsersResource {
Users user = userService.findByEmail(request.getEmail());
if (user != null) {
// TODO: Generer un token de reset et l'envoyer par email
// Pour l'instant, on retourne success pour ne pas reveler si l'email existe
// String resetToken = generateResetToken();
// emailService.sendPasswordResetEmail(user.getEmail(), resetToken);
LOG.info("Utilisateur trouve, email de reinitialisation devrait etre envoye : " + request.getEmail());
// En standby : pas encore de service d'envoi de mail. Quand disponible :
// - generer un token de reset (table dédiée ou champ user avec expiration)
// - appeler emailService.sendPasswordResetEmail(user.getEmail(), resetToken)
// Pour l'instant, on retourne success pour ne pas reveler si l'email existe.
LOG.info("Utilisateur trouve, email de reinitialisation (en standby - pas de mail service) : " + request.getEmail());
} else {
LOG.info("Aucun utilisateur trouve avec cet email (ne pas reveler) : " + request.getEmail());
}
@@ -318,4 +326,98 @@ public class UsersResource {
}
}
/**
* Attribue un rôle à un utilisateur (réservé au super administrateur).
* Requiert le header X-Super-Admin-Key correspondant à afterwork.super-admin.api-key.
*
* @param id L'ID de l'utilisateur.
* @param request Le DTO contenant le nouveau rôle.
* @param apiKeyHeader Valeur du header X-Super-Admin-Key (injecté via @HeaderParam).
* @return L'utilisateur mis à jour.
*/
@PUT
@Path("/{id}/role")
@Transactional
@Operation(summary = "Attribuer un rôle à un utilisateur (super admin)",
description = "Modifie le rôle d'un utilisateur. Réservé au super administrateur (header X-Super-Admin-Key).")
@APIResponse(responseCode = "200", description = "Rôle mis à jour")
@APIResponse(responseCode = "403", description = "Clé super admin invalide ou absente")
@APIResponse(responseCode = "404", description = "Utilisateur non trouvé")
public Response assignRole(
@PathParam("id") UUID id,
@Valid AssignRoleRequestDTO request,
@HeaderParam(SUPER_ADMIN_KEY_HEADER) String apiKeyHeader) {
String key = superAdminApiKey.orElse("");
if (key.isBlank()) {
LOG.warn("Opération assignRole refusée : afterwork.super-admin.api-key non configurée");
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Opération non autorisée : clé super admin non configurée.\"}")
.build();
}
if (apiKeyHeader == null || !apiKeyHeader.equals(key)) {
LOG.warn("Opération assignRole refusée : clé super admin invalide ou absente");
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Clé super administrateur invalide ou absente.\"}")
.build();
}
try {
Users user = userService.assignRole(id, request.getRole());
UserCreateResponseDTO responseDTO = new UserCreateResponseDTO(user);
return Response.ok(responseDTO).build();
} catch (UserNotFoundException e) {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"" + e.getMessage() + "\"}")
.build();
}
}
/**
* Génère un token temporaire d'impersonation (Super Admin se connecte en tant qu'un autre utilisateur).
* Le client envoie ce token (ex: Authorization: Bearer &lt;token&gt;) pour les requêtes suivantes.
* La validation du token côté backend (filter) est à implémenter si nécessaire.
*
* @param id L'ID de l'utilisateur à impersonner.
* @param apiKeyHeader X-Super-Admin-Key.
* @return JSON avec impersonationToken, expiresInSeconds, userId.
*/
@POST
@Path("/{id}/impersonate")
@Operation(summary = "Impersonation (Super Admin)",
description = "Génère un token temporaire pour se connecter en tant que cet utilisateur. Header X-Super-Admin-Key requis.")
public Response impersonate(
@PathParam("id") UUID id,
@HeaderParam(SUPER_ADMIN_KEY_HEADER) String apiKeyHeader) {
String key = superAdminApiKey.orElse("");
if (key.isBlank()) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Opération non autorisée : clé super admin non configurée.\"}")
.build();
}
if (apiKeyHeader == null || !apiKeyHeader.equals(key)) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"message\": \"Clé super administrateur invalide ou absente.\"}")
.build();
}
try {
Users user = userService.getUserById(id);
// Token temporaire (UUID) — à valider côté backend via un filter si besoin
String token = UUID.randomUUID().toString();
int expiresInSeconds = 900; // 15 min
return Response.ok(Map.of(
"impersonationToken", token,
"expiresInSeconds", expiresInSeconds,
"userId", user.getId().toString(),
"email", user.getEmail()
)).build();
} catch (UserNotFoundException e) {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"message\": \"Utilisateur non trouvé.\"}")
.build();
}
}
}

View File

@@ -0,0 +1,38 @@
package com.lions.dev.resource;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lions.dev.service.WavePaymentService;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
/**
* Webhook Wave pour les notifications de paiement (payment.completed, payment.cancelled, etc.).
*/
@Path("/webhooks/wave")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class WaveWebhookResource {
private static final Logger LOG = Logger.getLogger(WaveWebhookResource.class);
@Inject
WavePaymentService wavePaymentService;
private final ObjectMapper objectMapper = new ObjectMapper();
@POST
public Response handleWebhook(String payload) {
try {
JsonNode node = objectMapper.readTree(payload);
wavePaymentService.handleWebhook(node);
return Response.ok().build();
} catch (Exception e) {
LOG.error("Erreur traitement webhook Wave", e);
return Response.status(Response.Status.BAD_REQUEST).build();
}
}
}

View File

@@ -0,0 +1,78 @@
package com.lions.dev.service;
import com.lions.dev.dto.response.admin.AdminRevenueResponseDTO;
import com.lions.dev.dto.response.admin.ManagerStatsResponseDTO;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.establishment.EstablishmentSubscription;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.EstablishmentRepository;
import com.lions.dev.repository.EstablishmentSubscriptionRepository;
import com.lions.dev.repository.UsersRepository;
import com.lions.dev.util.UserRoles;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class AdminStatsService {
@Inject
EstablishmentSubscriptionRepository subscriptionRepository;
@Inject
EstablishmentRepository establishmentRepository;
@Inject
UsersRepository usersRepository;
/**
* Revenus totaux : somme des montants des abonnements actifs.
*/
public AdminRevenueResponseDTO getRevenue() {
List<EstablishmentSubscription> active = subscriptionRepository.list(
"status", EstablishmentSubscription.STATUS_ACTIVE);
long count = active.size();
BigDecimal total = active.stream()
.map(s -> s.getAmountXof() != null ? BigDecimal.valueOf(s.getAmountXof()) : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return new AdminRevenueResponseDTO(total, count);
}
/**
* Liste des managers avec statut (Actif/Suspendu) et date d'expiration d'abonnement.
*/
public List<ManagerStatsResponseDTO> getManagers() {
List<Users> managers = usersRepository.list("role", UserRoles.MANAGER);
List<ManagerStatsResponseDTO> result = new ArrayList<>();
for (Users manager : managers) {
List<Establishment> establishments = establishmentRepository.findByManagerId(manager.getId());
UUID establishmentId = null;
String establishmentName = null;
java.time.LocalDateTime expiresAt = null;
String status = manager.isActive() ? "ACTIVE" : "SUSPENDED";
if (!establishments.isEmpty()) {
Establishment est = establishments.get(0);
establishmentId = est.getId();
establishmentName = est.getName();
Optional<EstablishmentSubscription> sub = subscriptionRepository.findActiveByEstablishmentId(est.getId());
if (sub.isPresent()) {
expiresAt = sub.get().getExpiresAt();
}
}
result.add(new ManagerStatsResponseDTO(
manager.getId(),
manager.getEmail(),
manager.getFirstName(),
manager.getLastName(),
status,
expiresAt,
establishmentId,
establishmentName
));
}
return result;
}
}

View File

@@ -0,0 +1,108 @@
package com.lions.dev.service;
import com.lions.dev.dto.request.booking.ReservationCreateRequestDTO;
import com.lions.dev.dto.response.booking.ReservationResponseDTO;
import com.lions.dev.entity.booking.Booking;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.BookingRepository;
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.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@ApplicationScoped
public class BookingService {
@Inject
BookingRepository bookingRepository;
@Inject
EstablishmentRepository establishmentRepository;
@Inject
UsersRepository usersRepository;
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_DATE_TIME;
public List<ReservationResponseDTO> getReservationsByUserId(UUID userId) {
return bookingRepository.findByUserId(userId).stream()
.map(ReservationResponseDTO::new)
.collect(Collectors.toList());
}
public ReservationResponseDTO getReservationById(UUID id) {
Booking booking = bookingRepository.findById(id);
if (booking == null) return null;
return new ReservationResponseDTO(booking);
}
@Transactional
public ReservationResponseDTO createReservation(ReservationCreateRequestDTO dto) {
Establishment establishment = establishmentRepository.findById(dto.getEstablishmentId());
Users user = usersRepository.findById(dto.getUserId());
if (establishment == null || user == null) {
throw new IllegalArgumentException("Établissement ou utilisateur non trouvé");
}
LocalDateTime reservationTime = parseReservationDate(dto.getReservationDate());
Booking booking = new Booking();
booking.setEstablishment(establishment);
booking.setUser(user);
booking.setReservationTime(reservationTime);
booking.setGuestCount(dto.getNumberOfPeople() > 0 ? dto.getNumberOfPeople() : 1);
booking.setStatus("PENDING");
booking.setSpecialRequests(dto.getNotes());
bookingRepository.persist(booking);
return new ReservationResponseDTO(booking);
}
@Transactional
public ReservationResponseDTO cancelReservation(UUID id) {
Booking booking = bookingRepository.findById(id);
if (booking == null) return null;
booking.setStatus("CANCELLED");
bookingRepository.persist(booking);
return new ReservationResponseDTO(booking);
}
@Transactional
public void deleteReservation(UUID id) {
bookingRepository.deleteById(id);
}
@Transactional
public ReservationResponseDTO updateReservation(UUID id, ReservationCreateRequestDTO dto) {
Booking booking = bookingRepository.findById(id);
if (booking == null) return null;
if (dto.getReservationDate() != null) {
booking.setReservationTime(parseReservationDate(dto.getReservationDate()));
}
if (dto.getNumberOfPeople() > 0) {
booking.setGuestCount(dto.getNumberOfPeople());
}
if (dto.getNotes() != null) {
booking.setSpecialRequests(dto.getNotes());
}
bookingRepository.persist(booking);
return new ReservationResponseDTO(booking);
}
private static LocalDateTime parseReservationDate(String value) {
if (value == null || value.isBlank()) return LocalDateTime.now();
try {
return LocalDateTime.parse(value, ISO);
} catch (Exception e) {
try {
int len = Math.min(19, value.length());
return LocalDateTime.parse(value.substring(0, len));
} catch (Exception e2) {
return LocalDateTime.now();
}
}
}
}

View File

@@ -75,6 +75,7 @@ public class EstablishmentService {
"SELECT DISTINCT e FROM Establishment e " +
"LEFT JOIN FETCH e.medias m " +
"LEFT JOIN FETCH e.manager " +
"WHERE (e.isActive IS NULL OR e.isActive = true) " +
"ORDER BY e.name ASC",
Establishment.class
)

View File

@@ -152,7 +152,7 @@ public class EventService {
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());
logger.error("[ERROR] Erreur publication Kafka pour événement {}", event.getId(), kafkaEx);
// Ne pas bloquer si Kafka échoue
}
}
@@ -424,14 +424,15 @@ public class EventService {
}
/**
* Récupère les événements par localisation.
* Récupère les événements par localisation (ville ou adresse de l'établissement).
* v2.0 : plus de colonne location ; recherche sur establishment.address et establishment.city.
*
* @param location La localisation des événements.
* @return La liste des événements situés à cette localisation.
* @param location Fragment de localisation (ville ou adresse).
* @return La liste des événements dont l'établissement matche.
*/
public List<Events> findEventsByLocation(String location) {
logger.info("[logger] Récupération des événements pour la localisation : " + location);
List<Events> events = eventsRepository.find("location", location).list();
List<Events> events = eventsRepository.findEventsByEstablishmentLocation(location);
logger.info("[logger] Nombre d'événements trouvés pour la localisation '" + location + "' : " + events.size());
return events;
}

View File

@@ -56,6 +56,11 @@ public class FriendshipService {
*/
@Transactional
public FriendshipCreateOneResponseDTO sendFriendRequest(FriendshipCreateOneRequestDTO request) {
if (request == null || request.getUserId() == null || request.getFriendId() == null) {
logger.error("[ERROR] Requête invalide : userId ou friendId manquant");
throw new IllegalArgumentException("L'identifiant de l'utilisateur et de l'ami sont requis.");
}
logger.info("[LOG] Envoi d'une demande d'amitié de l'utilisateur " + request.getUserId() + " à l'utilisateur " + request.getFriendId());
// Récupérer les utilisateurs concernés

View File

@@ -187,7 +187,7 @@ public class MessageService {
// Ne pas bloquer si la confirmation échoue
}
} catch (Exception e) {
System.out.println("[ERROR] Erreur lors de la publication dans Kafka : " + e.getMessage());
io.quarkus.logging.Log.error("[ERROR] Erreur lors de la publication dans Kafka pour message " + message.getId(), e);
// Ne pas bloquer l'envoi du message si Kafka échoue
}

View File

@@ -263,4 +263,24 @@ public class UsersService {
Optional<Users> userOptional = usersRepository.findByEmail(email);
return userOptional.orElse(null);
}
/**
* Attribue un rôle à un utilisateur (réservé au super administrateur).
*
* @param userId L'ID de l'utilisateur à modifier.
* @param newRole Le nouveau rôle (SUPER_ADMIN, ADMIN, MANAGER, USER).
* @return L'utilisateur mis à jour.
* @throws UserNotFoundException Si l'utilisateur n'existe pas.
*/
@Transactional
public Users assignRole(UUID userId, String newRole) {
Users user = usersRepository.findById(userId);
if (user == null) {
throw new UserNotFoundException("Utilisateur non trouvé avec l'ID : " + userId);
}
user.setRole(newRole);
usersRepository.persist(user);
System.out.println("[LOG] Rôle attribué à " + user.getEmail() + " : " + newRole);
return user;
}
}

View File

@@ -0,0 +1,216 @@
package com.lions.dev.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lions.dev.dto.response.establishment.InitiateSubscriptionResponseDTO;
import com.lions.dev.entity.establishment.Establishment;
import com.lions.dev.entity.establishment.EstablishmentPayment;
import com.lions.dev.entity.establishment.EstablishmentSubscription;
import com.lions.dev.entity.users.Users;
import com.lions.dev.repository.EstablishmentPaymentRepository;
import com.lions.dev.repository.EstablishmentRepository;
import com.lions.dev.repository.EstablishmentSubscriptionRepository;
import com.lions.dev.repository.UsersRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* Service d'intégration Wave pour le paiement des droits d'accès des établissements.
* Crée une session de paiement Wave et traite les webhooks (payment.completed, etc.).
*/
@ApplicationScoped
public class WavePaymentService {
private static final Logger LOG = Logger.getLogger(WavePaymentService.class);
private static final int MONTHLY_AMOUNT_XOF = 15_000;
private static final int YEARLY_AMOUNT_XOF = 150_000;
@Inject
EstablishmentRepository establishmentRepository;
@Inject
EstablishmentSubscriptionRepository subscriptionRepository;
@Inject
EstablishmentPaymentRepository paymentRepository;
@Inject
UsersRepository usersRepository;
@ConfigProperty(name = "wave.api.url", defaultValue = "https://api.wave.com")
String waveApiUrl;
@ConfigProperty(name = "wave.api.key", defaultValue = "")
Optional<String> waveApiKey;
private final ObjectMapper objectMapper = new ObjectMapper();
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
/**
* Initie un paiement Wave pour un établissement (droits d'accès).
* Crée une session Wave et retourne l'URL de redirection.
*/
@Transactional
public InitiateSubscriptionResponseDTO initiatePayment(UUID establishmentId, String plan, String clientPhone) {
Establishment establishment = establishmentRepository.findById(establishmentId);
if (establishment == null) {
throw new IllegalArgumentException("Établissement non trouvé : " + establishmentId);
}
int amountXof = EstablishmentSubscription.PLAN_MONTHLY.equals(plan) ? MONTHLY_AMOUNT_XOF : YEARLY_AMOUNT_XOF;
String description = "AfterWork - Abonnement " + plan + " - " + establishment.getName();
EstablishmentSubscription subscription = new EstablishmentSubscription();
subscription.setEstablishmentId(establishmentId);
subscription.setPlan(plan);
subscription.setStatus(EstablishmentSubscription.STATUS_PENDING);
subscription.setAmountXof(amountXof);
subscriptionRepository.persist(subscription);
EstablishmentPayment payment = new EstablishmentPayment();
payment.setEstablishmentId(establishmentId);
payment.setAmountXof(amountXof);
payment.setStatus(EstablishmentPayment.STATUS_PENDING);
payment.setClientPhone(clientPhone);
payment.setPlan(plan);
paymentRepository.persist(payment);
String waveSessionId = null;
String paymentUrl = null;
String apiKey = waveApiKey.orElse("");
if (!apiKey.isBlank()) {
try {
Map<String, Object> body = new HashMap<>();
body.put("amount", amountXof);
body.put("currency", "XOF");
body.put("description", description);
body.put("client_reference", subscription.getId().toString());
body.put("customer_phone_number", clientPhone.startsWith("+") ? clientPhone : "+" + clientPhone);
String bodyJson = objectMapper.writeValueAsString(body);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(waveApiUrl + "/wave/api/v1/checkout/sessions"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
.timeout(Duration.ofSeconds(15))
.POST(HttpRequest.BodyPublishers.ofString(bodyJson))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 200 && response.statusCode() < 300) {
JsonNode node = objectMapper.readTree(response.body());
waveSessionId = node.has("id") ? node.get("id").asText() : null;
paymentUrl = node.has("payment_url") ? node.get("payment_url").asText() : null;
} else {
LOG.warn("Wave API error: " + response.statusCode() + " " + response.body());
}
} catch (Exception e) {
LOG.error("Erreur appel Wave API", e);
}
} else {
LOG.warn("Wave API key non configurée : utilisation d'une URL de test");
paymentUrl = "https://checkout.wave.com/session/test?ref=" + subscription.getId();
waveSessionId = "test-" + subscription.getId();
}
subscription.setWaveSessionId(waveSessionId);
payment.setWaveSessionId(waveSessionId);
subscriptionRepository.persist(subscription);
paymentRepository.persist(payment);
return new InitiateSubscriptionResponseDTO(
paymentUrl != null ? paymentUrl : "",
waveSessionId,
amountXof,
plan,
EstablishmentSubscription.STATUS_PENDING
);
}
/**
* Vérifie si un établissement a un abonnement actif (droits d'accès payés).
*/
public boolean hasActiveSubscription(UUID establishmentId) {
return subscriptionRepository.findActiveByEstablishmentId(establishmentId).isPresent();
}
/**
* Traite un webhook Wave (payment.completed, payment.cancelled, etc.).
* Met à jour l'abonnement et le paiement.
*/
@Transactional
public void handleWebhook(JsonNode payload) {
String eventType = payload.has("type") ? payload.get("type").asText() : null;
JsonNode data = payload.has("data") ? payload.get("data") : null;
if (data == null) return;
String sessionId = data.has("id") ? data.get("id").asText() : (data.has("session_id") ? data.get("session_id").asText() : null);
if (sessionId == null) return;
subscriptionRepository.findByWaveSessionId(sessionId).ifPresent(sub -> {
UUID establishmentId = sub.getEstablishmentId();
Establishment establishment = establishmentRepository.findById(establishmentId);
if ("payment.completed".equals(eventType)) {
sub.setStatus(EstablishmentSubscription.STATUS_ACTIVE);
sub.setPaidAt(LocalDateTime.now());
if (EstablishmentSubscription.PLAN_MONTHLY.equals(sub.getPlan())) {
sub.setExpiresAt(LocalDateTime.now().plusMonths(1));
} else {
sub.setExpiresAt(LocalDateTime.now().plusYears(1));
}
subscriptionRepository.persist(sub);
// Activer l'établissement et le manager
if (establishment != null) {
establishment.setIsActive(true);
establishmentRepository.persist(establishment);
Users manager = establishment.getManager();
if (manager != null) {
manager.setActive(true);
usersRepository.persist(manager);
LOG.info("Webhook Wave: établissement et manager activés pour " + establishmentId);
}
}
} else if ("payment.cancelled".equals(eventType) || "payment.expired".equals(eventType)
|| "payment.failed".equals(eventType)) {
sub.setStatus(EstablishmentSubscription.STATUS_CANCELLED);
subscriptionRepository.persist(sub);
// Suspendre l'établissement et le manager
if (establishment != null) {
establishment.setIsActive(false);
establishmentRepository.persist(establishment);
Users manager = establishment.getManager();
if (manager != null) {
manager.setActive(false);
usersRepository.persist(manager);
LOG.info("Webhook Wave: établissement et manager suspendus pour " + establishmentId);
}
}
}
});
paymentRepository.findByWaveSessionId(sessionId).ifPresent(payment -> {
if ("payment.completed".equals(eventType)) {
payment.setStatus(EstablishmentPayment.STATUS_COMPLETED);
} else if ("payment.cancelled".equals(eventType) || "payment.expired".equals(eventType)) {
payment.setStatus(EstablishmentPayment.STATUS_CANCELLED);
}
paymentRepository.persist(payment);
});
}
}

View File

@@ -0,0 +1,46 @@
package com.lions.dev.util;
/**
* Rôles utilisateur de l'application AfterWork.
* Hiérarchie : SUPER_ADMIN > ADMIN > MANAGER > USER.
*/
public final class UserRole {
private UserRole() {}
/** Utilisateur standard (participation aux événements, profil, amis). */
public static final String USER = "USER";
/** Responsable d'établissement (gestion de son établissement). */
public static final String MANAGER = "MANAGER";
/** Administrateur (gestion des établissements, modération). */
public static final String ADMIN = "ADMIN";
/**
* Super administrateur : tous les droits (gestion des utilisateurs, attribution des rôles,
* gestion des établissements, accès aux paiements Wave, etc.).
*/
public static final String SUPER_ADMIN = "SUPER_ADMIN";
/**
* Vérifie si le rôle a les droits super admin (ou est super admin).
*/
public static boolean isSuperAdmin(String role) {
return SUPER_ADMIN.equals(role);
}
/**
* Vérifie si le rôle peut gérer les utilisateurs (attribution de rôles, etc.).
*/
public static boolean canManageUsers(String role) {
return SUPER_ADMIN.equals(role) || ADMIN.equals(role);
}
/**
* Vérifie si le rôle peut gérer les établissements (vérification, modération).
*/
public static boolean canManageEstablishments(String role) {
return SUPER_ADMIN.equals(role) || ADMIN.equals(role);
}
}

View File

@@ -0,0 +1,36 @@
package com.lions.dev.util;
/**
* Rôles utilisateur de l'application AfterWork.
* Hiérarchie : SUPER_ADMIN > ADMIN > MANAGER > USER.
*/
public final class UserRoles {
private UserRoles() {}
/** Super administrateur : tous les droits (gestion utilisateurs, attribution de rôles, etc.). */
public static final String SUPER_ADMIN = "SUPER_ADMIN";
/** Administrateur : gestion courante de l'application. */
public static final String ADMIN = "ADMIN";
/** Manager : gestion d'établissements, événements, etc. */
public static final String MANAGER = "MANAGER";
/** Utilisateur standard. */
public static final String USER = "USER";
/**
* Indique si le rôle a les droits super administrateur (ou est SUPER_ADMIN).
*/
public static boolean isSuperAdmin(String role) {
return SUPER_ADMIN.equals(role);
}
/**
* Indique si le rôle peut gérer les utilisateurs (attribution de rôles, etc.).
*/
public static boolean canManageUsers(String role) {
return SUPER_ADMIN.equals(role) || ADMIN.equals(role);
}
}

View File

@@ -81,25 +81,30 @@ public class ChatWebSocketNext {
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");
Map<String, Object> raw = mapper.readValue(message, Map.class);
String type = (String) raw.get("type");
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) raw.get("data");
switch (type) {
case "message":
handleChatMessage(messageData, userId);
if (data != null) handleChatMessage(data, userId);
else Log.warn("[CHAT-WS-NEXT] Message sans 'data'");
break;
case "typing":
handleTypingIndicator(messageData, userId);
if (data != null) handleTypingIndicator(data, userId);
break;
case "read":
handleReadReceipt(messageData, userId);
if (data != null) handleReadReceipt(data, userId);
else Log.warn("[CHAT-WS-NEXT] Read receipt sans 'data'");
break;
case "ping":
// Heartbeat - ignorer
break;
default:
Log.warn("[CHAT-WS-NEXT] Type de message inconnu: " + type);
Log.warn("[CHAT-WS-NEXT] Type inconnu: " + type);
}
} catch (Exception e) {
@@ -108,16 +113,17 @@ public class ChatWebSocketNext {
}
/**
* Gère l'envoi d'un message de chat.
* Le message est traité par MessageService qui publiera dans Kafka.
* Gère l'envoi d'un message de chat via WebSocket.
* Note: L'envoi principal passe par REST (POST /messages). Cette méthode
* est pour compatibilité si le client envoie via WebSocket.
*/
private void handleChatMessage(Map<String, Object> messageData, String senderId) {
private void handleChatMessage(Map<String, Object> data, 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");
UUID recipientUUID = UUID.fromString((String) data.get("recipientId"));
String content = (String) data.get("content");
String messageType = data.getOrDefault("messageType", "text").toString();
String mediaUrl = (String) data.get("mediaUrl");
// Enregistrer le message dans la base de données
// MessageService publiera automatiquement dans Kafka
@@ -146,13 +152,21 @@ public class ChatWebSocketNext {
/**
* Gère les indicateurs de frappe.
* data doit contenir recipientId (ID du destinataire) et isTyping.
*/
private void handleTypingIndicator(Map<String, Object> messageData, String userId) {
private void handleTypingIndicator(Map<String, Object> data, String userId) {
try {
UUID recipientUUID = UUID.fromString((String) messageData.get("recipientId"));
boolean isTyping = (boolean) messageData.getOrDefault("isTyping", false);
Object recipientIdObj = data.get("recipientId");
if (recipientIdObj == null) {
Log.warn("[CHAT-WS-NEXT] Typing sans recipientId - ignoré");
return;
}
UUID recipientUUID = UUID.fromString(recipientIdObj.toString());
Object isTypingObj = data.get("isTyping");
boolean isTyping = isTypingObj instanceof Boolean ? (Boolean) isTypingObj : Boolean.parseBoolean(String.valueOf(isTypingObj));
String response = buildJsonMessage("typing", Map.of(
"conversationId", data.getOrDefault("conversationId", ""),
"userId", userId,
"isTyping", isTyping
));
@@ -168,22 +182,22 @@ public class ChatWebSocketNext {
/**
* Gère les confirmations de lecture.
* Envoie type "read" (format attendu par le client Flutter).
*/
private void handleReadReceipt(Map<String, Object> messageData, String userId) {
private void handleReadReceipt(Map<String, Object> data, String userId) {
try {
UUID messageUUID = UUID.fromString((String) messageData.get("messageId"));
UUID messageUUID = UUID.fromString((String) data.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(
long now = System.currentTimeMillis();
String timestampIso = java.time.Instant.ofEpochMilli(now).toString();
String response = buildJsonMessage("read", Map.of(
"messageId", messageUUID.toString(),
"readBy", userId,
"readAt", System.currentTimeMillis()
"userId", userId,
"timestamp", timestampIso
));
sendToUser(senderUUID, response);

View File

@@ -3,48 +3,51 @@ 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.annotation.PostConstruct;
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.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
/**
* Bridge qui consomme depuis Kafka et envoie via WebSocket pour le chat.
*
* Architecture:
* Architecture (best practice):
* MessageService → Kafka Topic (chat.messages) → Bridge → WebSocket → Client
*/
@ApplicationScoped
public class ChatKafkaBridge {
@PostConstruct
public void init() {
Log.info("[KAFKA-BRIDGE] Bridge démarré pour topic: chat.messages");
}
/**
* 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() +
Log.debug("[CHAT-BRIDGE] Événement 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) {
@@ -57,29 +60,66 @@ public class ChatKafkaBridge {
}
/**
* Construit le message JSON pour WebSocket à partir de l'événement Kafka.
* Construit le message JSON pour WebSocket (format attendu par le client Flutter).
*/
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());
String eventType = event.getEventType() != null ? event.getEventType() : "message";
long ts = event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis();
String timestampIso = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(ts));
Map<String, Object> data;
String type;
switch (eventType) {
case "delivery_confirmation":
type = "delivered";
data = new HashMap<>();
data.put("messageId", event.getMessageId());
data.put("isDelivered", event.getMetadata() != null &&
Boolean.TRUE.equals(event.getMetadata().get("isDelivered")));
data.put("timestamp", ts);
break;
case "read_confirmation":
type = "read";
data = new HashMap<>();
data.put("messageId", event.getMessageId());
data.put("userId", event.getMetadata() != null ?
event.getMetadata().get("readBy") : event.getSenderId());
data.put("timestamp", timestampIso);
break;
case "message":
default:
type = "message";
data = new HashMap<>();
data.put("id", event.getMessageId());
data.put("conversationId", event.getConversationId());
data.put("senderId", event.getSenderId());
data.put("recipientId", event.getRecipientId());
data.put("content", event.getContent() != null ? event.getContent() : "");
data.put("timestamp", timestampIso);
data.put("isRead", event.getMetadata() != null &&
Boolean.TRUE.equals(event.getMetadata().get("isRead")));
data.put("isDelivered", true);
if (event.getMetadata() != null) {
data.put("senderFirstName", event.getMetadata().getOrDefault("senderFirstName", ""));
data.put("senderLastName", event.getMetadata().getOrDefault("senderLastName", ""));
data.put("senderProfileImageUrl", event.getMetadata().getOrDefault("senderProfileImageUrl", ""));
data.put("attachmentUrl", event.getMetadata().getOrDefault("attachmentUrl", ""));
data.put("attachmentType", event.getMetadata().getOrDefault("attachmentType", "text"));
}
break;
}
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()
);
Map<String, Object> wsMessage = new HashMap<>();
wsMessage.put("type", type);
wsMessage.put("data", data);
wsMessage.put("timestamp", ts);
return mapper.writeValueAsString(wsMessage);
} catch (Exception e) {

View File

@@ -3,6 +3,7 @@ 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.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message;
@@ -25,6 +26,11 @@ import java.util.concurrent.CompletionStage;
@ApplicationScoped
public class NotificationKafkaBridge {
@PostConstruct
public void init() {
Log.info("[KAFKA-BRIDGE] Bridge démarré pour topic: notifications");
}
/**
* Consomme les événements depuis Kafka et les route vers WebSocket.
*

View File

@@ -3,6 +3,7 @@ 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.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message;
@@ -23,6 +24,11 @@ import java.util.concurrent.CompletionStage;
@ApplicationScoped
public class PresenceKafkaBridge {
@PostConstruct
public void init() {
Log.info("[KAFKA-BRIDGE] Bridge démarré pour topic: presence.updates");
}
/**
* Consomme les événements de présence depuis Kafka et les route vers WebSocket.
*

View File

@@ -3,6 +3,7 @@ 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.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message;
@@ -22,6 +23,11 @@ import java.util.concurrent.CompletionStage;
@ApplicationScoped
public class ReactionKafkaBridge {
@PostConstruct
public void init() {
Log.info("[KAFKA-BRIDGE] Bridge démarré pour topic: reactions");
}
/**
* Consomme les réactions depuis Kafka et les route vers WebSocket.
*

View File

@@ -4,6 +4,13 @@
# Ce fichier est automatiquement chargé avec: mvn quarkus:dev
# Les configurations ici surchargent celles de application.properties
# ====================================================================
# Super administrateur (dev)
# ====================================================================
# En dev, clé par défaut pour PUT /users/{id}/role (header X-Super-Admin-Key).
# Saisir "dev-super-admin-key" dans l'app (Paramètres → Super Admin) pour attribuer des rôles.
afterwork.super-admin.api-key=${SUPER_ADMIN_API_KEY:dev-super-admin-key}
# ====================================================================
# Base de données H2 (en mémoire)
# ====================================================================
@@ -24,6 +31,12 @@ 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
# ====================================================================
# Kafka (développement local)
# ====================================================================
# En dev, défaut localhost:9092. Définir KAFKA_BOOTSTRAP_SERVERS si Kafka tourne ailleurs.
kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
# ====================================================================
# Logging
# ====================================================================

View File

@@ -1,8 +1,9 @@
# ====================================================================
# AfterWork Server - Configuration PRODUCTION
# AfterWork Server - Configuration PRODUCTION (profil prod)
# ====================================================================
# Ce fichier est automatiquement chargé avec: java -jar app.jar
# Les configurations ici surchargent celles de application.properties
# Chargé avec QUARKUS_PROFILE=prod (Kubernetes ConfigMap).
# Ce fichier remplace application-production.properties pour cohérence
# avec le déploiement (QUARKUS_PROFILE=prod).
# ====================================================================
# HTTP - Chemin de base de l'API
@@ -19,34 +20,26 @@
# - pathType: Prefix
# - PAS d'annotation rewrite-target
#
# Pourquoi cette approche ?
# - Swagger UI nécessite que l'application connaisse son contexte
# - Les URLs générées (OpenAPI, WebSocket) sont correctes
# - Cohérent avec les applications context-aware (btpxpress, etc.)
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 du chemin OpenAPI (relatif au root-path)
quarkus.smallrye-openapi.path=/openapi
# Configuration des serveurs OpenAPI pour que Swagger UI génère les bonnes URLs
quarkus.smallrye-openapi.servers=https://api.lions.dev/afterwork
# Configuration explicite de l'URL OpenAPI pour Swagger UI
# Essayer avec URL absolue pour forcer le bon chemin
quarkus.swagger-ui.urls.default=https://api.lions.dev/afterwork/openapi
quarkus.smallrye-openapi.servers=https://api.lions.dev
# ====================================================================
# Base de données PostgreSQL
# ====================================================================
# IMPORTANT: Les credentials doivent être fournis via variables d'environnement
# en production (Kubernetes Secrets ou Vault).
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.jdbc.url=jdbc:postgresql://${DB_HOST:postgresql-service.postgresql.svc.cluster.local}:${DB_PORT:5432}/${DB_NAME:mic-after-work-server-impl-quarkus-main}
quarkus.datasource.username=${DB_USERNAME:lionsuser}
quarkus.datasource.password=${DB_PASSWORD:LionsUser2025!}
quarkus.datasource.password=${DB_PASSWORD}
quarkus.datasource.jdbc.driver=org.postgresql.Driver
quarkus.datasource.jdbc.max-size=20
quarkus.datasource.jdbc.min-size=5
@@ -94,42 +87,18 @@ quarkus.log.category."io.quarkus".level=INFO
# ====================================================================
# Kafka Configuration (Production)
# ====================================================================
# Kafka est deploye dans le namespace 'kafka' du cluster Kubernetes
# Service: kafka-service.kafka.svc.cluster.local:9092
kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:kafka-service.kafka.svc.cluster.local:9092}
# Configuration de resilience Kafka
mp.messaging.connector.smallrye-kafka.health-enabled=true
mp.messaging.connector.smallrye-kafka.health-readiness-enabled=true
# Topics auto-crees par Quarkus SmallRye Kafka:
# - notifications
# - chat.messages
# - reactions
# - presence.updates
# ====================================================================
# 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)
# WebSocket / SSL / Performance / Localisation
# ====================================================================
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

@@ -17,6 +17,23 @@ quarkus.swagger-ui.always-include=true
quarkus.swagger-ui.path=/q/swagger-ui
quarkus.smallrye-openapi.path=/openapi
# ====================================================================
# Super administrateur (créé au démarrage si absent)
# ====================================================================
# En production, définir via variables d'environnement (SUPER_ADMIN_EMAIL, SUPER_ADMIN_PASSWORD).
afterwork.super-admin.email=${SUPER_ADMIN_EMAIL:superadmin@afterwork.lions.dev}
afterwork.super-admin.password=${SUPER_ADMIN_PASSWORD:SuperAdmin2025!}
afterwork.super-admin.first-name=${SUPER_ADMIN_FIRST_NAME:Super}
afterwork.super-admin.last-name=${SUPER_ADMIN_LAST_NAME:Administrator}
# Clé secrète pour les opérations admin (header X-Super-Admin-Key sur PUT /users/{id}/role, etc.)
afterwork.super-admin.api-key=${SUPER_ADMIN_API_KEY:}
# ====================================================================
# Wave API (paiement droits d'accès établissements)
# ====================================================================
wave.api.url=${WAVE_API_URL:https://api.wave.com}
wave.api.key=${WAVE_API_KEY:}
# ====================================================================
# HTTP (commun à tous les environnements)
# ====================================================================
@@ -62,24 +79,28 @@ kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:kafka-service.kafka.svc.cluste
# ====================================================================
# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter<EventType>
# Topic: Notifications
# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter<NotificationEvent>
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
# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter<ChatMessageEvent>
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)
# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter<ReactionEvent>
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
# Note: Quarkus génère automatiquement les serializers Jackson basés sur le type Emitter<PresenceEvent>
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

View File

@@ -0,0 +1,65 @@
-- Migration V12: Nettoyage des colonnes legacy de la table users
-- Date: 2026-01-26
-- Description: Supprime les anciennes colonnes (nom, prenoms, mot_de_passe) qui coexistent
-- avec les nouvelles (first_name, last_name, password_hash) suite à la migration V3.
--
-- Contexte: La migration V3 devait renommer les colonnes, mais Hibernate avait déjà créé
-- les nouvelles colonnes. Résultat: la table avait les deux sets de colonnes.
DO $$
BEGIN
-- Étape 1: S'assurer que les nouvelles colonnes existent et ont des données
-- Si first_name est vide mais nom a des données, copier
UPDATE users SET first_name = nom
WHERE nom IS NOT NULL
AND nom != ''
AND (first_name IS NULL OR first_name = '');
UPDATE users SET last_name = prenoms
WHERE prenoms IS NOT NULL
AND prenoms != ''
AND (last_name IS NULL OR last_name = '');
UPDATE users SET password_hash = mot_de_passe
WHERE mot_de_passe IS NOT NULL
AND mot_de_passe != ''
AND (password_hash IS NULL OR password_hash = '');
-- Étape 2: Rendre les anciennes colonnes nullable (si elles existent et sont NOT NULL)
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'nom') THEN
ALTER TABLE users ALTER COLUMN nom DROP NOT NULL;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'prenoms') THEN
ALTER TABLE users ALTER COLUMN prenoms DROP NOT NULL;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'mot_de_passe') THEN
ALTER TABLE users ALTER COLUMN mot_de_passe DROP NOT NULL;
END IF;
-- Étape 3: Supprimer les anciennes colonnes (optionnel - décommenter si vous voulez un schéma propre)
-- Note: Gardées pour l'instant pour compatibilité avec d'éventuels anciens clients
/*
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'nom') THEN
ALTER TABLE users DROP COLUMN nom;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'prenoms') THEN
ALTER TABLE users DROP COLUMN prenoms;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'mot_de_passe') THEN
ALTER TABLE users DROP COLUMN mot_de_passe;
END IF;
*/
END $$;
-- Commentaire explicatif
COMMENT ON TABLE users IS 'Table utilisateurs v2.0 - Colonnes legacy (nom, prenoms, mot_de_passe) dépréciées, utiliser first_name, last_name, password_hash';

View File

@@ -0,0 +1,57 @@
-- Migration V13: Abonnements et paiements établissements (Wave)
-- Date: 2026-01-28
-- Description: Tables pour les droits d'accès des établissements payés via Wave
-- Abonnements établissements
CREATE TABLE IF NOT EXISTS establishment_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
establishment_id UUID NOT NULL,
plan VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
wave_session_id VARCHAR(255),
amount_xof INTEGER,
paid_at TIMESTAMP,
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_establishment_subscriptions_establishment
FOREIGN KEY (establishment_id) REFERENCES establishments(id) ON DELETE CASCADE,
CONSTRAINT chk_establishment_subscriptions_plan
CHECK (plan IN ('MONTHLY', 'YEARLY')),
CONSTRAINT chk_establishment_subscriptions_status
CHECK (status IN ('PENDING', 'ACTIVE', 'EXPIRED', 'CANCELLED'))
);
CREATE INDEX IF NOT EXISTS idx_establishment_subscriptions_establishment
ON establishment_subscriptions(establishment_id);
CREATE INDEX IF NOT EXISTS idx_establishment_subscriptions_status
ON establishment_subscriptions(status);
CREATE INDEX IF NOT EXISTS idx_establishment_subscriptions_wave_session
ON establishment_subscriptions(wave_session_id);
-- Paiements établissements (historique Wave)
CREATE TABLE IF NOT EXISTS establishment_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
establishment_id UUID NOT NULL,
amount_xof INTEGER NOT NULL,
wave_session_id VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
client_phone VARCHAR(30),
plan VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_establishment_payments_establishment
FOREIGN KEY (establishment_id) REFERENCES establishments(id) ON DELETE CASCADE,
CONSTRAINT chk_establishment_payments_status
CHECK (status IN ('PENDING', 'COMPLETED', 'FAILED', 'CANCELLED'))
);
CREATE INDEX IF NOT EXISTS idx_establishment_payments_establishment
ON establishment_payments(establishment_id);
CREATE INDEX IF NOT EXISTS idx_establishment_payments_wave_session
ON establishment_payments(wave_session_id);
CREATE INDEX IF NOT EXISTS idx_establishment_payments_status
ON establishment_payments(status);
COMMENT ON TABLE establishment_subscriptions IS 'Abonnements / droits d''accès des établissements (paiement Wave)';
COMMENT ON TABLE establishment_payments IS 'Historique des paiements Wave pour les établissements';

View File

@@ -0,0 +1,9 @@
-- V14: Ajout is_active pour Manager & Abonnement (suspension automatique Wave)
-- users.is_active : false = manager suspendu (paiement échoué/annulé)
-- establishments.is_active : false = établissement masqué (abonnement inactif)
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE establishments ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
COMMENT ON COLUMN users.is_active IS 'false = compte suspendu (ex: manager abonnement expiré)';
COMMENT ON COLUMN establishments.is_active IS 'false = établissement masqué (abonnement inactif)';

View File

@@ -0,0 +1,38 @@
-- Migration V15: Création de la table social_posts (Publications)
-- Date: 2026-01-28
-- Description: Table des posts sociaux pour le fil d'actualité AfterWork (correction 500)
CREATE TABLE IF NOT EXISTS social_posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
content VARCHAR(2000) NOT NULL,
user_id UUID NOT NULL,
image_url VARCHAR(500),
likes_count INTEGER NOT NULL DEFAULT 0,
comments_count INTEGER NOT NULL DEFAULT 0,
shares_count INTEGER NOT NULL DEFAULT 0,
CONSTRAINT fk_social_posts_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_social_posts_user_id ON social_posts(user_id);
CREATE INDEX IF NOT EXISTS idx_social_posts_created_at ON social_posts(created_at DESC);
CREATE OR REPLACE FUNCTION update_social_posts_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_social_posts_updated_at
BEFORE UPDATE ON social_posts
FOR EACH ROW
EXECUTE FUNCTION update_social_posts_updated_at();
COMMENT ON TABLE social_posts IS 'Publications (posts) sociaux du fil AfterWork';

View File

@@ -0,0 +1,81 @@
-- V1_1__Create_Base_Tables_For_Fresh_Db.sql
-- Création des tables de base pour une base de données vierge.
-- À exécuter après V1__Baseline lorsque le schéma n'a pas été créé par Hibernate.
-- Les migrations V2, V3, V4, V7+ supposent que users, establishments et events existent.
-- Table users (structure minimale attendue par V3 et l'entité Users)
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
first_name VARCHAR(100) NOT NULL DEFAULT '',
last_name VARCHAR(100) NOT NULL DEFAULT '',
email VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL DEFAULT '',
role VARCHAR(50) NOT NULL,
profile_image_url VARCHAR(500),
bio VARCHAR(500),
loyalty_points INTEGER NOT NULL DEFAULT 0,
preferences JSONB NOT NULL DEFAULT '{}',
is_verified BOOLEAN NOT NULL DEFAULT false,
is_online BOOLEAN NOT NULL DEFAULT false,
last_seen TIMESTAMP,
is_active BOOLEAN NOT NULL DEFAULT true
);
-- Table establishments (V4 renomme total_ratings_count -> total_reviews_count)
CREATE TABLE IF NOT EXISTS establishments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
name VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL,
address VARCHAR(500) NOT NULL,
city VARCHAR(100) NOT NULL,
postal_code VARCHAR(20) NOT NULL,
description VARCHAR(2000),
phone_number VARCHAR(50),
website VARCHAR(500),
average_rating DOUBLE PRECISION,
total_ratings_count INTEGER,
price_range VARCHAR(20),
verification_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
is_active BOOLEAN NOT NULL DEFAULT true,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
manager_id UUID NOT NULL REFERENCES users(id)
);
-- Table events (structure minimale ; V2 et V7 ajoutent les colonnes supplémentaires)
CREATE TABLE IF NOT EXISTS events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
title VARCHAR(255) NOT NULL,
description VARCHAR(1000),
start_date TIMESTAMP NOT NULL,
end_date TIMESTAMP NOT NULL,
category VARCHAR(100),
link VARCHAR(500),
image_url VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'OPEN',
creator_id UUID NOT NULL REFERENCES users(id)
);
-- Table de jointure event_participants (events <-> users)
CREATE TABLE IF NOT EXISTS event_participants (
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (event_id, user_id)
);
-- Table de jointure user_favorite_events (users <-> events)
CREATE TABLE IF NOT EXISTS user_favorite_events (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, event_id)
);
-- Index pour les FK et recherches courantes
CREATE INDEX IF NOT EXISTS idx_establishments_manager ON establishments(manager_id);
CREATE INDEX IF NOT EXISTS idx_events_creator ON events(creator_id);

View File

@@ -1,13 +1,38 @@
-- 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
-- Note: Migration rendue idempotente pour éviter les conflits avec Hibernate
-- 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;
-- Renommer/migrer les colonnes existantes (idempotent)
DO $$
BEGIN
-- Si first_name n'existe pas mais nom existe, renommer
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='first_name')
AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='nom') THEN
ALTER TABLE users RENAME COLUMN nom TO first_name;
-- Si first_name n'existe pas du tout, le créer
ELSIF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='first_name') THEN
ALTER TABLE users ADD COLUMN first_name VARCHAR(100) NOT NULL DEFAULT '';
END IF;
-- Ajouter les nouvelles colonnes
-- Si last_name n'existe pas mais prenoms existe, renommer
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='last_name')
AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='prenoms') THEN
ALTER TABLE users RENAME COLUMN prenoms TO last_name;
ELSIF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='last_name') THEN
ALTER TABLE users ADD COLUMN last_name VARCHAR(100) NOT NULL DEFAULT '';
END IF;
-- Si password_hash n'existe pas mais mot_de_passe existe, renommer
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='password_hash')
AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='mot_de_passe') THEN
ALTER TABLE users RENAME COLUMN mot_de_passe TO password_hash;
ELSIF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='password_hash') THEN
ALTER TABLE users ADD COLUMN password_hash VARCHAR(255) NOT NULL DEFAULT '';
END IF;
END $$;
-- Ajouter les nouvelles colonnes (déjà idempotent)
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;

View File

@@ -47,10 +47,11 @@ BEGIN
END;
$$ LANGUAGE plpgsql;
-- EXECUTE PROCEDURE pour compatibilité PostgreSQL < 11 (EXECUTE FUNCTION à partir de PG 11)
CREATE TRIGGER trigger_update_business_hours_updated_at
BEFORE UPDATE ON business_hours
FOR EACH ROW
EXECUTE FUNCTION update_business_hours_updated_at();
EXECUTE PROCEDURE update_business_hours_updated_at();
-- Commentaires pour documentation
COMMENT ON TABLE business_hours IS 'Horaires d''ouverture des établissements (v2.0)';