diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/CheckoutModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/CheckoutModel.java new file mode 100644 index 0000000..0308260 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/model/CheckoutModel.java @@ -0,0 +1,41 @@ +package fr.titionfire.ffsaf.data.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.persistence.*; +import lombok.*; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import java.util.Date; +import java.util.List; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection + +@Entity +@Table(name = "checkout") +public class CheckoutModel { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "Identifiant du checkout", example = "42") + Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "membre", referencedColumnName = "id") + MembreModel membre; + + Date creationDate = new Date(); + + List licenseIds; + + Integer checkoutId; + + PaymentStatus paymentStatus; + + public enum PaymentStatus { + PENDING, AUTHORIZED, REFUSED, UNKNOW, REGISTERED, REFUNDING, REFUNDED, CONTESTED + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java index 75bb228..cf080b4 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java @@ -14,7 +14,7 @@ import org.eclipse.microprofile.openapi.annotations.media.Schema; @Entity @Table(name = "licence") -public class LicenceModel { +public class LicenceModel implements LoggableModel { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Schema(description = "L'identifiant de la licence.") @@ -30,9 +30,23 @@ public class LicenceModel { @Schema(description = "La saison de la licence.", example = "2025") int saison; - @Schema(description = "Nom du médecin sur certificat médical.", example = "M. Jean") + @Schema(description = "Nom et date du médecin sur certificat médical.", example = "M. Jean¤2025-02-03", format = "¤") String certificate; @Schema(description = "Licence validée", example = "true") boolean validate; + + @Schema(description = "Licence payer", example = "true") + @Column(nullable = false, columnDefinition = "boolean default false") + boolean pay = false; + + @Override + public String getObjectName() { + return "licence " + id.toString(); + } + + @Override + public LogModel.ObjectType getObjectType() { + return LogModel.ObjectType.Licence; + } } diff --git a/src/main/java/fr/titionfire/ffsaf/data/repository/CheckoutRepository.java b/src/main/java/fr/titionfire/ffsaf/data/repository/CheckoutRepository.java new file mode 100644 index 0000000..6a4d68d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/repository/CheckoutRepository.java @@ -0,0 +1,9 @@ +package fr.titionfire.ffsaf.data.repository; + +import fr.titionfire.ffsaf.data.model.CheckoutModel; +import io.quarkus.hibernate.reactive.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class CheckoutRepository implements PanacheRepositoryBase { +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index cc7bf88..3604881 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -20,18 +20,11 @@ import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.hibernate.reactive.mutiny.Mutiny; -import java.io.*; -import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.List; -import java.util.UUID; -import java.util.concurrent.TimeUnit; import java.util.stream.Stream; @WithSession @@ -62,6 +55,9 @@ public class AffiliationService { @Inject ReactiveMailer reactiveMailer; + @Inject + LoggerService ls; + @ConfigProperty(name = "upload_dir") String media; @@ -270,7 +266,9 @@ public class AffiliationService { .call(l1 -> l1 != null && l1.stream().anyMatch(l -> l.getSaison() == saison) ? Uni.createFrom().nullItem() : Panache.withTransaction(() -> licenceRepository.persist( - new LicenceModel(null, m, club.getId(), saison, null, true))))); + new LicenceModel(null, m, club.getId(), saison, null, true, false))) + .call(licenceModel -> ls.logA(LogModel.ActionType.ADD, m.getObjectName(), + licenceModel)))); } public Uni accept(AffiliationRequestSaveForm form) { diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java new file mode 100644 index 0000000..ce2ee6d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java @@ -0,0 +1,175 @@ +package fr.titionfire.ffsaf.domain.service; + +import fr.titionfire.ffsaf.data.model.CheckoutModel; +import fr.titionfire.ffsaf.data.model.LogModel; +import fr.titionfire.ffsaf.data.repository.CheckoutRepository; +import fr.titionfire.ffsaf.data.repository.LicenceRepository; +import fr.titionfire.ffsaf.rest.client.HelloAssoService; +import fr.titionfire.ffsaf.rest.client.dto.CheckoutIntentsRequest; +import fr.titionfire.ffsaf.rest.client.dto.CheckoutIntentsResponse; +import fr.titionfire.ffsaf.rest.client.dto.CheckoutMetadata; +import fr.titionfire.ffsaf.rest.exception.DInternalError; +import fr.titionfire.ffsaf.utils.SecurityCtx; +import fr.titionfire.ffsaf.utils.Utils; +import io.quarkus.hibernate.reactive.panache.Panache; +import io.quarkus.hibernate.reactive.panache.common.WithSession; +import io.quarkus.scheduler.Scheduled; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.hibernate.reactive.mutiny.Mutiny; + +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +@WithSession +@ApplicationScoped +public class CheckoutService { + + @Inject + CheckoutRepository repository; + + @Inject + LicenceRepository licenceRepository; + + @Inject + MembreService membreService; + + @Inject + LicenceService licenceService; + + @Inject + LoggerService ls; + + @RestClient + HelloAssoService helloAssoService; + + @ConfigProperty(name = "frontRootUrl") + String frontRootUrl; + + @ConfigProperty(name = "unitLicencePrice") + int unitLicencePrice; + + @ConfigProperty(name = "helloasso.organizationSlug") + String organizationSlug; + + public Uni canDeleteLicence(long id) { + return repository.find("?1 IN licenseIds", id).count().map(count -> count == 0); + } + + public Uni create(List ids, SecurityCtx securityCtx) { + return membreService.getByAccountId(securityCtx.getSubject()) + .call(membreModel -> Mutiny.fetch(membreModel.getClub())) + .chain(membreModel -> { + CheckoutModel model = new CheckoutModel(); + model.setMembre(membreModel); + model.setLicenseIds(ids); + model.setPaymentStatus(CheckoutModel.PaymentStatus.UNKNOW); + + return Panache.withTransaction(() -> repository.persist(model)); + }) + .chain(checkoutModel -> { + CheckoutIntentsRequest request = new CheckoutIntentsRequest(); + request.setTotalAmount(unitLicencePrice * checkoutModel.getLicenseIds().size()); + request.setInitialAmount(unitLicencePrice * checkoutModel.getLicenseIds().size()); + request.setItemName("%d licences %d-%d pour %s".formatted(checkoutModel.getLicenseIds().size(), + Utils.getSaison(), Utils.getSaison() + 1, checkoutModel.getMembre().getClub().getName())); + request.setBackUrl(frontRootUrl + "/club/member/pay"); + request.setErrorUrl(frontRootUrl + "/club/member/pay/error"); + request.setReturnUrl(frontRootUrl + "/club/member/pay/return"); + request.setContainsDonation(false); + request.setPayer(new CheckoutIntentsRequest.Payer(checkoutModel.getMembre().getFname(), + checkoutModel.getMembre().getLname(), checkoutModel.getMembre().getEmail())); + request.setMetadata(new CheckoutMetadata(checkoutModel.getId())); + + return helloAssoService.checkout(organizationSlug, request) + .call(response -> { + checkoutModel.setCheckoutId(response.getId()); + return Panache.withTransaction(() -> repository.persist(checkoutModel)); + }); + }) + .onFailure().transform(t -> new DInternalError(t.getMessage())) + .map(CheckoutIntentsResponse::getRedirectUrl); + } + + public Uni paymentStatusChange(String state, CheckoutMetadata metadata) { + return repository.findById(metadata.getCheckoutDBId()) + .chain(checkoutModel -> { + CheckoutModel.PaymentStatus newStatus = CheckoutModel.PaymentStatus.valueOf(state.toUpperCase()); + + Uni uni = Uni.createFrom().nullItem(); + + if (checkoutModel.getPaymentStatus().equals(newStatus)) + return uni; + + if (newStatus.equals(CheckoutModel.PaymentStatus.AUTHORIZED)) { + for (Long id : checkoutModel.getLicenseIds()) { + uni = uni.chain(__ -> licenceRepository.findById(id) + .onFailure().recoverWithNull() + .call(licenceModel -> { + if (licenceModel == null) { + ls.logAnonymous(LogModel.ActionType.UPDATE, LogModel.ObjectType.Licence, + "Fail to save payment for licence (checkout n°" + checkoutModel.getCheckoutId() + ")", + "", id); + return Uni.createFrom().nullItem(); + } + + ls.logUpdateAnonymous("Paiement de la licence", licenceModel); + licenceModel.setPay(true); + + if (licenceModel.getCertificate() != null && licenceModel.getCertificate() + .length() > 3) { + if (!licenceModel.isValidate()) + ls.logUpdateAnonymous("Validation automatique de la licence", + licenceModel); + return licenceService.validateLicences(licenceModel); + } else { + return Panache.withTransaction( + () -> licenceRepository.persist(licenceModel)); + } + })); + } + } else if (checkoutModel.getPaymentStatus().equals(CheckoutModel.PaymentStatus.AUTHORIZED)) { + for (Long id : checkoutModel.getLicenseIds()) { + uni = uni.chain(__ -> licenceRepository.findById(id) + .onFailure().recoverWithNull() + .call(licenceModel -> { + if (licenceModel == null) + return Uni.createFrom().nullItem(); + + ls.logUpdateAnonymous("Annulation automatique du paiement de la licence", + licenceModel); + licenceModel.setPay(false); + if (licenceModel.isValidate()) + ls.logUpdateAnonymous( + "Annulation automatique de la validation de la licence", + licenceModel); + licenceModel.setValidate(false); + return Panache.withTransaction(() -> licenceRepository.persist(licenceModel)); + })); + } + } + uni = uni.call(__ -> ls.append()); + + checkoutModel.setPaymentStatus(newStatus); + return uni.chain(__ -> Panache.withTransaction(() -> repository.persist(checkoutModel))); + }) + .onFailure().invoke(Throwable::printStackTrace) + .map(__ -> Response.ok().build()); + } + + @Scheduled(cron = "0 0 * * * ?") + Uni everyHours() { + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.HOUR, -1); + Date dateLimit = calendar.getTime(); + + return repository.delete("creationDate < ?1 AND (checkoutId IS NULL OR paymentStatus = ?2)", dateLimit, + CheckoutModel.PaymentStatus.UNKNOW) + .map(__ -> null); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java index 5e2274a..82ff6d9 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java @@ -31,16 +31,10 @@ import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.hibernate.reactive.mutiny.Mutiny; -import org.jboss.logging.Logger; -import java.io.*; import java.util.*; -import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import static fr.titionfire.ffsaf.net2.Client_Thread.MAPPER; @@ -48,7 +42,6 @@ import static fr.titionfire.ffsaf.net2.Client_Thread.MAPPER; @WithSession @ApplicationScoped public class ClubService { - private static final Logger LOGGER = Logger.getLogger(ClubService.class); @Inject ClubRepository repository; @@ -68,12 +61,6 @@ public class ClubService { @Inject LoggerService ls; - @ConfigProperty(name = "pdf-maker.jar-path") - String pdfMakerJarPath; - - @ConfigProperty(name = "pdf-maker.sign-file") - String sign_file; - public SimpleClubModel findByIdOptionalClub(long id) throws Throwable { return VertxContextSupport.subscribeAndAwait( () -> Panache.withTransaction(() -> repository.findById(id).map(SimpleClubModel::fromModel))); @@ -346,120 +333,4 @@ public class ClubService { return data; }).collect().asList(); } - - public Uni getAffiliationPdf(String subject) { - return getAffiliationPdf( - combRepository.find("userId = ?1", subject).firstResult() - .invoke(Unchecked.consumer(m -> { - if (m == null || m.getClub() == null) - throw new DNotFoundException("Club non trouvé"); - })) - .map(MembreModel::getClub) - .call(m -> Mutiny.fetch(m.getAffiliations()))); - } - - public Uni getAffiliationPdf(long id) { - return getAffiliationPdf( - repository.findById(id) - .invoke(Unchecked.consumer(m -> { - if (m == null) - throw new DNotFoundException("Club non trouvé"); - })) - .call(m -> Mutiny.fetch(m.getAffiliations()))); - } - - - private Uni getAffiliationPdf(Uni uniBase) { - return uniBase - .map(Unchecked.function(m -> { - if (m.getAffiliations().stream() - .noneMatch(licenceModel -> licenceModel.getSaison() == Utils.getSaison())) - throw new DNotFoundException("Pas d'affiliation pour la saison en cours"); - - try { - byte[] buff = make_pdf(m); - if (buff == null) - throw new IOException("Error making pdf"); - - String mimeType = "application/pdf"; - - Response.ResponseBuilder resp = Response.ok(buff); - resp.type(MediaType.APPLICATION_OCTET_STREAM); - resp.header(HttpHeaders.CONTENT_LENGTH, buff.length); - resp.header(HttpHeaders.CONTENT_TYPE, mimeType); - resp.header(HttpHeaders.CONTENT_DISPOSITION, - "inline; " + "filename=\"Attestation d'affiliation " + Utils.getSaison() + "-" + - (Utils.getSaison() + 1) + " de " + m.getName() + ".pdf\""); - return resp.build(); - } catch (Exception e) { - throw new IOException(e); - } - })); - } - - private byte[] make_pdf(ClubModel m) throws IOException, InterruptedException { - List cmd = new ArrayList<>(); - cmd.add("java"); - cmd.add("-jar"); - cmd.add(pdfMakerJarPath); - - UUID uuid = UUID.randomUUID(); - - cmd.add("/tmp/" + uuid + ".pdf"); - cmd.add("club"); - cmd.add(m.getName()); - cmd.add(Utils.getSaison() + ""); - cmd.add(m.getNo_affiliation() + ""); - cmd.add(new File(sign_file).getAbsolutePath()); - - return getPdf(cmd, uuid, LOGGER); - } - - static byte[] getPdf(List cmd, UUID uuid, Logger logger) throws IOException, InterruptedException { - ProcessBuilder processBuilder = new ProcessBuilder(cmd); - processBuilder.redirectErrorStream(true); - Process process = processBuilder.start(); - - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - - StringBuilder builder = new StringBuilder(); - Thread t = new Thread(() -> { - try { - String line; - while ((line = reader.readLine()) != null) - builder.append(line).append("\n"); - } catch (Exception ignored) { - } - }); - t.start(); - - int code = -1; - if (!process.waitFor(30, TimeUnit.SECONDS)) { - process.destroy(); - builder.append("Timeout..."); - } else { - code = process.exitValue(); - } - - if (t.isAlive()) - t.interrupt(); - - logger.debug("PDF maker: " + builder); - - if (code != 0) { - throw new IOException("Error code: " + code); - } else { - File file = new File("/tmp/" + uuid + ".pdf"); - try (FileInputStream fis = new FileInputStream(file)) { - byte[] buff = fis.readAllBytes(); - //noinspection ResultOfMethodCallIgnored - file.delete(); - return buff; - } catch (IOException e) { - //noinspection ResultOfMethodCallIgnored - file.delete(); - } - return null; - } - } } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/HelloAssoTokenService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/HelloAssoTokenService.java new file mode 100644 index 0000000..253a071 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/HelloAssoTokenService.java @@ -0,0 +1,68 @@ +package fr.titionfire.ffsaf.domain.service; + +import fr.titionfire.ffsaf.rest.client.HelloAssoAuthClient; +import fr.titionfire.ffsaf.rest.client.dto.TokenResponse; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.logging.Logger; + +@ApplicationScoped +public class HelloAssoTokenService { + private static final Logger LOG = Logger.getLogger(HelloAssoTokenService.class); + + @Inject + @RestClient + HelloAssoAuthClient authClient; + + @ConfigProperty(name = "helloasso.client-id") + String clientId; + + @ConfigProperty(name = "helloasso.client-secret") + String clientSecret; + + private TokenResponse currentToken; // Stockage en mémoire (pour un seul pod) + + // Récupère un token valide (en le rafraîchissant si nécessaire) + public Uni getValidAccessToken() { + if (currentToken == null || currentToken.isExpired()) { + return fetchNewToken(clientId, clientSecret); + } + return Uni.createFrom().item(currentToken.accessToken); + } + + // Récupère un nouveau token (via client_credentials ou refresh_token) + private Uni fetchNewToken(String clientId, String clientSecret) { + if (currentToken != null && currentToken.refreshToken != null) { + // On utilise le refresh_token si disponible + return authClient.refreshToken("refresh_token", clientId, currentToken.refreshToken) + .onItem().invoke(token -> { + LOG.info("Token rafraîchi avec succès"); + currentToken = token; + }) + .onFailure().recoverWithItem(e -> { + LOG.warn("Échec du rafraîchissement, utilisation des credentials", e); + return null; // Force l'utilisation des credentials + }) + .flatMap(token -> token != null ? + Uni.createFrom().item(token.accessToken) : + getTokenWithCredentials(clientId, clientSecret) + ); + } else { + return getTokenWithCredentials(clientId, clientSecret); + } + } + + // Récupère un token avec client_id/client_secret + private Uni getTokenWithCredentials(String clientId, String clientSecret) { + return authClient.getToken("client_credentials", clientId, clientSecret) + .onItem().invoke(token -> { + LOG.info("Nouveau token obtenu"); + currentToken = token; + }) + .onFailure().invoke(e -> LOG.error("Erreur lors de l'obtention du token", e)) + .map(token -> token.accessToken); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java index f6af470..6e22109 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java @@ -260,7 +260,7 @@ public class KeycloakService { """ Bonjour, - Suite à votre première inscription à la Fédération Française de Soft Armored Fighting (FFSAF), votre compte pour accéder à l'intranet a été créé. + Suite à votre première inscription %sà la Fédération Française de Soft Armored Fighting (FFSAF), votre compte pour accéder à l'intranet a été créé. Ce compte vous permettra de consulter vos informations, de vous inscrire aux compétitions et de consulter vos résultats. Vous allez recevoir dans les prochaines minutes un email vous demandant de vérifier votre email et de définir un mot de passe. @@ -273,7 +273,9 @@ public class KeycloakService { Cordialement, L'équipe de la FFSAF - """, user.getUsername()) + """, + membreModel.getRole() == RoleAsso.MEMBRE ? "par votre club (" + membreModel.getClub() + .getName() + ") " : "", user.getUsername()) ).setFrom("FFSAF ").setReplyTo("support@ffsaf.fr") ) : Uni.createFrom().nullItem()) .call(user -> membreService.setUserId(membreModel.getId(), user.getId())) diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java index dd925e3..2269249 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java @@ -1,6 +1,7 @@ package fr.titionfire.ffsaf.domain.service; import fr.titionfire.ffsaf.data.model.LicenceModel; +import fr.titionfire.ffsaf.data.model.LogModel; import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.data.repository.CombRepository; import fr.titionfire.ffsaf.data.repository.LicenceRepository; @@ -20,6 +21,7 @@ import org.hibernate.reactive.mutiny.Mutiny; import java.util.List; import java.util.function.Consumer; +import java.util.function.Function; @WithSession @ApplicationScoped @@ -34,6 +36,15 @@ public class LicenceService { @Inject SequenceRepository sequenceRepository; + @Inject + KeycloakService keycloakService; + + @Inject + LoggerService ls; + + @Inject + CheckoutService checkoutService; + public Uni> getLicence(long id, Consumer checkPerm) { return combRepository.findById(id).invoke(checkPerm) .chain(combRepository -> Mutiny.fetch(combRepository.getLicences())); @@ -48,6 +59,29 @@ public class LicenceService { .chain(membres -> repository.find("saison = ?1 AND membre IN ?2", Utils.getSaison(), membres).list()); } + public Uni valideLicences(List ids) { + Uni uni = Uni.createFrom().nullItem(); + + for (Long id : ids) { + uni = uni.chain(__ -> repository.find("membre.id = ?1 AND saison = ?2", id, Utils.getSaison()).firstResult() + .chain(model -> { + if (!model.isValidate()) + ls.logUpdate("validation de la licence", model); + return validateLicences(model); + })) + .map(__ -> "OK"); + } + return uni.call(__ -> ls.append()); + } + + protected Uni validateLicences(LicenceModel model) { + model.setValidate(true); + return Panache.withTransaction(() -> repository.persist(model) + .call(m -> Mutiny.fetch(m.getMembre()) + .call(genLicenceNumberAndAccountIfNeed()) + )); + } + public Uni setLicence(long id, LicenceForm form) { if (form.getId() == -1) { return combRepository.findById(id).chain(membreModel -> { @@ -57,33 +91,69 @@ public class LicenceService { model.setSaison(form.getSaison()); model.setCertificate(form.getCertificate()); model.setValidate(form.isValidate()); + model.setPay(form.isPay()); return Panache.withTransaction(() -> repository.persist(model) - .call(m -> (m.isValidate() && membreModel.getLicence() <= 0) ? - sequenceRepository.getNextValueInTransaction(SequenceType.Licence) - .invoke(i -> membreModel.setLicence(Math.toIntExact(i))) - .chain(() -> combRepository.persist(membreModel)) - : Uni.createFrom().nullItem() - )); + .call(m -> m.isValidate() ? Uni.createFrom().item(membreModel) + .call(genLicenceNumberAndAccountIfNeed()) + : Uni.createFrom().nullItem() + )) + .call(licenceModel -> ls.logA(LogModel.ActionType.ADD, membreModel.getObjectName(), + licenceModel)); }); } else { return repository.findById(form.getId()).chain(model -> { + ls.logChange("Certificate", model.getCertificate(), form.getCertificate(), model); + ls.logChange("Validate", model.isValidate(), form.isValidate(), model); + ls.logChange("Pay", model.isPay(), form.isPay(), model); model.setCertificate(form.getCertificate()); model.setValidate(form.isValidate()); + model.setPay(form.isPay()); return Panache.withTransaction(() -> repository.persist(model) - .call(m -> m.isValidate() ? Mutiny.fetch(m.getMembre()) - .call(membreModel -> (membreModel.getLicence() <= 0) ? - sequenceRepository.getNextValueInTransaction(SequenceType.Licence) - .invoke(i -> membreModel.setLicence(Math.toIntExact(i))) - .chain(() -> combRepository.persist(membreModel)) - : Uni.createFrom().nullItem()) - : Uni.createFrom().nullItem() - )); + .call(m -> m.isValidate() ? Mutiny.fetch(m.getMembre()) + .call(genLicenceNumberAndAccountIfNeed()) + : Uni.createFrom().nullItem() + )) + .call(__ -> ls.append()); }); } } + private Function> genLicenceNumberAndAccountIfNeed() { + return membreModel -> ((membreModel.getLicence() <= 0) ? + sequenceRepository.getNextValueInTransaction(SequenceType.Licence) + .invoke(i -> membreModel.setLicence(Math.toIntExact(i))) + .chain(() -> combRepository.persist(membreModel)) + : Uni.createFrom().nullItem()) + .call(__ -> (membreModel.getUserId() == null) ? + keycloakService.initCompte(membreModel.getId()) + : Uni.createFrom().nullItem()); + } + + public Uni payLicences(List ids, Consumer checkPerm, SecurityCtx securityCtx) { + return repository.list("membre.id IN ?1 AND saison = ?2 AND pay = FALSE", ids, Utils.getSaison()) + .invoke(Unchecked.consumer(models -> { + if (models.size() != ids.size()) + throw new DBadRequestException("Erreur lors de la sélection des membres"); + })) + .call(models -> { + Uni uni = Uni.createFrom().nullItem(); + for (LicenceModel model : models) + uni = uni.chain(__ -> Mutiny.fetch(model.getMembre()).invoke(checkPerm)); + return uni; + }) + .chain(models -> checkoutService.create(models.stream().map(LicenceModel::getId).toList(), + securityCtx)); + } + public Uni deleteLicence(long id) { - return Panache.withTransaction(() -> repository.deleteById(id)); + return repository.findById(id) + .call(__ -> checkoutService.canDeleteLicence(id) + .invoke(Unchecked.consumer(b -> { + if (!b) throw new DBadRequestException( + "Impossible de supprimer une licence pour laquelle un paiement est en cours"); + }))) + .call(model -> ls.logADelete(model)) + .chain(model -> repository.delete(model)); } public Uni askLicence(long id, LicenceForm form, Consumer checkPerm) { @@ -101,11 +171,15 @@ public class LicenceService { model.setCertificate(form.getCertificate()); model.setValidate(false); return Panache.withTransaction(() -> repository.persist(model)); - })); + })) + .call(licenceModel -> ls.logA(LogModel.ActionType.ADD, membreModel.getObjectName(), + licenceModel)); } else { return repository.findById(form.getId()).chain(model -> { + ls.logChange("Certificate", model.getCertificate(), form.getCertificate(), model); model.setCertificate(form.getCertificate()); - return Panache.withTransaction(() -> repository.persist(model)); + return Panache.withTransaction(() -> repository.persist(model)) + .call(__ -> ls.append()); }); } }); @@ -114,10 +188,18 @@ public class LicenceService { public Uni deleteAskLicence(long id, Consumer checkPerm) { return repository.findById(id) .call(licenceModel -> Mutiny.fetch(licenceModel.getMembre()).invoke(checkPerm)) + .call(__ -> checkoutService.canDeleteLicence(id) + .invoke(Unchecked.consumer(b -> { + if (!b) throw new DBadRequestException( + "Impossible de supprimer une licence pour laquelle un paiement est en cours"); + }))) .invoke(Unchecked.consumer(licenceModel -> { if (licenceModel.isValidate()) throw new DBadRequestException("Impossible de supprimer une licence déjà validée"); + if (licenceModel.isPay()) + throw new DBadRequestException("Impossible de supprimer une licence déjà payée"); })) + .call(model -> ls.logADelete(model)) .chain(__ -> Panache.withTransaction(() -> repository.deleteById(id))); } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/LoggerService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LoggerService.java index 89a8b06..025c88b 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LoggerService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LoggerService.java @@ -71,10 +71,18 @@ public class LoggerService { message)); } + public void logAnonymous(ActionType action, ObjectType object, String message, String target_name, Long target_id) { + buffer.add(new LogModel(null, null, new Date(), action, object, target_id, target_name, message)); + } + public void log(ActionType action, String message, LoggableModel model) { log(action, model.getObjectType(), message, model.getObjectName(), model.getId()); } + public void logAnonymous(ActionType action, String message, LoggableModel model) { + logAnonymous(action, model.getObjectType(), message, model.getObjectName(), model.getId()); + } + public void logAdd(LoggableModel model) { log(ActionType.ADD, "", model); } @@ -83,6 +91,10 @@ public class LoggerService { log(ActionType.UPDATE, message, model); } + public void logUpdateAnonymous(String message, LoggableModel model) { + logAnonymous(ActionType.UPDATE, message, model); + } + public void logChange(String champ, Object o1, Object o2, LoggableModel model) { if (Objects.equals(o1, o2)) return; diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java index 6e6e58e..cd20f5f 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -13,7 +13,6 @@ import fr.titionfire.ffsaf.rest.data.SimpleMembre; import fr.titionfire.ffsaf.rest.data.SimpleMembreInOutData; import fr.titionfire.ffsaf.rest.exception.DBadRequestException; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; -import fr.titionfire.ffsaf.rest.exception.DNotFoundException; import fr.titionfire.ffsaf.rest.from.FullMemberForm; import fr.titionfire.ffsaf.utils.*; import io.quarkus.hibernate.reactive.panache.Panache; @@ -27,22 +26,13 @@ import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.hibernate.reactive.mutiny.Mutiny; import org.jboss.logging.Logger; -import java.io.File; -import java.io.FilenameFilter; -import java.io.IOException; -import java.text.SimpleDateFormat; import java.util.*; import java.util.concurrent.atomic.AtomicReference; -import static fr.titionfire.ffsaf.domain.service.ClubService.getPdf; - @WithSession @ApplicationScoped @@ -67,8 +57,6 @@ public class MembreService { @ConfigProperty(name = "upload_dir") String media; - @ConfigProperty(name = "pdf-maker.jar-path") - String pdfMakerJarPath; @Inject RegisterRepository registerRepository; @@ -90,46 +78,93 @@ public class MembreService { final static String FIND_NAME_REQUEST = "unaccent(fname) ILIKE unaccent(?1) OR unaccent(lname) ILIKE unaccent(?1) " + "OR unaccent(fname || ' ' || lname) ILIKE unaccent(?1) OR unaccent(lname || ' ' || fname) ILIKE unaccent(?1)"; - public Uni> searchAdmin(int limit, int page, String search, String club) { - if (search == null) - search = ""; - search = "%" + search.replaceAll(" ", "% %") + "%"; - - PanacheQuery query; - - if (club == null || club.isBlank()) { - query = repository.find(FIND_NAME_REQUEST, Sort.ascending("fname", "lname"), search) - .page(Page.ofSize(limit)); - } else { - if (club.equals("null")) { - query = repository.find( - "club IS NULL AND (" + FIND_NAME_REQUEST + ")", - Sort.ascending("fname", "lname"), search).page(Page.ofSize(limit)); - } else { - query = repository.find( - "LOWER(club.name) LIKE LOWER(?2) AND (" + FIND_NAME_REQUEST + ")", - Sort.ascending("fname", "lname"), search, club + "%").page(Page.ofSize(limit)); - } - } - return getPageResult(query, limit, page); + private Uni> getLicenceListe(int licenceRequest, int payState) { + Uni> baseUni; + String queryStr = "saison = ?1"; + if (payState == 0) + queryStr += " AND pay = FALSE"; + if (payState == 1) + queryStr += " AND pay = TRUE"; + if (licenceRequest == 0 || licenceRequest == 1) + baseUni = licenceRepository.list(queryStr, Utils.getSaison()); + else if (licenceRequest == 2) + baseUni = licenceRepository.list(queryStr + " AND validate = FALSE", Utils.getSaison()); + else if (licenceRequest == 5) + baseUni = licenceRepository.list(queryStr + " AND validate = FALSE AND LENGTH(certificate) >= 3", + Utils.getSaison()); + else if (licenceRequest == 6) + baseUni = licenceRepository.list(queryStr + " AND validate = FALSE AND LENGTH(certificate) <= 2", + Utils.getSaison()); + else if (licenceRequest == 3) + baseUni = licenceRepository.list(queryStr + " AND validate = TRUE", Utils.getSaison()); + else + baseUni = Uni.createFrom().item(new ArrayList<>()); + return baseUni; } - public Uni> search(int limit, int page, String search, String subject) { + public Uni> searchAdmin(int limit, int page, String search, String club, + int licenceRequest, int payState) { if (search == null) search = ""; search = "%" + search.replaceAll(" ", "% %") + "%"; String finalSearch = search; - return repository.find("userId = ?1", subject).firstResult() - .chain(membreModel -> { - PanacheQuery query = repository.find( - "club = ?2 AND (" + FIND_NAME_REQUEST + ")", - Sort.ascending("fname", "lname"), finalSearch, membreModel.getClub()) - .page(Page.ofSize(limit)); + Uni> baseUni = getLicenceListe(licenceRequest, payState); + + return baseUni + .map(l -> l.stream().map(l2 -> l2.getMembre().getId()).toList()) + .chain(ids -> { + PanacheQuery query; + + String idf = ((licenceRequest == 0 || licenceRequest == 4) ? "NOT IN" : "IN"); + + if (club == null || club.isBlank()) { + query = repository.find( + "id " + idf + " ?2 AND (" + FIND_NAME_REQUEST + ")", + Sort.ascending("fname", "lname"), finalSearch, ids) + .page(Page.ofSize(limit)); + } else { + if (club.equals("null")) { + query = repository.find( + "id " + idf + " ?2 AND club IS NULL AND (" + FIND_NAME_REQUEST + ")", + Sort.ascending("fname", "lname"), finalSearch, ids).page(Page.ofSize(limit)); + } else { + query = repository.find( + "id " + idf + " ?3 AND LOWER(club.name) LIKE LOWER(?2) AND (" + FIND_NAME_REQUEST + ")", + Sort.ascending("fname", "lname"), finalSearch, club + "%", ids) + .page(Page.ofSize(limit)); + } + } return getPageResult(query, limit, page); }); } + public Uni> search(int limit, int page, String search, int licenceRequest, int payState, + String subject) { + if (search == null) + search = ""; + search = "%" + search.replaceAll(" ", "% %") + "%"; + + String finalSearch = search; + + Uni> baseUni = getLicenceListe(licenceRequest, payState); + + return baseUni + .map(l -> l.stream().map(l2 -> l2.getMembre().getId()).toList()) + .chain(ids -> { + String idf = ((licenceRequest == 0 || licenceRequest == 4) ? "NOT IN" : "IN"); + + return repository.find("userId = ?1", subject).firstResult() + .chain(membreModel -> { + PanacheQuery query = repository.find( + "id " + idf + " ?3 AND club = ?2 AND (" + FIND_NAME_REQUEST + ")", + Sort.ascending("fname", "lname"), finalSearch, membreModel.getClub(), ids) + .page(Page.ofSize(limit)); + return getPageResult(query, limit, page); + }); + }); + } + private Uni> getPageResult(PanacheQuery query, int limit, int page) { return Uni.createFrom().item(new PageResult()) .invoke(result -> result.setPage(page)) @@ -167,9 +202,10 @@ public class MembreService { clubModel.set(membreModel.getClub()); if (data2.stream().noneMatch(d -> d.getLicence() != null)) return Uni.createFrom().item(new ArrayList()); - return repository.list("licence IN ?1 OR LOWER(lname || ' ' || fname) IN ?2", + return repository.list("licence IN ?1 OR LOWER(lname || ' ' || fname) IN ?2 OR email IN ?3", data2.stream().map(SimpleMembreInOutData::getLicence).filter(Objects::nonNull).toList(), - data2.stream().map(o -> (o.getNom() + " " + o.getPrenom()).toLowerCase()).toList()); + data2.stream().map(o -> (o.getNom() + " " + o.getPrenom()).toLowerCase()).toList(), + data2.stream().map(SimpleMembreInOutData::getEmail).filter(Objects::nonNull).toList()); }) .call(Unchecked.function(membres -> { for (MembreModel membreModel : membres) { @@ -181,7 +217,8 @@ public class MembreService { for (SimpleMembreInOutData dataIn : data2) { MembreModel model = membres.stream() .filter(m -> Objects.equals(m.getLicence(), dataIn.getLicence()) || m.getLname() - .equals(dataIn.getNom()) && m.getFname().equals(dataIn.getPrenom())).findFirst() + .equals(dataIn.getNom()) && m.getFname().equals(dataIn.getPrenom()) || + Objects.equals(m.getFname(), dataIn.getEmail())).findFirst() .orElseGet(() -> { MembreModel mm = new MembreModel(); mm.setClub(clubModel.get()); @@ -190,6 +227,18 @@ public class MembreService { return mm; }); + if (model.getEmail() != null) { + if (model.getLicence() != null && !model.getLicence().equals(dataIn.getLicence())) { + throw new DBadRequestException("Email déja utiliser"); + } + + if (StringSimilarity.similarity(model.getLname().toUpperCase(), + dataIn.getNom().toUpperCase()) > 3 || StringSimilarity.similarity( + model.getFname().toUpperCase(), dataIn.getPrenom().toUpperCase()) > 3) { + throw new DBadRequestException("Email déja utiliser"); + } + } + boolean add = model.getId() == null; if ((!add && StringSimilarity.similarity(model.getLname().toUpperCase(), @@ -268,6 +317,11 @@ public class MembreService { public Uni update(long id, FullMemberForm membre) { return update(repository.findById(id) + .call(__ -> repository.count("email LIKE ?1 AND id != ?2", membre.getEmail(), id) + .invoke(Unchecked.consumer(c -> { + if (c > 0) + throw new DBadRequestException("Email déjà utiliser"); + }))) .chain(membreModel -> clubRepository.findById(membre.getClub()) .map(club -> new Pair<>(membreModel, club))) .onItem().transform(pair -> { @@ -285,9 +339,20 @@ public class MembreService { public Uni update(long id, FullMemberForm membre, SecurityCtx securityCtx) { return update(repository.findById(id) + .call(__ -> repository.count("email LIKE ?1 AND id != ?2", membre.getEmail(), id) + .invoke(Unchecked.consumer(c -> { + if (c > 0) + throw new DBadRequestException("Email déjà utiliser"); + }))) .invoke(Unchecked.consumer(membreModel -> { if (!securityCtx.isInClubGroup(membreModel.getClub().getId())) throw new DForbiddenException(); + if (StringSimilarity.similarity(membreModel.getLname().toUpperCase(), + membre.getLname().toUpperCase()) > 3 || StringSimilarity.similarity( + membreModel.getFname().toUpperCase(), membre.getFname().toUpperCase()) > 3) { + throw new DBadRequestException( + "Pour enregistrer un nouveau membre, veuillez utilisez le bouton prévue a cette effet."); + } })) .invoke(Unchecked.consumer(membreModel -> { RoleAsso source = RoleAsso.MEMBRE; @@ -365,6 +430,10 @@ public class MembreService { public Uni add(FullMemberForm input) { return clubRepository.findById(input.getClub()) + .call(__ -> repository.count("email LIKE ?1", input.getEmail()) + .invoke(Unchecked.consumer(c -> { + if (c > 0) throw new DBadRequestException("Email déjà utiliser"); + }))) .chain(clubModel -> { MembreModel model = getMembreModel(input, clubModel); return Panache.withTransaction(() -> repository.persist(model)); @@ -377,6 +446,18 @@ public class MembreService { public Uni add(FullMemberForm input, String subject) { return repository.find("userId = ?1", subject).firstResult() + .call(__ -> repository.count("email LIKE ?1", input.getEmail()) + .invoke(Unchecked.consumer(c -> { + if (c > 0) throw new DBadRequestException("Email déjà utiliser"); + }))) + .call(membreModel -> + repository.count( + "unaccent(lname) ILIKE unaccent(?2) AND unaccent(fname) ILIKE unaccent(?2) AND club = ?3", + input.getLname(), input.getFname(), membreModel.getClub()) + .invoke(Unchecked.consumer(c -> { + if (c > 0) + throw new DBadRequestException("Membre déjà existent"); + }))) .chain(membreModel -> { MembreModel model = getMembreModel(input, membreModel.getClub()); model.setRole(RoleAsso.MEMBRE); @@ -477,70 +558,4 @@ public class MembreService { .map(__ -> null); } - public Uni getLicencePdf(String subject) { - return getLicencePdf(repository.find("userId = ?1", subject).firstResult() - .call(m -> Mutiny.fetch(m.getLicences()))); - } - - public Uni getLicencePdf(Uni uniBase) { - return uniBase - .map(Unchecked.function(m -> { - LicenceModel licence = m.getLicences().stream() - .filter(licenceModel -> licenceModel.getSaison() == Utils.getSaison() && licenceModel.isValidate()) - .findFirst() - .orElseThrow(() -> new DNotFoundException("Pas de licence pour la saison en cours")); - - try { - byte[] buff = make_pdf(m, licence); - if (buff == null) - throw new IOException("Error making pdf"); - - String mimeType = "application/pdf"; - - Response.ResponseBuilder resp = Response.ok(buff); - resp.type(MediaType.APPLICATION_OCTET_STREAM); - resp.header(HttpHeaders.CONTENT_LENGTH, buff.length); - resp.header(HttpHeaders.CONTENT_TYPE, mimeType); - resp.header(HttpHeaders.CONTENT_DISPOSITION, - "inline; " + "filename=\"Attestation d'adhésion " + Utils.getSaison() + "-" + - (Utils.getSaison() + 1) + " de " + m.getLname() + " " + m.getFname() + ".pdf\""); - return resp.build(); - } catch (Exception e) { - throw new IOException(e); - } - })); - } - - private byte[] make_pdf(MembreModel m, LicenceModel licence) throws IOException, InterruptedException { - List cmd = new ArrayList<>(); - cmd.add("java"); - cmd.add("-jar"); - cmd.add(pdfMakerJarPath); - - UUID uuid = UUID.randomUUID(); - - cmd.add("/tmp/" + uuid + ".pdf"); - cmd.add("membre"); - cmd.add(m.getFname()); - cmd.add(m.getLname()); - cmd.add(m.getGenre().str); - cmd.add(m.getCategorie().getName()); - cmd.add(licence.getCertificate() == null ? "" : licence.getCertificate()); - cmd.add(Utils.getSaison() + ""); - cmd.add(m.getLicence() + ""); - cmd.add(m.getClub().getName()); - cmd.add(m.getClub().getNo_affiliation() + ""); - cmd.add(m.getBirth_date() == null ? "--" : new SimpleDateFormat("dd/MM/yyyy").format(m.getBirth_date())); - - FilenameFilter filter = (directory, filename) -> filename.startsWith(m.getId() + "."); - File[] files = new File(media, "ppMembre").listFiles(filter); - if (files != null && files.length > 0) { - File file = files[0]; - cmd.add(file.getAbsolutePath()); - } else { - cmd.add("/dev/null"); - } - - return getPdf(cmd, uuid, LOGGER); - } } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/PDFService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/PDFService.java new file mode 100644 index 0000000..e95bb8a --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/PDFService.java @@ -0,0 +1,232 @@ +package fr.titionfire.ffsaf.domain.service; + +import fr.titionfire.ffsaf.data.model.ClubModel; +import fr.titionfire.ffsaf.data.model.LicenceModel; +import fr.titionfire.ffsaf.data.model.MembreModel; +import fr.titionfire.ffsaf.data.repository.ClubRepository; +import fr.titionfire.ffsaf.data.repository.CombRepository; +import fr.titionfire.ffsaf.rest.exception.DNotFoundException; +import fr.titionfire.ffsaf.utils.Utils; +import io.quarkus.hibernate.reactive.panache.common.WithSession; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.hibernate.reactive.mutiny.Mutiny; +import org.jboss.logging.Logger; + +import java.io.*; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@WithSession +@ApplicationScoped +public class PDFService { + private static final Logger LOGGER = Logger.getLogger(PDFService.class); + + @Inject + CombRepository combRepository; + + @Inject + ClubRepository clubRepository; + + @ConfigProperty(name = "upload_dir") + String media; + + @ConfigProperty(name = "pdf-maker.jar-path") + String pdfMakerJarPath; + + @ConfigProperty(name = "pdf-maker.sign-file") + String sign_file; + + + public Uni getLicencePdf(String subject) { + return getLicencePdf(combRepository.find("userId = ?1", subject).firstResult() + .call(m -> Mutiny.fetch(m.getLicences()))); + } + + public Uni getLicencePdf(Uni uniBase) { + return uniBase + .map(Unchecked.function(m -> { + LicenceModel licence = m.getLicences().stream() + .filter(licenceModel -> licenceModel.getSaison() == Utils.getSaison() && licenceModel.isValidate()) + .findFirst() + .orElseThrow(() -> new DNotFoundException("Pas de licence pour la saison en cours")); + + try { + byte[] buff = make_pdf(m, licence); + if (buff == null) + throw new IOException("Error making pdf"); + + String mimeType = "application/pdf"; + + Response.ResponseBuilder resp = Response.ok(buff); + resp.type(MediaType.APPLICATION_OCTET_STREAM); + resp.header(HttpHeaders.CONTENT_LENGTH, buff.length); + resp.header(HttpHeaders.CONTENT_TYPE, mimeType); + resp.header(HttpHeaders.CONTENT_DISPOSITION, + "inline; " + "filename=\"Attestation d'adhésion " + Utils.getSaison() + "-" + + (Utils.getSaison() + 1) + " de " + m.getLname() + " " + m.getFname() + ".pdf\""); + return resp.build(); + } catch (Exception e) { + throw new IOException(e); + } + })); + } + + private byte[] make_pdf(MembreModel m, LicenceModel licence) throws IOException, InterruptedException { + List cmd = new ArrayList<>(); + cmd.add("java"); + cmd.add("-jar"); + cmd.add(pdfMakerJarPath); + + UUID uuid = UUID.randomUUID(); + + cmd.add("/tmp/" + uuid + ".pdf"); + cmd.add("membre"); + cmd.add(m.getFname()); + cmd.add(m.getLname()); + cmd.add(m.getGenre().str); + cmd.add(m.getCategorie().getName()); + cmd.add(licence.getCertificate() == null ? "" : licence.getCertificate()); + cmd.add(Utils.getSaison() + ""); + cmd.add(m.getLicence() + ""); + cmd.add(m.getClub().getName()); + cmd.add(m.getClub().getNo_affiliation() + ""); + cmd.add(m.getBirth_date() == null ? "--" : new SimpleDateFormat("dd/MM/yyyy").format(m.getBirth_date())); + + FilenameFilter filter = (directory, filename) -> filename.startsWith(m.getId() + "."); + File[] files = new File(media, "ppMembre").listFiles(filter); + if (files != null && files.length > 0) { + File file = files[0]; + cmd.add(file.getAbsolutePath()); + } else { + cmd.add("/dev/null"); + } + + return getPdf(cmd, uuid); + } + + public Uni getAffiliationPdf(String subject) { + return getAffiliationPdf( + combRepository.find("userId = ?1", subject).firstResult() + .invoke(Unchecked.consumer(m -> { + if (m == null || m.getClub() == null) + throw new DNotFoundException("Club non trouvé"); + })) + .map(MembreModel::getClub) + .call(m -> Mutiny.fetch(m.getAffiliations()))); + } + + public Uni getAffiliationPdf(long id) { + return getAffiliationPdf( + clubRepository.findById(id) + .invoke(Unchecked.consumer(m -> { + if (m == null) + throw new DNotFoundException("Club non trouvé"); + })) + .call(m -> Mutiny.fetch(m.getAffiliations()))); + } + + + private Uni getAffiliationPdf(Uni uniBase) { + return uniBase + .map(Unchecked.function(m -> { + if (m.getAffiliations().stream() + .noneMatch(licenceModel -> licenceModel.getSaison() == Utils.getSaison())) + throw new DNotFoundException("Pas d'affiliation pour la saison en cours"); + + try { + byte[] buff = make_pdf(m); + if (buff == null) + throw new IOException("Error making pdf"); + + String mimeType = "application/pdf"; + + Response.ResponseBuilder resp = Response.ok(buff); + resp.type(MediaType.APPLICATION_OCTET_STREAM); + resp.header(HttpHeaders.CONTENT_LENGTH, buff.length); + resp.header(HttpHeaders.CONTENT_TYPE, mimeType); + resp.header(HttpHeaders.CONTENT_DISPOSITION, + "inline; " + "filename=\"Attestation d'affiliation " + Utils.getSaison() + "-" + + (Utils.getSaison() + 1) + " de " + m.getName() + ".pdf\""); + return resp.build(); + } catch (Exception e) { + throw new IOException(e); + } + })); + } + + private byte[] make_pdf(ClubModel m) throws IOException, InterruptedException { + List cmd = new ArrayList<>(); + cmd.add("java"); + cmd.add("-jar"); + cmd.add(pdfMakerJarPath); + + UUID uuid = UUID.randomUUID(); + + cmd.add("/tmp/" + uuid + ".pdf"); + cmd.add("club"); + cmd.add(m.getName()); + cmd.add(Utils.getSaison() + ""); + cmd.add(m.getNo_affiliation() + ""); + cmd.add(new File(sign_file).getAbsolutePath()); + + return getPdf(cmd, uuid); + } + + static byte[] getPdf(List cmd, UUID uuid) throws IOException, InterruptedException { + ProcessBuilder processBuilder = new ProcessBuilder(cmd); + processBuilder.redirectErrorStream(true); + Process process = processBuilder.start(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + + StringBuilder builder = new StringBuilder(); + Thread t = new Thread(() -> { + try { + String line; + while ((line = reader.readLine()) != null) + builder.append(line).append("\n"); + } catch (Exception ignored) { + } + }); + t.start(); + + int code = -1; + if (!process.waitFor(30, TimeUnit.SECONDS)) { + process.destroy(); + builder.append("Timeout..."); + } else { + code = process.exitValue(); + } + + if (t.isAlive()) + t.interrupt(); + + PDFService.LOGGER.debug("PDF maker: " + builder); + + if (code != 0) { + throw new IOException("Error code: " + code); + } else { + File file = new File("/tmp/" + uuid + ".pdf"); + try (FileInputStream fis = new FileInputStream(file)) { + byte[] buff = fis.readAllBytes(); + //noinspection ResultOfMethodCallIgnored + file.delete(); + return buff; + } catch (IOException e) { + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + return null; + } + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/WebhookService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/WebhookService.java new file mode 100644 index 0000000..9e011bc --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/WebhookService.java @@ -0,0 +1,28 @@ +package fr.titionfire.ffsaf.domain.service; + +import fr.titionfire.ffsaf.rest.client.dto.HelloassoNotification; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@ApplicationScoped +public class WebhookService { + + @Inject + CheckoutService checkoutService; + + @ConfigProperty(name = "helloasso.organizationSlug") + String organizationSlug; + + public Uni helloAssoNotification(HelloassoNotification notification) { + if (notification.getEventType().equals("Payment")){ + if (notification.getData().getOrder().getOrganizationSlug().equalsIgnoreCase(organizationSlug)){ + return checkoutService.paymentStatusChange(notification.getData().getState(), notification.getMetadata()); + } + } + + return Uni.createFrom().item(Response.ok().build()); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index a9e7657..1b3a2c2 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -2,6 +2,7 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.domain.service.ClubService; +import fr.titionfire.ffsaf.domain.service.PDFService; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; import fr.titionfire.ffsaf.rest.data.*; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; @@ -39,6 +40,9 @@ public class ClubEndpoints { @Inject ClubService clubService; + @Inject + PDFService pdfService; + @Inject SecurityCtx securityCtx; @@ -219,7 +223,7 @@ public class ClubEndpoints { @APIResponse(responseCode = "500", description = "Erreur interne du serveur") }) public Uni getAffiliation(@Parameter(description = "Identifiant de club") @PathParam("id") long id) { - return clubService.getAffiliationPdf(id); + return pdfService.getAffiliationPdf(id); } @GET @@ -268,7 +272,7 @@ public class ClubEndpoints { @APIResponse(responseCode = "500", description = "Erreur interne du serveur") }) public Uni getMeAffiliation() { - return clubService.getAffiliationPdf(securityCtx.getSubject()); + return pdfService.getAffiliationPdf(securityCtx.getSubject()); } @GET diff --git a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java index 1a045d8..9264b2d 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java @@ -13,6 +13,7 @@ import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; @@ -80,6 +81,21 @@ public class LicenceEndpoints { .map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList()); } + @POST + @Path("pay") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Paiement des licence", description = "Retourne le lien de paiement pour les licence des membre fournie") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Commande avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni payLicences(@Parameter(description = "Id des membres") List ids) { + return licenceService.payLicences(ids, checkPerm, securityCtx); + } + @POST @Path("{id}") @RolesAllowed("federation_admin") @@ -97,6 +113,21 @@ public class LicenceEndpoints { return licenceService.setLicence(id, form).map(SimpleLicence::fromModel); } + @POST + @Path("validate") + @RolesAllowed("federation_admin") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Validation licence", description = "Valide en masse les licence de l'année en cours (pour les administrateurs)") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Les licences ont été mise à jour avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni valideLicences(@Parameter(description = "Id des membres a valider") List ids) { + return licenceService.valideLicences(ids); + } + @DELETE @Path("{id}") @RolesAllowed("federation_admin") diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java index 82865fd..e0b04b0 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java @@ -57,12 +57,14 @@ public class MembreAdminEndpoints { @Parameter(description = "Nombre max de résulta (max 50)") @QueryParam("limit") Integer limit, @Parameter(description = "Page à consulter") @QueryParam("page") Integer page, @Parameter(description = "Text à rechercher") @QueryParam("search") String search, - @Parameter(description = "Club à filter") @QueryParam("club") String club) { + @Parameter(description = "Club à filter") @QueryParam("club") String club, + @Parameter(description = "Etat de la demande de licence: 0 -> sans demande, 1 -> avec demande ou validée, 2 -> toute les demande non validée, 3 -> validée, 4 -> tout, 5 -> demande complete, 6 -> demande incomplete") @QueryParam("licenceRequest") int licenceRequest, + @Parameter(description = "Etat du payment: 0 -> non payer, 1 -> payer, 2 -> tout") @QueryParam("payment") int payment) { if (limit == null) limit = 50; if (page == null || page < 1) page = 1; - return membreService.searchAdmin(limit, page - 1, search, club); + return membreService.searchAdmin(limit, page - 1, search, club, licenceRequest, payment); } @GET diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java index 2c6a3ce..545552c 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java @@ -49,12 +49,14 @@ public class MembreClubEndpoints { public Uni> getFindClub( @Parameter(description = "Nombre max de résulta (max 50)") @QueryParam("limit") Integer limit, @Parameter(description = "Page à consulter") @QueryParam("page") Integer page, - @Parameter(description = "Text à rechercher") @QueryParam("search") String search) { + @Parameter(description = "Text à rechercher") @QueryParam("search") String search, + @Parameter(description = "Etat de la demande de licence: 0 -> sans demande, 1 -> avec demande ou validée, 2 -> toute les demande non validée, 3 -> validée, 4 -> tout, 5 -> demande complete, 6 -> demande incomplete") @QueryParam("licenceRequest") int licenceRequest, + @Parameter(description = "Etat du payment: 0 -> non payer, 1 -> payer, 2 -> tout") @QueryParam("payment") int payment) { if (limit == null) limit = 50; if (page == null || page < 1) page = 1; - return membreService.search(limit, page - 1, search, securityCtx.getSubject()); + return membreService.search(limit, page - 1, search, licenceRequest, payment, securityCtx.getSubject()); } @GET diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java index 1227bd7..cc0794b 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java @@ -2,6 +2,7 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.domain.service.MembreService; +import fr.titionfire.ffsaf.domain.service.PDFService; import fr.titionfire.ffsaf.rest.data.MeData; import fr.titionfire.ffsaf.rest.data.SimpleMembre; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; @@ -34,6 +35,9 @@ public class MembreEndpoints { @Inject MembreService membreService; + @Inject + PDFService pdfService; + @ConfigProperty(name = "upload_dir") String media; @@ -106,7 +110,7 @@ public class MembreEndpoints { @APIResponse(responseCode = "500", description = "Erreur interne du serveur") }) public Uni getMeLicence() { - return membreService.getLicencePdf(securityCtx.getSubject()); + return pdfService.getLicencePdf(securityCtx.getSubject()); } @GET @@ -151,6 +155,6 @@ public class MembreEndpoints { @APIResponse(responseCode = "500", description = "Erreur interne du serveur") }) public Uni getLicencePDF(@PathParam("id") long id) { - return membreService.getLicencePdf(membreService.getByIdWithLicence(id).onItem().invoke(checkPerm)); + return pdfService.getLicencePdf(membreService.getByIdWithLicence(id).onItem().invoke(checkPerm)); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/WebhookEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/WebhookEndpoints.java new file mode 100644 index 0000000..431d3d6 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/WebhookEndpoints.java @@ -0,0 +1,53 @@ +package fr.titionfire.ffsaf.rest; + +import fr.titionfire.ffsaf.domain.service.WebhookService; +import fr.titionfire.ffsaf.rest.client.dto.HelloassoNotification; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +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.jboss.logging.Logger; + +@Path("api/webhook") +public class WebhookEndpoints { + private static final Logger LOGGER = Logger.getLogger(WebhookEndpoints.class); + + @Inject + WebhookService webhookService; + + @Inject + RoutingContext context; + + @ConfigProperty(name = "helloasso.webhook.ip-source") + String helloassoIp; + + @ConfigProperty(name = "quarkus.http.proxy.proxy-address-forwarding") + boolean proxyForwarding; + + @POST + @Path("ha") + @Operation(hidden = true) + @Consumes(MediaType.APPLICATION_JSON) + public Uni helloAsso(HelloassoNotification notification) { + String ip; + if (proxyForwarding) { + ip = context.request().getHeader("X-Forwarded-For"); + if (ip == null) + ip = context.request().authority().host(); + } else { + ip = context.request().authority().host(); + } + + if (!helloassoIp.equals(ip)) { + LOGGER.infof("helloAsso webhook reject : bas ip (%s)", ip); + return Uni.createFrom().item(Response.status(Response.Status.FORBIDDEN).build()); + } + return webhookService.helloAssoNotification(notification); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoAuthClient.java b/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoAuthClient.java new file mode 100644 index 0000000..820ba19 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoAuthClient.java @@ -0,0 +1,32 @@ +package fr.titionfire.ffsaf.rest.client; + +import fr.titionfire.ffsaf.rest.client.dto.TokenResponse; +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@Path("/") +@RegisterRestClient(configKey = "helloasso-auth") +public interface HelloAssoAuthClient { + + @POST + @Path("/token") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + Uni getToken( + @FormParam("grant_type") String grantType, + @FormParam("client_id") String clientId, + @FormParam("client_secret") String clientSecret + ); + + @POST + @Path("/token") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + Uni refreshToken( + @FormParam("grant_type") String grantType, + @FormParam("client_id") String clientId, + @FormParam("refresh_token") String refreshToken + ); +} \ No newline at end of file diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoHeadersFactory.java b/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoHeadersFactory.java new file mode 100644 index 0000000..91a080d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoHeadersFactory.java @@ -0,0 +1,24 @@ +package fr.titionfire.ffsaf.rest.client; + +import fr.titionfire.ffsaf.domain.service.HelloAssoTokenService; +import io.quarkus.rest.client.reactive.ReactiveClientHeadersFactory; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.MultivaluedMap; +import org.jboss.resteasy.reactive.common.util.MultivaluedTreeMap; + +@ApplicationScoped +public class HelloAssoHeadersFactory extends ReactiveClientHeadersFactory { + + @Inject + HelloAssoTokenService helloAssoTokenService; + + @Override + public Uni> getHeaders(MultivaluedMap incomingHeaders, + MultivaluedMap clientOutgoingHeaders) { + MultivaluedMap map = new MultivaluedTreeMap<>(); + return helloAssoTokenService.getValidAccessToken() + .invoke(token -> map.putSingle("Authorization", "Bearer " + token)).map(__ -> map); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoService.java b/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoService.java new file mode 100644 index 0000000..dc366fb --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoService.java @@ -0,0 +1,51 @@ +package fr.titionfire.ffsaf.rest.client; + +import fr.titionfire.ffsaf.rest.client.dto.CheckoutIntentsRequest; +import fr.titionfire.ffsaf.rest.client.dto.CheckoutIntentsResponse; +import io.quarkus.rest.client.reactive.ClientExceptionMapper; +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import java.io.ByteArrayInputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; + +@Path("/") +@RegisterRestClient(configKey = "helloasso-api") +@RegisterClientHeaders(HelloAssoHeadersFactory.class) +public interface HelloAssoService { + + @GET + @Path("/users/me/organizations") + @Produces("text/plain") + Uni test(); + + @POST + @Path("organizations/{organizationSlug}/checkout-intents") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + Uni checkout(@PathParam("organizationSlug") String organizationSlug, + CheckoutIntentsRequest data); + + @ClientExceptionMapper + static RuntimeException toException(Response response, Method method) { + if (!method.getDeclaringClass().getName().equals("fr.titionfire.ffsaf.rest.client.HelloAssoService")) + return null; + + if (method.getName().equals("checkout")) { + if (response.getStatus() == 400) { + if (response.getEntity() instanceof ByteArrayInputStream) { + ByteArrayInputStream error = response.readEntity(ByteArrayInputStream.class); + return new RuntimeException(new String(error.readAllBytes(), StandardCharsets.UTF_8)); + } + + return new RuntimeException("The remote service responded with HTTP 400"); + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/ApiError.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/ApiError.java new file mode 100644 index 0000000..eb40742 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/ApiError.java @@ -0,0 +1,14 @@ +package fr.titionfire.ffsaf.rest.client.dto; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class ApiError { + public String error; + public String error_description; + + @Override + public String toString() { + return error + ": " + error_description; + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsRequest.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsRequest.java new file mode 100644 index 0000000..8fa8c08 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsRequest.java @@ -0,0 +1,30 @@ +package fr.titionfire.ffsaf.rest.client.dto; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@RegisterForReflection +public class CheckoutIntentsRequest { + public int totalAmount; + public int initialAmount; + public String itemName; + public String backUrl; + public String errorUrl; + public String returnUrl; + public boolean containsDonation; + public Payer payer; + public CheckoutMetadata metadata; + + @Data + @AllArgsConstructor + @RegisterForReflection + public static class Payer { + public String firstName; + public String lastName; + public String email; + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsResponse.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsResponse.java new file mode 100644 index 0000000..433af7c --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsResponse.java @@ -0,0 +1,11 @@ +package fr.titionfire.ffsaf.rest.client.dto; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.Data; + +@Data +@RegisterForReflection +public class CheckoutIntentsResponse { + public int id; + public String redirectUrl; +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutMetadata.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutMetadata.java new file mode 100644 index 0000000..f1eca04 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutMetadata.java @@ -0,0 +1,12 @@ +package fr.titionfire.ffsaf.rest.client.dto; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +@RegisterForReflection +public class CheckoutMetadata { + public long checkoutDBId; +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/HelloassoNotification.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/HelloassoNotification.java new file mode 100644 index 0000000..550236d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/HelloassoNotification.java @@ -0,0 +1,16 @@ +package fr.titionfire.ffsaf.rest.client.dto; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@RegisterForReflection +public class HelloassoNotification { + private NotificationData data; + private String eventType; + private CheckoutMetadata metadata; +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/NotificationData.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/NotificationData.java new file mode 100644 index 0000000..8d79e6e --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/NotificationData.java @@ -0,0 +1,30 @@ +package fr.titionfire.ffsaf.rest.client.dto; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@RegisterForReflection +public class NotificationData { + private Order order; + private Integer id; + private String organizationSlug; + private String checkoutIntentId; + private String oldSlugOrganization; // Pour les changements de nom d'association + private String newSlugOrganization; + private String state; // Pour les formulaires + + + @Data + @NoArgsConstructor + @AllArgsConstructor + @RegisterForReflection + public static class Order { + private Integer id; + private String organizationSlug; + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/TokenResponse.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/TokenResponse.java new file mode 100644 index 0000000..3639533 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/TokenResponse.java @@ -0,0 +1,30 @@ +package fr.titionfire.ffsaf.rest.client.dto; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class TokenResponse { + @JsonProperty("access_token") + public String accessToken; + + @JsonProperty("refresh_token") + public String refreshToken; + + @JsonProperty("token_type") + public String tokenType; // Toujours "bearer" + + @JsonProperty("expires_in") + public long expiresIn; // Durée de validité en secondes (1800s = 30min) + + // Pour stocker l'heure d'obtention du token + private long timestamp; + + public TokenResponse() { + this.timestamp = System.currentTimeMillis() / 1000; // Timestamp en secondes + } + + // Vérifie si le token est expiré + public boolean isExpired() { + return (System.currentTimeMillis() / 1000) - timestamp >= expiresIn; + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java index fad08b3..66709be 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java @@ -22,6 +22,8 @@ public class SimpleLicence { String certificate; @Schema(description = "Validation de la licence", example = "true") boolean validate; + @Schema(description = "Licence payer", example = "true") + boolean pay; public static SimpleLicence fromModel(LicenceModel model) { if (model == null) @@ -33,6 +35,7 @@ public class SimpleLicence { .saison(model.getSaison()) .certificate(model.getCertificate()) .validate(model.isValidate()) + .pay(model.isPay()) .build(); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java index f6d8cc3..7b2af3c 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java @@ -21,10 +21,14 @@ public class LicenceForm { private int saison; @FormParam("certificate") - @Schema(description = "Nom du médecin sur certificat médical.", example = "M. Jean", required = true) + @Schema(description = "Nom et date du médecin sur certificat médical.", example = "M. Jean¤2025-02-03", format = "¤", required = true) private String certificate = null; @FormParam("validate") - @Schema(description = "Licence validée (seuls les admin pourrons enregistrer cette valeur)", example = "true", required = true) + @Schema(description = "Licence validée (seuls les admin pourrons modifier cette valeur)", example = "true", required = true) private boolean validate; + + @FormParam("pay") + @Schema(description = "Paiement de la licence (seuls les admin pourrons modifier cette valeur)", example = "true", required = true) + private boolean pay; } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 231f1b1..0e1ae68 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -63,4 +63,14 @@ quarkus.http.auth.permission.public.policy=permit quarkus.keycloak.admin-client.server-url=https://auth.safca.fr -quarkus.native.resources.includes=asset/** \ No newline at end of file +quarkus.native.resources.includes=asset/** + +# HelloAsso Connector +helloasso.api=https://api.helloasso.com +helloasso.client-id=changeme +helloasso.client-secret=changeme + +quarkus.rest-client.helloasso-auth.url=${helloasso.api}/oauth2 +quarkus.rest-client.helloasso-auth.scope=javax.inject.Singleton + +quarkus.rest-client.helloasso-api.url=${helloasso.api}/v5 diff --git a/src/main/webapp/src/pages/MemberList.jsx b/src/main/webapp/src/pages/MemberList.jsx index 1bfc32a..ccc6202 100644 --- a/src/main/webapp/src/pages/MemberList.jsx +++ b/src/main/webapp/src/pages/MemberList.jsx @@ -10,6 +10,8 @@ import {apiAxios, errFormater} from "../utils/Tools.js"; import {toast} from "react-toastify"; import {SearchBar} from "../components/SearchBar.jsx"; import * as XLSX from "xlsx-js-style"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faEuroSign} from "@fortawesome/free-solid-svg-icons"; export function MemberList({source}) { const {hash} = useLocation(); @@ -21,15 +23,17 @@ export function MemberList({source}) { const [licenceData, setLicenceData] = useState([]); const [showLicenceState, setShowLicenceState] = useState(false); const [clubFilter, setClubFilter] = useState(""); + const [stateFilter, setStateFilter] = useState(4) const [lastSearch, setLastSearch] = useState(""); + const [paymentFilter, setPaymentFilter] = useState(2); const setLoading = useLoadingSwitcher() - const {data, error, refresh} = useFetch(`/member/find/${source}?page=${page}`, setLoading, 1) + const {data, error, refresh} = useFetch(`/member/find/${source}?page=${page}&licenceRequest=${stateFilter}&payment=${paymentFilter}`, setLoading, 1) useEffect(() => { - refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}`); - }, [hash, clubFilter]); + refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}&licenceRequest=${stateFilter}&payment=${paymentFilter}`); + }, [hash, clubFilter, stateFilter, lastSearch, paymentFilter]); useEffect(() => { if (!data) @@ -73,7 +77,6 @@ export function MemberList({source}) { if (search === lastSearch) return; setLastSearch(search); - refresh(`/member/find/${source}?page=${page}&search=${search}&club=${clubFilter}`); } return <> @@ -92,12 +95,20 @@ export function MemberList({source}) {
+ {source === "admin" && + } + {source === "club" && false && // TODO: enable when payment is ready + }
Filtre
+ clubFilter={clubFilter} setClubFilter={setClubFilter} source={source} + stateFilter={stateFilter} setStateFilter={setStateFilter} paymentFilter={paymentFilter} + setPaymentFilter={setPaymentFilter}/>
@@ -356,7 +367,8 @@ function MakeCentralPanel({data, visibleMember, navigate, showLicenceState, page function MakeRow({member, showLicenceState, navigate, source}) { const rowContent = <>
- {member.licence_number ? String(member.licence_number).padStart(5, '0') : "-------"} + {(member.licence_number ? String(member.licence_number).padStart(5, '0') : "-------") + " "} + {(showLicenceState && member.licence != null && member.licence.pay)? : <>  }
{member.fname} {member.lname}
@@ -381,7 +393,7 @@ function MakeRow({member, showLicenceState, navigate, source}) { let allClub = [] -function FiltreBar({showLicenceState, setShowLicenceState, data, clubFilter, setClubFilter, source}) { +function FiltreBar({showLicenceState, setShowLicenceState, data, clubFilter, setClubFilter, source, stateFilter, setStateFilter, paymentFilter, setPaymentFilter}) { useEffect(() => { if (!data) return; @@ -394,6 +406,25 @@ function FiltreBar({showLicenceState, setShowLicenceState, data, clubFilter, set
{source !== "club" && } +
+ +
+
+ +
} diff --git a/src/main/webapp/src/pages/PayAndValidateList.css b/src/main/webapp/src/pages/PayAndValidateList.css new file mode 100644 index 0000000..8215728 --- /dev/null +++ b/src/main/webapp/src/pages/PayAndValidateList.css @@ -0,0 +1,112 @@ +.HaPay { + width: fit-content; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; +} + +.HaPay * { + font-family: "Open Sans", "Trebuchet MS", "Lucida Sans Unicode", + "Lucida Grande", "Lucida Sans", Arial, sans-serif; + transition: all 0.3s ease-out; +} + +.HaPayButton { + align-items: stretch; + -webkit-box-pack: stretch; + -ms-flex-pack: stretch; + background: none; + border: none; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + padding: 0; + border-radius: 8px; +} + +.HaPayButton:hover { + cursor: pointer; +} + +.HaPayButton:not(:disabled):focus { + box-shadow: 0 0 0 0.25rem rgba(73, 211, 138, 0.25); + -webkit-box-shadow: 0 0 0 0.25rem rgba(73, 211, 138, 0.25); +} + +.HaPayButton:not(:disabled):hover .HaPayButtonLabel, +.HaPayButton:not(:disabled):focus .HaPayButtonLabel { + background-color: #483dbe; +} + +.HaPayButton:not(:disabled):hover .HaPayButtonLogo, +.HaPayButton:not(:disabled):focus .HaPayButtonLogo, +.HaPayButton:not(:disabled):hover .HaPayButtonLabel, +.HaPayButton:not(:disabled):focus .HaPayButtonLabel { + border: 1px solid #483dbe; +} + +.HaPayButton:disabled { + cursor: not-allowed; +} + +.HaPayButton:disabled .HaPayButtonLogo, +.HaPayButton:disabled .HaPayButtonLabel { + border: 1px solid #d1d6de; +} + +.HaPayButtonLogo { + background-color: #ffffff; + border: 1px solid #4c40cf; + border-top-left-radius: 8px; + border-bottom-left-radius: 8px; + padding: 7px 10px; + width: 50px; +} + +.HaPayButtonLabel { + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: space-between; + column-gap: 5px; + background-color: #4c40cf; + border: 1px solid #4c40cf; + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; + color: #ffffff; + font-size: 16px; + font-weight: 800; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + padding: 0 16px; +} + +.HaPayButton:disabled .HaPayButtonLabel { + background-color: #d1d6de; + color: #505870; +} + +.HaPaySecured { + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: space-between; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + column-gap: 5px; + padding: 8px 16px; + font-size: 12px; + font-weight: 600; + color: #2e2f5e; +} + +.HaPay svg { + fill: currentColor; +} diff --git a/src/main/webapp/src/pages/PayAndValidateList.jsx b/src/main/webapp/src/pages/PayAndValidateList.jsx new file mode 100644 index 0000000..6e0cc66 --- /dev/null +++ b/src/main/webapp/src/pages/PayAndValidateList.jsx @@ -0,0 +1,444 @@ +import {useLoadingSwitcher} from "../hooks/useLoading.jsx"; +import {useFetch} from "../hooks/useFetch.js"; +import {AxiosError} from "../components/AxiosError.jsx"; +import {ThreeDots} from "react-loader-spinner"; +import {useEffect, useRef, useState} from "react"; +import {useLocation, useNavigate} from "react-router-dom"; +import {apiAxios, errFormater} from "../utils/Tools.js"; +import {toast} from "react-toastify"; +import {SearchBar} from "../components/SearchBar.jsx"; +import {ConfirmDialog} from "../components/ConfirmDialog.jsx"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faCircleInfo, faEuroSign} from "@fortawesome/free-solid-svg-icons"; +import "./PayAndValidateList.css"; + +export function PayAndValidateList({source}) { + + const {hash} = useLocation(); + const navigate = useNavigate(); + let page = Number(hash.substring(1)); + page = (page > 0) ? page : 1; + + const [memberData, setMemberData] = useState([]); + const [licenceData, setLicenceData] = useState([]); + const [clubFilter, setClubFilter] = useState(""); + const [stateFilter, setStateFilter] = useState((source === "club") ? 1 : 2) + const [lastSearch, setLastSearch] = useState(""); + const [paymentFilter, setPaymentFilter] = useState((source === "club") ? 0 : 2); + + const storedMembers = sessionStorage.getItem("selectedMembers"); + const [selectedMembers, setSelectedMembers] = useState(storedMembers ? JSON.parse(storedMembers) : []); + + const setLoading = useLoadingSwitcher() + const { + data, + error, + refresh + } = useFetch(`/member/find/${source}?page=${page}&licenceRequest=${stateFilter}&payment=${paymentFilter}`, setLoading, 1) + + useEffect(() => { + sessionStorage.setItem("selectedMembers", JSON.stringify(selectedMembers)); + }, [selectedMembers]); + + useEffect(() => { + refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}&licenceRequest=${stateFilter}&payment=${paymentFilter}`); + }, [hash, clubFilter, stateFilter, lastSearch, paymentFilter]); + + useEffect(() => { + if (!data) + return; + const data2 = []; + for (const e of data.result) { + data2.push({ + id: e.id, + fname: e.fname, + lname: e.lname, + club: e.club, + categorie: e.categorie, + licence_number: e.licence, + licence: licenceData.find(licence => licence.membre === e.id) + }) + } + setMemberData(data2); + }, [data, licenceData]); + + useEffect(() => { + toast.promise( + apiAxios.get(`/licence/current/${source}`), + { + pending: "Chargement des licences...", + success: "Licences chargées", + error: { + render({data}) { + return errFormater(data, "Impossible de charger les licences") + } + } + }) + .then(data => { + setLicenceData(data.data); + }); + }, []); + + const search = (search) => { + if (search === lastSearch) + return; + setLastSearch(search); + } + + const handleValidation = () => { + if (selectedMembers.length === 0) { + toast.error("Aucun membre sélectionné"); + return; + } + + toast.promise( + apiAxios.post(`/licence/validate`, selectedMembers), + { + pending: "Validation des licences en cours...", + success: "Licences validées avec succès 🎉", + error: { + render({data}) { + return errFormater(data, "Échec de la validation des licences") + } + } + } + ).then(() => { + setSelectedMembers([]); + refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}&licenceRequest=${stateFilter}&payment=${paymentFilter}`); + }); + } + + const handlePay = () => { + if (selectedMembers.length === 0) { + toast.error("Aucun membre sélectionné"); + return; + } + + toast.promise( + apiAxios.post(`/licence/pay`, selectedMembers), + { + pending: "Création de la commande en cours...", + success: "Commande crée avec succès 🎉", + error: { + render({data}) { + return errFormater(data, "Échec de le création de la commande") + } + } + } + ).then((data) => { + window.location.href = data.data; + }); + } + + return <> +

Validation des licences

+ +
+
+
+ + {data + ? + : error + ? + : + } +
+
+
+
Filtre
+
+ +
+
+ +
+ {source === "admin" && <> + + + } + + {source === "club" && <> + {selectedMembers.length} licences sélectionnée
Total à régler : {selectedMembers.length * 15}€
+ +
+
+ + A propos de HelloAsso +
+
+ Le modèle solidaire de HelloAsso garantit que 100% de votre paiement sera versé à l’association choisie. Vous + pouvez soutenir l’aide qu’ils apportent aux associations en laissant une contribution volontaire à HelloAsso au + moment de votre paiement. +
+
+ } +
+ +
+
+
+ +} + +function HaPay({onClick}) { + return <> +
+ +
+ + + + Paiement sécurisé + Logo Visa + Logo Mastercard + Logo CB + Logo PCI +
+
+ +} + +function MakeCentralPanel({data, visibleMember, navigate, page, source, selectedMembers, setSelectedMembers}) { + const lastCheckedRef = useRef(null); + + function handleCheckbox(e, memberId) { + const isShiftKeyPressed = e.shiftKey; + const isChecked = !selectedMembers.includes(memberId); // Inverse l'état actuel + + if (isShiftKeyPressed && lastCheckedRef.current !== null) { + // Sélection multiple avec Shift + const startIndex = visibleMember.findIndex(m => m.id === lastCheckedRef.current); + const endIndex = visibleMember.findIndex(m => m.id === memberId); + const [start, end] = [Math.min(startIndex, endIndex), Math.max(startIndex, endIndex)]; + + const newSelected = [...selectedMembers]; + for (let i = start; i <= end; i++) { + const member = visibleMember[i]; + if (isChecked && !newSelected.includes(member.id)) { + newSelected.push(member.id); + } else if (!isChecked) { + const index = newSelected.indexOf(member.id); + if (index !== -1) newSelected.splice(index, 1); + } + } + setSelectedMembers(newSelected); + } else { + // Sélection normale (sans Shift) + setSelectedMembers(prev => + isChecked + ? [...prev, memberId] + : prev.filter(id => id !== memberId) + ); + } + + lastCheckedRef.current = memberId; // Met à jour le dernier membre cliqué + } + + const handleCheckboxClick = (e, memberId) => { + handleCheckbox(e, memberId); + }; + + const handleRowClick = (e, memberId) => { + // Si le clic est sur la checkbox, on laisse le gestionnaire de la checkbox gérer l'événement + if (e.target.type === 'checkbox') return; + handleCheckbox(e, memberId); + }; + + const pages = [] + for (let i = 1; i <= data.page_count; i++) { + pages.push(
  • + navigate("#" + i)}>{i} +
  • ); + } + + return <> +
    + Ligne {((page - 1) * data.page_size) + 1} à { + (page * data.page_size > data.result_count) ? data.result_count : (page * data.page_size)} (page {page} sur {data.page_count}) +
      + {visibleMember.map(member => ( + ))} +
    +
    +
    + +
    + +} + +function MakeRow({member, source, isChecked, onCheckboxClick, onRowClick}) { + const rowContent = <> +
    +
    + { + }} + onClick={(e) => onCheckboxClick(e, member.id)}/> + {(member.licence_number ? String(member.licence_number).padStart(5, '0') : "-------") + " "} + {(member.licence != null && member.licence.pay) ? : <>  } +
    +
    +
    {member.fname} {member.lname}
    +
    +
    + {source === "club" ? + {member.categorie} + : {member.club?.name || "Sans club"}} + + + if (member.licence != null) { + return
  • 1 ? "warning" : "danger"))} + onClick={(e) => onRowClick(e, member.id)}> + {rowContent} +
  • + } else { + return
  • onRowClick(e, member.id)}> + {rowContent} +
  • + } +} + +let allClub = [] + +function FiltreBar({data, clubFilter, setClubFilter, source, stateFilter, setStateFilter, paymentFilter, setPaymentFilter}) { + useEffect(() => { + if (!data) + return; + allClub.push(...data.result.map((e) => e.club?.name)) + allClub = allClub.filter((value, index, self) => self.indexOf(value) === index).filter(value => value != null).sort() + }, [data]); + + return
    + {source !== "club" && } +
    + +
    + {source !== "club" &&
    + +
    } +
    +} + +function ClubSelectFilter({clubFilter, setClubFilter}) { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/club/no_detail`, setLoading, 1) + + return <> + {data + ?
    + +
    + : error + ? + : + } + +} + +function Def() { + return
    +
  • +
  • +
  • +
  • +
  • +
    +} diff --git a/src/main/webapp/src/pages/admin/AdminRoot.jsx b/src/main/webapp/src/pages/admin/AdminRoot.jsx index 948503b..ee9bc53 100644 --- a/src/main/webapp/src/pages/admin/AdminRoot.jsx +++ b/src/main/webapp/src/pages/admin/AdminRoot.jsx @@ -9,8 +9,8 @@ import {AffiliationReqPage} from "./affiliation/AffiliationReqPage.jsx"; import {NewClubPage} from "./club/NewClubPage.jsx"; import {ClubPage} from "./club/ClubPage.jsx"; import {AffiliationReqList} from "./affiliation/AffiliationReqList.jsx"; -import {Scale} from "leaflet/src/control/Control.Scale.js"; import {StatsPage} from "./StatsPage.jsx"; +import {PayAndValidateList} from "../PayAndValidateList.jsx"; export function AdminRoot() { return <> @@ -35,6 +35,10 @@ export function getAdminChildren() { path: 'member/new', element: }, + { + path: 'member/validate', + element: + }, { path: 'club', element: @@ -60,4 +64,4 @@ export function getAdminChildren() { element: } ] -} \ No newline at end of file +} diff --git a/src/main/webapp/src/pages/admin/member/LicenceCard.jsx b/src/main/webapp/src/pages/admin/member/LicenceCard.jsx index 83c49a8..ea7f0c0 100644 --- a/src/main/webapp/src/pages/admin/member/LicenceCard.jsx +++ b/src/main/webapp/src/pages/admin/member/LicenceCard.jsx @@ -2,7 +2,7 @@ import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; import {useFetch} from "../../../hooks/useFetch.js"; import {useEffect, useReducer, useState} from "react"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faPen} from "@fortawesome/free-solid-svg-icons"; +import {faEuroSign, faPen} from "@fortawesome/free-solid-svg-icons"; import {AxiosError} from "../../../components/AxiosError.jsx"; import {apiAxios, errFormater, getSaison} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; @@ -66,7 +66,7 @@ export function LicenceCard({userData}) { return
    1 ? "warning" : "danger"))}> -
    {licence?.saison}-{licence?.saison + 1}
    +
    {licence?.saison}-{licence?.saison + 1} {(licence.pay) && }
    @@ -134,6 +134,7 @@ function ModalContent({licence, dispatch}) { const [certificateBy, setCertificateBy] = useState("") const [certificateDate, setCertificateDate] = useState("") const [validate, setValidate] = useState(false) + const [pay, setPay] = useState(false) const [isNew, setNew] = useState(true) const setSeason = (event) => { setSaison(Number(event.target.value)) @@ -147,6 +148,9 @@ function ModalContent({licence, dispatch}) { const handleValidateChange = (event) => { setValidate(event.target.value === 'true'); } + const handlePayChange = (event) => { + setPay(event.target.value === 'true'); + } useEffect(() => { if (licence.id !== -1) { @@ -160,12 +164,14 @@ function ModalContent({licence, dispatch}) { setCertificateDate(licence.certificate.split('¤')[1]) } setValidate(licence.validate) + setPay(licence.pay); } else { setNew(true) setSaison(getSaison()) setCertificateBy("") setCertificateDate("") setValidate(false) + setPay(false); } }, [licence]); @@ -197,6 +203,8 @@ function ModalContent({licence, dispatch}) {
    + diff --git a/src/main/webapp/src/pages/club/ClubRoot.jsx b/src/main/webapp/src/pages/club/ClubRoot.jsx index d7a7216..70cccf8 100644 --- a/src/main/webapp/src/pages/club/ClubRoot.jsx +++ b/src/main/webapp/src/pages/club/ClubRoot.jsx @@ -5,6 +5,9 @@ import {useAuth} from "../../hooks/useAuth.jsx"; import {MemberList} from "../MemberList.jsx"; import {NewMemberPage} from "./member/NewMemberPage.jsx"; import {MyClubPage} from "./club/MyClubPage.jsx"; +import {PayAndValidateList} from "../PayAndValidateList.jsx"; +import {PaymentError} from "./PaymentError.jsx"; +import {PaymentReturn} from "./PaymentReturn.jsx"; export function ClubRoot() { const {userinfo} = useAuth() @@ -33,6 +36,18 @@ export function getClubChildren() { path: 'member', element: }, + { + path: 'member/pay', + element: + }, + { + path: 'member/pay/error', + element: + }, + { + path: 'member/pay/return', + element: + }, { path: 'member/:id', element: @@ -46,4 +61,4 @@ export function getClubChildren() { element: } ] -} \ No newline at end of file +} diff --git a/src/main/webapp/src/pages/club/PaymentError.jsx b/src/main/webapp/src/pages/club/PaymentError.jsx new file mode 100644 index 0000000..f7330cb --- /dev/null +++ b/src/main/webapp/src/pages/club/PaymentError.jsx @@ -0,0 +1,19 @@ +import {useSearchParams} from "react-router-dom"; + +export function PaymentError () { + const [searchParams, setSearchParams] = useSearchParams(); + + const error = searchParams.get("error"); + + return
    +
    +
    +

    Erreur de paiement😕

    +

    Une erreur est survenue lors du traitement de votre paiement. Veuillez réessayer plus tard.

    +

    Message d'erreur : {error}

    + + +
    +
    +
    ; +} diff --git a/src/main/webapp/src/pages/club/PaymentReturn.jsx b/src/main/webapp/src/pages/club/PaymentReturn.jsx new file mode 100644 index 0000000..31627f1 --- /dev/null +++ b/src/main/webapp/src/pages/club/PaymentReturn.jsx @@ -0,0 +1,16 @@ +import {useNavigate} from "react-router-dom"; + +export function PaymentReturn() { + const navigate = useNavigate(); + + return
    +
    +
    +

    🎉Votre paiement a été traité avec succès.🎉

    +

    Merci pour votre paiement. Les licences devraient être activées dans l'heure qui vient, à condition que le certificat médical soit rempli.

    + + +
    +
    +
    ; +} diff --git a/src/main/webapp/src/pages/club/member/LicenceCard.jsx b/src/main/webapp/src/pages/club/member/LicenceCard.jsx index 5bcccb7..cf3e75c 100644 --- a/src/main/webapp/src/pages/club/member/LicenceCard.jsx +++ b/src/main/webapp/src/pages/club/member/LicenceCard.jsx @@ -2,7 +2,7 @@ import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; import {useFetch} from "../../../hooks/useFetch.js"; import {useEffect, useReducer, useState} from "react"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faInfo, faPen} from "@fortawesome/free-solid-svg-icons"; +import {faEuroSign, faInfo, faPen} from "@fortawesome/free-solid-svg-icons"; import {AxiosError} from "../../../components/AxiosError.jsx"; import {apiAxios, errFormater, getSaison} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; @@ -172,13 +172,16 @@ function ModalContent({licence, dispatch}) { Certificat médical
    Fait par - , le -
    +
    +
    Paiement de la licence:
    +
    Validation de la licence:
    @@ -187,7 +190,7 @@ function ModalContent({licence, dispatch}) { {currentSaison && !licence.validate && } - {currentSaison && !licence.validate && licence.id !== -1 && + {currentSaison && !licence.validate && licence.id !== -1 && !licence.pay && } diff --git a/src/main/webapp/src/utils/Tools.js b/src/main/webapp/src/utils/Tools.js index a9f105e..2d7d046 100644 --- a/src/main/webapp/src/utils/Tools.js +++ b/src/main/webapp/src/utils/Tools.js @@ -9,7 +9,9 @@ apiAxios.defaults.headers.post['Accept'] = 'application/json; charset=UTF-8'; export const errFormater = (data, msg) => { - return `${msg} (${data.response.statusText}: ${data.response.data}) 😕` + if (typeof data.response.data === 'string' || data.response.data instanceof String) + return `${msg} (${data.response.statusText}: ${data.response.data}) 😕` + return `${msg} (${data.response.statusText}: ${JSON.stringify(data.response.data)}) 😕` } export function getCategoryFormBirthDate(birth_date, currentDate = new Date()) { @@ -48,4 +50,4 @@ export function getSaison(currentDate = new Date()) { } else { return currentDate.getFullYear() - 1 } -} \ No newline at end of file +}