package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.membre.CompteAdherentResponse;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.MembreOrganisation;
import dev.lions.unionflow.server.entity.SouscriptionOrganisation;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.SouscriptionOrganisationRepository;
import dev.lions.unionflow.server.service.CompteAdherentService;
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
import dev.lions.unionflow.server.service.MembreService;
import dev.lions.unionflow.server.service.support.SecuriteHelper;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
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.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* Endpoint REST pour le compte adhérent du membre connecté.
*
*
Toutes les routes de ce resource sont protégées et ne retournent
* que les données du membre connecté (pas d'accès aux comptes tiers).
*
*
Exemple de réponse :
*
* GET /api/membres/mon-compte
* → { "numeroMembre": "MUF-2026-001", "soldeTotalDisponible": 215000, ... }
*
*/
@Path("/api/membres")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Compte Adhérent", description = "Vue financière unifiée du membre connecté")
public class CompteAdherentResource {
private static final Logger LOG = Logger.getLogger(CompteAdherentResource.class);
@Inject
CompteAdherentService compteAdherentService;
@Inject
SecuriteHelper securiteHelper;
@Inject
MembreRepository membreRepository;
@Inject
MembreOrganisationRepository membreOrganisationRepo;
@Inject
SouscriptionOrganisationRepository souscriptionRepo;
@Inject
MembreKeycloakSyncService membreKeycloakSyncService;
@Inject
MembreService membreService;
/**
* Retourne le compte adhérent complet du membre connecté :
* numéro de membre, soldes (cotisations + épargne), capacité d'emprunt, taux d'engagement.
*/
@GET
@Path("/mon-compte")
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "SUPER_ADMIN" })
@Operation(
summary = "Compte adhérent du membre connecté",
description = "Agrège cotisations, épargne et crédit en une vue financière unifiée."
)
public Response getMonCompte() {
CompteAdherentResponse compte = compteAdherentService.getMonCompte();
return Response.ok(compte).build();
}
/**
* Retourne le statut du compte du membre connecté.
*
* Endpoint léger appelé par le mobile juste après le login Keycloak
* pour détecter les comptes en attente/suspendus/désactivés avant d'accorder l'accès.
*
*
Si aucun enregistrement membre n'existe (ex. : administrateur créé directement
* dans Keycloak sans fiche membre), retourne {@code ACTIF} pour ne pas bloquer les admins.
*/
@GET
@Path("/mon-statut")
@Authenticated
@Operation(
summary = "Statut du compte du membre connecté",
description = "Retourne statutCompte : ACTIF, EN_ATTENTE_VALIDATION, SUSPENDU ou DESACTIVE."
)
public Response getMonStatut() {
String email = securiteHelper.resolveEmail();
if (email == null || email.isBlank()) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
Optional membreOpt = membreRepository.findByEmail(email.trim())
.or(() -> membreRepository.findByEmail(email.trim().toLowerCase()));
// Pas de fiche membre → administrateur pur → accès autorisé
String statutCompte = membreOpt
.map(Membre::getStatutCompte)
.filter(s -> s != null && !s.isBlank())
.orElse("ACTIF");
// Premier login : Keycloak a déjà forcé UPDATE_PASSWORD dans le Chrome Custom Tab.
// Si l'utilisateur possède un token valide (@Authenticated), c'est la preuve que
// le changement de mot de passe est complété — mettre à jour la DB et assigner les rôles.
// Pour les anciens comptes (sans UPDATE_PASSWORD assigné), le service assigne automatiquement
// la required action et retourne REAUTH_REQUIS pour que Flutter déclenche une nouvelle auth.
boolean premierLoginComplet = false;
boolean reAuthRequired = false;
if (membreOpt.isPresent() && Boolean.TRUE.equals(membreOpt.get().getPremiereConnexion())) {
Membre m = membreOpt.get();
MembreKeycloakSyncService.PremierLoginResultat resultat =
membreKeycloakSyncService.completerPremierLogin(m.getId());
if (resultat == MembreKeycloakSyncService.PremierLoginResultat.COMPLETE) {
premierLoginComplet = true;
// Relire le statutCompte après activation éventuelle
statutCompte = membreRepository.findByIdOptional(m.getId())
.map(Membre::getStatutCompte)
.orElse(statutCompte);
LOG.infof("Premier login complété au statut pour %s → %s", m.getEmail(), statutCompte);
} else if (resultat == MembreKeycloakSyncService.PremierLoginResultat.REAUTH_REQUIS) {
reAuthRequired = true;
LOG.infof("Réauthentification requise pour %s (UPDATE_PASSWORD assigné)", m.getEmail());
}
}
// Fallback : auto-activer si EN_ATTENTE_VALIDATION et org a souscription active
// (membres sans premiereConnexion=true ou créés avant cette logique)
if ("EN_ATTENTE_VALIDATION".equals(statutCompte) && membreOpt.isPresent()) {
Membre m = membreOpt.get();
UUID orgId = membreOrganisationRepo.findFirstByMembreId(m.getId())
.map(mo -> mo.getOrganisation().getId())
.orElse(null);
if (membreService.orgHasActiveSubscription(orgId)) {
LOG.infof("Auto-activation au login de %s (org %s a souscription active)", m.getEmail(), orgId);
membreService.activerMembre(m.getId());
try {
membreKeycloakSyncService.activerMembreDansKeycloak(m.getId());
} catch (Exception e) {
LOG.warnf("Activation Keycloak au login échouée pour %s (non bloquant): %s",
m.getEmail(), e.getMessage());
}
statutCompte = "ACTIF";
}
}
Map response = new HashMap<>();
response.put("statutCompte", statutCompte);
// changerMotDePasseRequis = false : Keycloak gère nativement le changement de mot de passe
// via la required action UPDATE_PASSWORD dans le Chrome Custom Tab (AppAuth).
response.put("changerMotDePasseRequis", false);
// Indique à Flutter que le token doit être rafraîchi (nouveaux rôles MEMBRE/MEMBRE_ACTIF)
response.put("premierLoginComplet", premierLoginComplet);
// Indique à Flutter qu'une réauthentification est nécessaire (UPDATE_PASSWORD vient d'être assigné)
response.put("reAuthRequired", reAuthRequired);
// Enrichir avec l'état d'onboarding pour les comptes en attente
if ("EN_ATTENTE_VALIDATION".equals(statutCompte)) {
membreOpt.flatMap(m -> membreOrganisationRepo.findFirstByMembreId(m.getId())
.map(MembreOrganisation::getOrganisation))
.ifPresent(org -> {
response.put("organisationId", org.getId().toString());
Optional souscOpt =
souscriptionRepo.findLatestByOrganisationId(org.getId());
if (souscOpt.isEmpty()) {
response.put("onboardingState", "NO_SUBSCRIPTION");
} else {
SouscriptionOrganisation sosc = souscOpt.get();
String valState = sosc.getStatutValidation() != null
? sosc.getStatutValidation().name()
: "EN_ATTENTE_PAIEMENT";
String onboardingState = switch (valState) {
case "EN_ATTENTE_PAIEMENT" -> "AWAITING_PAYMENT";
case "PAIEMENT_INITIE" -> "PAYMENT_INITIATED";
case "PAIEMENT_CONFIRME" -> "AWAITING_VALIDATION";
case "VALIDEE" -> "VALIDATED";
case "REJETEE" -> "REJECTED";
default -> "AWAITING_PAYMENT";
};
response.put("onboardingState", onboardingState);
response.put("souscriptionId", sosc.getId().toString());
if (sosc.getWaveSessionId() != null) {
response.put("waveSessionId", sosc.getWaveSessionId());
}
}
response.put("typeOrganisation", org.getTypeOrganisation() != null
? org.getTypeOrganisation() : "ASSOCIATION");
});
if (!response.containsKey("onboardingState")) {
response.put("onboardingState", "NO_SUBSCRIPTION");
}
}
return Response.ok(response).build();
}
/**
* Permet au membre connecté de changer son mot de passe lors du premier login.
* Appelle Keycloak via lions-user-manager et marque {@code premiereConnexion = false}.
*
* Body attendu : {@code { "nouveauMotDePasse": "..." }}
*/
@PUT
@Path("/mon-compte/mot-de-passe")
@Authenticated
@Operation(
summary = "Changer le mot de passe au premier login",
description = "Met à jour le mot de passe Keycloak et lève le flag premiereConnexion."
)
public Response changerMotDePasse(Map body) {
String email = securiteHelper.resolveEmail();
if (email == null || email.isBlank()) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
String nouveauMotDePasse = body == null ? null : body.get("nouveauMotDePasse");
if (nouveauMotDePasse == null || nouveauMotDePasse.isBlank()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("message", "Le champ 'nouveauMotDePasse' est requis."))
.build();
}
Optional membreOpt = membreRepository.findByEmail(email.trim())
.or(() -> membreRepository.findByEmail(email.trim().toLowerCase()));
if (membreOpt.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("message", "Aucun membre trouvé pour ce compte."))
.build();
}
membreKeycloakSyncService.changerMotDePassePremierLogin(membreOpt.get().getId(), nouveauMotDePasse);
return Response.ok(Map.of("message", "Mot de passe mis à jour avec succès.")).build();
}
/**
* Endpoint mobile : changement de mot de passe depuis l'app Flutter.
* Bypass lions-user-manager — appel direct à l'API Admin Keycloak.
*
* Body attendu : {@code { "userId": "...", "oldPassword": "...", "newPassword": "..." }}
*/
@POST
@Path("/auth/change-password")
@Authenticated
@Operation(
summary = "Changer le mot de passe (mobile)",
description = "Endpoint dédié à l'application mobile. Bypass lions-user-manager via API Admin Keycloak directe."
)
public Response changerMotDePasseMobile(Map body) {
String email = securiteHelper.resolveEmail();
if (email == null || email.isBlank()) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
String newPassword = body == null ? null : body.get("newPassword");
if (newPassword == null || newPassword.isBlank()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("message", "Le champ 'newPassword' est requis."))
.build();
}
if (newPassword.length() < 8) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("message", "Le mot de passe doit contenir au moins 8 caractères."))
.build();
}
Optional membreOpt = membreRepository.findByEmail(email.trim())
.or(() -> membreRepository.findByEmail(email.trim().toLowerCase()));
if (membreOpt.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("message", "Aucun membre trouvé pour ce compte."))
.build();
}
try {
membreKeycloakSyncService.changerMotDePasseDirectKeycloak(membreOpt.get().getId(), newPassword);
return Response.ok(Map.of("message", "Mot de passe mis à jour avec succès.")).build();
} catch (Exception e) {
LOG.errorf(e, "Erreur changement mot de passe pour %s", email);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("message", "Erreur lors du changement de mot de passe: " + e.getMessage()))
.build();
}
}
}