From 580104de00d76533d4cddd4caaf10cd6404f5264 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Tue, 12 Aug 2025 15:29:48 +0200 Subject: [PATCH 01/11] feat:check email in import + move pdf code in service --- .../ffsaf/domain/service/ClubService.java | 129 ---------- .../ffsaf/domain/service/MembreService.java | 98 ++------ .../ffsaf/domain/service/PDFService.java | 232 ++++++++++++++++++ .../titionfire/ffsaf/rest/ClubEndpoints.java | 8 +- .../ffsaf/rest/MembreEndpoints.java | 8 +- 5 files changed, 261 insertions(+), 214 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/PDFService.java 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/MembreService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java index 6e6e58e..be34bb1 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; @@ -167,9 +155,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 +170,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 +180,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(), @@ -477,70 +479,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/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/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)); } } From c85c28fee21f87131b4b75f8ed66067e2704ae80 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Tue, 12 Aug 2025 15:39:36 +0200 Subject: [PATCH 02/11] feat: lock full name change on membre edit --- .../fr/titionfire/ffsaf/domain/service/MembreService.java | 6 ++++++ 1 file changed, 6 insertions(+) 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 be34bb1..a6f29c5 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -290,6 +290,12 @@ public class MembreService { .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; From 41a88ea91454feb3925c6c7283516e679bc33f97 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Tue, 12 Aug 2025 15:52:19 +0200 Subject: [PATCH 03/11] feat: lock membre creation with same name --- .../fr/titionfire/ffsaf/domain/service/MembreService.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 a6f29c5..72e54ef 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -385,6 +385,14 @@ public class MembreService { public Uni add(FullMemberForm input, String subject) { return repository.find("userId = ?1", subject).firstResult() + .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); From 1cd4a1ff97ede4052ccc0fd9975d29ddfc97e345 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Tue, 12 Aug 2025 18:56:04 +0200 Subject: [PATCH 04/11] feat: licence state filter membreList --- .../ffsaf/data/model/LicenceModel.java | 2 +- .../ffsaf/domain/service/MembreService.java | 109 +++++++++++++----- .../ffsaf/rest/MembreAdminEndpoints.java | 5 +- .../ffsaf/rest/MembreClubEndpoints.java | 5 +- src/main/webapp/src/pages/MemberList.jsx | 28 ++++- 5 files changed, 107 insertions(+), 42 deletions(-) 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..9249442 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java @@ -30,7 +30,7 @@ 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 du médecin sur certificat médical.", example = "M. Jean") // TODO Update for date String certificate; @Schema(description = "Licence validée", example = "true") 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 72e54ef..0c632c7 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -78,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); - } - - public Uni> search(int limit, int page, String search, String subject) { + public Uni> searchAdmin(int limit, int page, String search, String club, int licenceRequest) { 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; + if (licenceRequest == 0 || licenceRequest == 1) + baseUni = licenceRepository.list("saison = ?1", Utils.getSaison()); + else if (licenceRequest == 2) + baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE", Utils.getSaison()); + else if (licenceRequest == 5) + baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) >= 3", Utils.getSaison()); + else if (licenceRequest == 6) + baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) <= 2", Utils.getSaison()); + else if (licenceRequest == 3) + baseUni = licenceRepository.list("saison = ?1 AND validate = TRUE", Utils.getSaison()); + else + baseUni = Uni.createFrom().item(new ArrayList<>()); + + 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()) { + LOGGER.info(ids); + 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, String subject) { + if (search == null) + search = ""; + search = "%" + search.replaceAll(" ", "% %") + "%"; + + String finalSearch = search; + + Uni> baseUni; + if (licenceRequest == 0 || licenceRequest == 1) + baseUni = licenceRepository.list("saison = ?1", Utils.getSaison()); + else if (licenceRequest == 2) + baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE", Utils.getSaison()); + else if (licenceRequest == 5) + baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) >= 3", Utils.getSaison()); + else if (licenceRequest == 6) + baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) <= 2", Utils.getSaison()); + else if (licenceRequest == 3) + baseUni = licenceRepository.list("saison = ?1 AND validate = TRUE", Utils.getSaison()); + else + baseUni = Uni.createFrom().item(new ArrayList<>()); + + 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)) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java index 82865fd..5fb8f82 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java @@ -57,12 +57,13 @@ 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) { 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); } @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..4dcc4e1 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java @@ -49,12 +49,13 @@ 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) { 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, securityCtx.getSubject()); } @GET diff --git a/src/main/webapp/src/pages/MemberList.jsx b/src/main/webapp/src/pages/MemberList.jsx index 1bfc32a..54a568f 100644 --- a/src/main/webapp/src/pages/MemberList.jsx +++ b/src/main/webapp/src/pages/MemberList.jsx @@ -21,15 +21,16 @@ 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 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}`, 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}`); + }, [hash, clubFilter, stateFilter]); useEffect(() => { if (!data) @@ -73,7 +74,7 @@ export function MemberList({source}) { if (search === lastSearch) return; setLastSearch(search); - refresh(`/member/find/${source}?page=${page}&search=${search}&club=${clubFilter}`); + refresh(`/member/find/${source}?page=${page}&search=${search}&club=${clubFilter}&licenceRequest=${stateFilter}`); } return <> @@ -92,12 +93,16 @@ export function MemberList({source}) {
+ {source === "admin" && + }
Filtre
+ clubFilter={clubFilter} setClubFilter={setClubFilter} source={source} + stateFilter={stateFilter} setStateFilter={setStateFilter}/>
@@ -381,7 +386,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}) { useEffect(() => { if (!data) return; @@ -394,6 +399,17 @@ function FiltreBar({showLicenceState, setShowLicenceState, data, clubFilter, set
{source !== "club" && } +
+ +
} From f75e805cc0a666513cc57579e450e28d828d62fe Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 13 Aug 2025 14:58:25 +0200 Subject: [PATCH 05/11] feat: update welcome mail --- .../fr/titionfire/ffsaf/domain/service/KeycloakService.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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())) From 7bd5e7baa56b8ea852ec2c50c77b16caed8d0f6a Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 13 Aug 2025 15:16:39 +0200 Subject: [PATCH 06/11] feat: check for email duplication when create or update membre --- .../ffsaf/domain/service/MembreService.java | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) 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 0c632c7..673e6b9 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -78,7 +78,8 @@ 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, int licenceRequest) { + public Uni> searchAdmin(int limit, int page, String search, String club, + int licenceRequest) { if (search == null) search = ""; search = "%" + search.replaceAll(" ", "% %") + "%"; @@ -91,9 +92,11 @@ public class MembreService { else if (licenceRequest == 2) baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE", Utils.getSaison()); else if (licenceRequest == 5) - baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) >= 3", Utils.getSaison()); + baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) >= 3", + Utils.getSaison()); else if (licenceRequest == 6) - baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) <= 2", Utils.getSaison()); + baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) <= 2", + Utils.getSaison()); else if (licenceRequest == 3) baseUni = licenceRepository.list("saison = ?1 AND validate = TRUE", Utils.getSaison()); else @@ -128,7 +131,8 @@ public class MembreService { }); } - public Uni> search(int limit, int page, String search, int licenceRequest, String subject) { + public Uni> search(int limit, int page, String search, int licenceRequest, + String subject) { if (search == null) search = ""; search = "%" + search.replaceAll(" ", "% %") + "%"; @@ -141,9 +145,11 @@ public class MembreService { else if (licenceRequest == 2) baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE", Utils.getSaison()); else if (licenceRequest == 5) - baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) >= 3", Utils.getSaison()); + baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) >= 3", + Utils.getSaison()); else if (licenceRequest == 6) - baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) <= 2", Utils.getSaison()); + baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) <= 2", + Utils.getSaison()); else if (licenceRequest == 3) baseUni = licenceRepository.list("saison = ?1 AND validate = TRUE", Utils.getSaison()); else @@ -317,6 +323,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 -> { @@ -334,6 +345,11 @@ 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(); @@ -420,6 +436,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)); @@ -432,6 +452,10 @@ 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", From 09f6cd7463ead65cf45dcddc0177647ba24146dd Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 13 Aug 2025 15:32:06 +0200 Subject: [PATCH 07/11] feat: masse licence validation --- .../ffsaf/domain/service/LicenceService.java | 44 ++- .../ffsaf/rest/LicenceEndpoints.java | 17 + src/main/webapp/src/pages/ValidateList.jsx | 302 ++++++++++++++++++ src/main/webapp/src/pages/admin/AdminRoot.jsx | 8 +- 4 files changed, 360 insertions(+), 11 deletions(-) create mode 100644 src/main/webapp/src/pages/ValidateList.jsx 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..a8db060 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java @@ -20,6 +20,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 +35,9 @@ public class LicenceService { @Inject SequenceRepository sequenceRepository; + @Inject + KeycloakService keycloakService; + public Uni> getLicence(long id, Consumer checkPerm) { return combRepository.findById(id).invoke(checkPerm) .chain(combRepository -> Mutiny.fetch(combRepository.getLicences())); @@ -48,6 +52,23 @@ 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 -> { + model.setValidate(true); + return Panache.withTransaction(() -> repository.persist(model) + .call(m -> Mutiny.fetch(m.getMembre()) + .call(genLicenceNumberAndAccountIfNeed()) + )); + })) + .map(__ -> "OK"); + } + return uni; + } + public Uni setLicence(long id, LicenceForm form) { if (form.getId() == -1) { return combRepository.findById(id).chain(membreModel -> { @@ -58,10 +79,8 @@ public class LicenceService { model.setCertificate(form.getCertificate()); model.setValidate(form.isValidate()); 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)) + .call(m -> m.isValidate() ? Uni.createFrom().item(membreModel) + .call(genLicenceNumberAndAccountIfNeed()) : Uni.createFrom().nullItem() )); }); @@ -71,17 +90,24 @@ public class LicenceService { model.setValidate(form.isValidate()); 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()) + .call(genLicenceNumberAndAccountIfNeed()) : Uni.createFrom().nullItem() )); }); } } + 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 deleteLicence(long id) { return Panache.withTransaction(() -> repository.deleteById(id)); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java index 1a045d8..448995b 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; @@ -97,6 +98,22 @@ 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 membre a valider") List ids) { + return licenceService.valideLicences(ids); + } + @DELETE @Path("{id}") @RolesAllowed("federation_admin") diff --git a/src/main/webapp/src/pages/ValidateList.jsx b/src/main/webapp/src/pages/ValidateList.jsx new file mode 100644 index 0000000..70f54b0 --- /dev/null +++ b/src/main/webapp/src/pages/ValidateList.jsx @@ -0,0 +1,302 @@ +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"; + +export function ValidateList({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(2) + const [lastSearch, setLastSearch] = useState(""); + + const [selectedMembers, setSelectedMembers] = useState([]); + + const setLoading = useLoadingSwitcher() + const {data, error, refresh} = useFetch(`/member/find/${source}?page=${page}&licenceRequest=${stateFilter}`, setLoading, 1) + + + useEffect(() => { + refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}&licenceRequest=${stateFilter}`); + }, [hash, clubFilter, stateFilter]); + + 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); + refresh(`/member/find/${source}?page=${page}&search=${search}&club=${clubFilter}&licenceRequest=${stateFilter}`); + } + + 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}`); + }); + } + + return <> +

Validation des licences

+ +
+
+
+ + {data + ? + : error + ? + : + } +
+
+ {source !== "club" && +
+
Filtre
+
+ +
+
} + +
+ {source === "admin" && <> + + + } +
+ +
+
+
+ +} + +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.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}) { + 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" && } +
    + +
    +
    +} + +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..74ab595 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 {ValidateList} from "../ValidateList.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 +} From 0563c7c8dee55d173d993a9e20693c3e192d355b Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 13 Aug 2025 22:05:19 +0200 Subject: [PATCH 08/11] feat: add pay information to licence --- .../ffsaf/data/model/LicenceModel.java | 6 +- .../domain/service/AffiliationService.java | 9 +-- .../ffsaf/domain/service/LicenceService.java | 4 ++ .../ffsaf/domain/service/MembreService.java | 62 +++++++++---------- .../ffsaf/rest/MembreAdminEndpoints.java | 5 +- .../ffsaf/rest/MembreClubEndpoints.java | 5 +- .../ffsaf/rest/data/SimpleLicence.java | 3 + .../ffsaf/rest/from/LicenceForm.java | 8 ++- src/main/webapp/src/pages/MemberList.jsx | 26 +++++--- src/main/webapp/src/pages/ValidateList.jsx | 34 +++++++--- .../src/pages/admin/member/LicenceCard.jsx | 12 +++- .../src/pages/club/member/LicenceCard.jsx | 11 ++-- 12 files changed, 114 insertions(+), 71 deletions(-) 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 9249442..c14547e 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java @@ -30,9 +30,13 @@ 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") // TODO Update for date + @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; } 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..89c7d28 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 @@ -270,7 +263,7 @@ 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))))); } public Uni accept(AffiliationRequestSaveForm form) { 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 a8db060..d4e1f50 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java @@ -78,6 +78,7 @@ 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() ? Uni.createFrom().item(membreModel) .call(genLicenceNumberAndAccountIfNeed()) @@ -88,6 +89,7 @@ public class LicenceService { return repository.findById(form.getId()).chain(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(genLicenceNumberAndAccountIfNeed()) @@ -143,6 +145,8 @@ public class LicenceService { .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"); })) .chain(__ -> Panache.withTransaction(() -> repository.deleteById(id))); } 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 673e6b9..cd20f5f 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -78,29 +78,38 @@ 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)"; + 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> searchAdmin(int limit, int page, String search, String club, - int licenceRequest) { + int licenceRequest, int payState) { if (search == null) search = ""; search = "%" + search.replaceAll(" ", "% %") + "%"; String finalSearch = search; - - Uni> baseUni; - if (licenceRequest == 0 || licenceRequest == 1) - baseUni = licenceRepository.list("saison = ?1", Utils.getSaison()); - else if (licenceRequest == 2) - baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE", Utils.getSaison()); - else if (licenceRequest == 5) - baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) >= 3", - Utils.getSaison()); - else if (licenceRequest == 6) - baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) <= 2", - Utils.getSaison()); - else if (licenceRequest == 3) - baseUni = licenceRepository.list("saison = ?1 AND validate = TRUE", Utils.getSaison()); - else - baseUni = Uni.createFrom().item(new ArrayList<>()); + Uni> baseUni = getLicenceListe(licenceRequest, payState); return baseUni .map(l -> l.stream().map(l2 -> l2.getMembre().getId()).toList()) @@ -110,7 +119,6 @@ public class MembreService { String idf = ((licenceRequest == 0 || licenceRequest == 4) ? "NOT IN" : "IN"); if (club == null || club.isBlank()) { - LOGGER.info(ids); query = repository.find( "id " + idf + " ?2 AND (" + FIND_NAME_REQUEST + ")", Sort.ascending("fname", "lname"), finalSearch, ids) @@ -131,7 +139,7 @@ public class MembreService { }); } - public Uni> search(int limit, int page, String search, int licenceRequest, + public Uni> search(int limit, int page, String search, int licenceRequest, int payState, String subject) { if (search == null) search = ""; @@ -139,21 +147,7 @@ public class MembreService { String finalSearch = search; - Uni> baseUni; - if (licenceRequest == 0 || licenceRequest == 1) - baseUni = licenceRepository.list("saison = ?1", Utils.getSaison()); - else if (licenceRequest == 2) - baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE", Utils.getSaison()); - else if (licenceRequest == 5) - baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) >= 3", - Utils.getSaison()); - else if (licenceRequest == 6) - baseUni = licenceRepository.list("saison = ?1 AND validate = FALSE AND LENGTH(certificate) <= 2", - Utils.getSaison()); - else if (licenceRequest == 3) - baseUni = licenceRepository.list("saison = ?1 AND validate = TRUE", Utils.getSaison()); - else - baseUni = Uni.createFrom().item(new ArrayList<>()); + Uni> baseUni = getLicenceListe(licenceRequest, payState); return baseUni .map(l -> l.stream().map(l2 -> l2.getMembre().getId()).toList()) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java index 5fb8f82..e0b04b0 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java @@ -58,12 +58,13 @@ public class MembreAdminEndpoints { @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 = "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 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, licenceRequest); + 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 4dcc4e1..545552c 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java @@ -50,12 +50,13 @@ public class MembreClubEndpoints { @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 = "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 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, licenceRequest, securityCtx.getSubject()); + return membreService.search(limit, page - 1, search, licenceRequest, payment, securityCtx.getSubject()); } @GET 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/webapp/src/pages/MemberList.jsx b/src/main/webapp/src/pages/MemberList.jsx index 54a568f..07a7adb 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(); @@ -23,14 +25,15 @@ export function MemberList({source}) { 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}&licenceRequest=${stateFilter}`, 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}&licenceRequest=${stateFilter}`); - }, [hash, clubFilter, stateFilter]); + refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}&licenceRequest=${stateFilter}&payment=${paymentFilter}`); + }, [hash, clubFilter, stateFilter, lastSearch, paymentFilter]); useEffect(() => { if (!data) @@ -74,7 +77,6 @@ export function MemberList({source}) { if (search === lastSearch) return; setLastSearch(search); - refresh(`/member/find/${source}?page=${page}&search=${search}&club=${clubFilter}&licenceRequest=${stateFilter}`); } return <> @@ -102,7 +104,8 @@ export function MemberList({source}) {
    + stateFilter={stateFilter} setStateFilter={setStateFilter} paymentFilter={paymentFilter} + setPaymentFilter={setPaymentFilter}/>
    @@ -361,7 +364,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}
    @@ -386,7 +390,7 @@ function MakeRow({member, showLicenceState, navigate, source}) { let allClub = [] -function FiltreBar({showLicenceState, setShowLicenceState, data, clubFilter, setClubFilter, source, stateFilter, setStateFilter}) { +function FiltreBar({showLicenceState, setShowLicenceState, data, clubFilter, setClubFilter, source, stateFilter, setStateFilter, paymentFilter, setPaymentFilter}) { useEffect(() => { if (!data) return; @@ -410,6 +414,14 @@ function FiltreBar({showLicenceState, setShowLicenceState, data, clubFilter, set
    +
    + +
    } diff --git a/src/main/webapp/src/pages/ValidateList.jsx b/src/main/webapp/src/pages/ValidateList.jsx index 70f54b0..2969d06 100644 --- a/src/main/webapp/src/pages/ValidateList.jsx +++ b/src/main/webapp/src/pages/ValidateList.jsx @@ -8,6 +8,8 @@ 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 {faEuroSign} from "@fortawesome/free-solid-svg-icons"; export function ValidateList({source}) { const {hash} = useLocation(); @@ -20,16 +22,21 @@ export function ValidateList({source}) { const [clubFilter, setClubFilter] = useState(""); const [stateFilter, setStateFilter] = useState(2) const [lastSearch, setLastSearch] = useState(""); + const [paymentFilter, setPaymentFilter] = useState(2); const [selectedMembers, setSelectedMembers] = useState([]); const setLoading = useLoadingSwitcher() - const {data, error, refresh} = useFetch(`/member/find/${source}?page=${page}&licenceRequest=${stateFilter}`, 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}&licenceRequest=${stateFilter}`); - }, [hash, clubFilter, stateFilter]); + refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}&licenceRequest=${stateFilter}&payment=${paymentFilter}`); + }, [hash, clubFilter, stateFilter, lastSearch, paymentFilter]); useEffect(() => { if (!data) @@ -70,7 +77,6 @@ export function ValidateList({source}) { if (search === lastSearch) return; setLastSearch(search); - refresh(`/member/find/${source}?page=${page}&search=${search}&club=${clubFilter}&licenceRequest=${stateFilter}`); } const handleValidation = () => { @@ -92,7 +98,7 @@ export function ValidateList({source}) { } ).then(() => { setSelectedMembers([]); - refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}&licenceRequest=${stateFilter}`); + refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}&licenceRequest=${stateFilter}&payment=${paymentFilter}`); }); } @@ -119,7 +125,8 @@ export function ValidateList({source}) {
    Filtre
    + stateFilter={stateFilter} setStateFilter={setStateFilter} paymentFilter={paymentFilter} + setPaymentFilter={setPaymentFilter}/>
    } @@ -128,7 +135,8 @@ export function ValidateList({source}) { - } @@ -223,7 +231,8 @@ function MakeRow({member, source, isChecked, onCheckboxClick, onRowClick}) { { }} onClick={(e) => onCheckboxClick(e, member.id)}/> - {member.licence_number ? String(member.licence_number).padStart(5, '0') : "-------"} + {(member.licence_number ? String(member.licence_number).padStart(5, '0') : "-------") + " "} + {(member.licence != null && member.licence.pay)? : <>  }
    {member.fname} {member.lname}
    @@ -251,7 +260,7 @@ function MakeRow({member, source, isChecked, onCheckboxClick, onRowClick}) { let allClub = [] -function FiltreBar({data, clubFilter, setClubFilter, source, stateFilter, setStateFilter}) { +function FiltreBar({data, clubFilter, setClubFilter, source, stateFilter, setStateFilter, paymentFilter, setPaymentFilter}) { useEffect(() => { if (!data) return; @@ -268,6 +277,13 @@ function FiltreBar({data, clubFilter, setClubFilter, source, stateFilter, setSta
    +
    + +
    } 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/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 && } From 0a56f8c1807340922f279cbdb4763692c251bf47 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 13 Aug 2025 22:29:49 +0200 Subject: [PATCH 09/11] feat: add licence log --- .../ffsaf/data/model/LicenceModel.java | 12 +++++- .../domain/service/AffiliationService.java | 7 ++- .../ffsaf/domain/service/LicenceService.java | 43 +++++++++++++------ 3 files changed, 48 insertions(+), 14 deletions(-) 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 c14547e..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.") @@ -39,4 +39,14 @@ public class LicenceModel { @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/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index 89c7d28..3604881 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -55,6 +55,9 @@ public class AffiliationService { @Inject ReactiveMailer reactiveMailer; + @Inject + LoggerService ls; + @ConfigProperty(name = "upload_dir") String media; @@ -263,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, false))))); + 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/LicenceService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java index d4e1f50..9a061a7 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; @@ -38,6 +39,9 @@ public class LicenceService { @Inject KeycloakService keycloakService; + @Inject + LoggerService ls; + public Uni> getLicence(long id, Consumer checkPerm) { return combRepository.findById(id).invoke(checkPerm) .chain(combRepository -> Mutiny.fetch(combRepository.getLicences())); @@ -58,6 +62,8 @@ public class LicenceService { 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); model.setValidate(true); return Panache.withTransaction(() -> repository.persist(model) .call(m -> Mutiny.fetch(m.getMembre()) @@ -66,7 +72,7 @@ public class LicenceService { })) .map(__ -> "OK"); } - return uni; + return uni.call(__ -> ls.append()); } public Uni setLicence(long id, LicenceForm form) { @@ -80,21 +86,27 @@ public class LicenceService { model.setValidate(form.isValidate()); model.setPay(form.isPay()); return Panache.withTransaction(() -> repository.persist(model) - .call(m -> m.isValidate() ? Uni.createFrom().item(membreModel) - .call(genLicenceNumberAndAccountIfNeed()) - : 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(genLicenceNumberAndAccountIfNeed()) - : Uni.createFrom().nullItem() - )); + .call(m -> m.isValidate() ? Mutiny.fetch(m.getMembre()) + .call(genLicenceNumberAndAccountIfNeed()) + : Uni.createFrom().nullItem() + )) + .call(__ -> ls.append()); }); } } @@ -111,7 +123,9 @@ public class LicenceService { } public Uni deleteLicence(long id) { - return Panache.withTransaction(() -> repository.deleteById(id)); + return repository.findById(id) + .call(model -> ls.logADelete(model)) + .chain(model -> repository.delete(model)); } public Uni askLicence(long id, LicenceForm form, Consumer checkPerm) { @@ -129,11 +143,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()); }); } }); @@ -148,6 +166,7 @@ public class LicenceService { 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))); } From 15f65b10149b2653b7a083496d12b6cd1afa2330 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Thu, 14 Aug 2025 22:45:03 +0200 Subject: [PATCH 10/11] feat: HelloAsso payment --- .../ffsaf/data/model/CheckoutModel.java | 41 +++++ .../data/repository/CheckoutRepository.java | 9 + .../ffsaf/domain/service/CheckoutService.java | 161 +++++++++++++++++ .../domain/service/HelloAssoTokenService.java | 68 ++++++++ .../ffsaf/domain/service/LicenceService.java | 43 ++++- .../ffsaf/domain/service/LoggerService.java | 12 ++ .../ffsaf/domain/service/WebhookService.java | 28 +++ .../ffsaf/rest/LicenceEndpoints.java | 18 +- .../ffsaf/rest/WebhookEndpoints.java | 53 ++++++ .../rest/client/HelloAssoAuthClient.java | 32 ++++ .../rest/client/HelloAssoHeadersFactory.java | 24 +++ .../ffsaf/rest/client/HelloAssoService.java | 51 ++++++ .../ffsaf/rest/client/dto/ApiError.java | 14 ++ .../client/dto/CheckoutIntentsRequest.java | 30 ++++ .../client/dto/CheckoutIntentsResponse.java | 11 ++ .../rest/client/dto/CheckoutMetadata.java | 12 ++ .../client/dto/HelloassoNotification.java | 16 ++ .../rest/client/dto/NotificationData.java | 30 ++++ .../ffsaf/rest/client/dto/TokenResponse.java | 30 ++++ src/main/resources/application.properties | 12 +- src/main/webapp/src/pages/MemberList.jsx | 3 + .../webapp/src/pages/PayAndValidateList.css | 112 ++++++++++++ ...alidateList.jsx => PayAndValidateList.jsx} | 162 ++++++++++++++++-- src/main/webapp/src/pages/admin/AdminRoot.jsx | 4 +- src/main/webapp/src/pages/club/ClubRoot.jsx | 17 +- .../webapp/src/pages/club/PaymentError.jsx | 19 ++ .../webapp/src/pages/club/PaymentReturn.jsx | 16 ++ src/main/webapp/src/utils/Tools.js | 6 +- 28 files changed, 1003 insertions(+), 31 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/data/model/CheckoutModel.java create mode 100644 src/main/java/fr/titionfire/ffsaf/data/repository/CheckoutRepository.java create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/HelloAssoTokenService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/WebhookService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/WebhookEndpoints.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoAuthClient.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoHeadersFactory.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/ApiError.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsRequest.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsResponse.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutMetadata.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/HelloassoNotification.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/NotificationData.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/TokenResponse.java create mode 100644 src/main/webapp/src/pages/PayAndValidateList.css rename src/main/webapp/src/pages/{ValidateList.jsx => PayAndValidateList.jsx} (54%) create mode 100644 src/main/webapp/src/pages/club/PaymentError.jsx create mode 100644 src/main/webapp/src/pages/club/PaymentReturn.jsx 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/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/CheckoutService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java new file mode 100644 index 0000000..20de61a --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java @@ -0,0 +1,161 @@ +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.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.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()); + } +} 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/LicenceService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java index 9a061a7..2269249 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java @@ -42,6 +42,9 @@ public class LicenceService { @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())); @@ -64,17 +67,21 @@ public class LicenceService { .chain(model -> { if (!model.isValidate()) ls.logUpdate("validation de la licence", model); - model.setValidate(true); - return Panache.withTransaction(() -> repository.persist(model) - .call(m -> Mutiny.fetch(m.getMembre()) - .call(genLicenceNumberAndAccountIfNeed()) - )); + 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 -> { @@ -122,8 +129,29 @@ public class LicenceService { : 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 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)); } @@ -160,6 +188,11 @@ 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"); 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/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/LicenceEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java index 448995b..9264b2d 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java @@ -81,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") @@ -98,7 +113,6 @@ public class LicenceEndpoints { return licenceService.setLicence(id, form).map(SimpleLicence::fromModel); } - @POST @Path("validate") @RolesAllowed("federation_admin") @@ -110,7 +124,7 @@ public class LicenceEndpoints { @APIResponse(responseCode = "403", description = "Accès refusé"), @APIResponse(responseCode = "500", description = "Erreur interne du serveur") }) - public Uni valideLicences(@Parameter(description = "Id des membre a valider") List ids) { + public Uni valideLicences(@Parameter(description = "Id des membres a valider") List ids) { return licenceService.valideLicences(ids); } 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/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 07a7adb..2020d40 100644 --- a/src/main/webapp/src/pages/MemberList.jsx +++ b/src/main/webapp/src/pages/MemberList.jsx @@ -98,6 +98,9 @@ export function MemberList({source}) { {source === "admin" && } + {source === "club" && + }
    Filtre
    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/ValidateList.jsx b/src/main/webapp/src/pages/PayAndValidateList.jsx similarity index 54% rename from src/main/webapp/src/pages/ValidateList.jsx rename to src/main/webapp/src/pages/PayAndValidateList.jsx index 2969d06..6e0cc66 100644 --- a/src/main/webapp/src/pages/ValidateList.jsx +++ b/src/main/webapp/src/pages/PayAndValidateList.jsx @@ -9,9 +9,11 @@ import {toast} from "react-toastify"; import {SearchBar} from "../components/SearchBar.jsx"; import {ConfirmDialog} from "../components/ConfirmDialog.jsx"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faEuroSign} from "@fortawesome/free-solid-svg-icons"; +import {faCircleInfo, faEuroSign} from "@fortawesome/free-solid-svg-icons"; +import "./PayAndValidateList.css"; + +export function PayAndValidateList({source}) { -export function ValidateList({source}) { const {hash} = useLocation(); const navigate = useNavigate(); let page = Number(hash.substring(1)); @@ -20,11 +22,12 @@ export function ValidateList({source}) { const [memberData, setMemberData] = useState([]); const [licenceData, setLicenceData] = useState([]); const [clubFilter, setClubFilter] = useState(""); - const [stateFilter, setStateFilter] = useState(2) + const [stateFilter, setStateFilter] = useState((source === "club") ? 1 : 2) const [lastSearch, setLastSearch] = useState(""); - const [paymentFilter, setPaymentFilter] = useState(2); + const [paymentFilter, setPaymentFilter] = useState((source === "club") ? 0 : 2); - const [selectedMembers, setSelectedMembers] = useState([]); + const storedMembers = sessionStorage.getItem("selectedMembers"); + const [selectedMembers, setSelectedMembers] = useState(storedMembers ? JSON.parse(storedMembers) : []); const setLoading = useLoadingSwitcher() const { @@ -33,6 +36,9 @@ export function ValidateList({source}) { 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}`); @@ -102,9 +108,31 @@ export function ValidateList({source}) { }); } + 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

    -
    @@ -120,15 +148,14 @@ export function ValidateList({source}) { }
    - {source !== "club" && -
    -
    Filtre
    -
    - -
    -
    } +
    +
    Filtre
    +
    + +
    +
    {source === "admin" && <> @@ -139,6 +166,22 @@ export function ValidateList({source}) { message={"Êtes-vous sûr de vouloir valider les " + selectedMembers.length + " licences ?"} onConfirm={handleValidation} id="confirm-validation"/> } + + {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. +
    +
    + }
    @@ -147,6 +190,88 @@ export function ValidateList({source}) { } +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); @@ -232,7 +357,7 @@ function MakeRow({member, source, isChecked, onCheckboxClick, onRowClick}) { }} onClick={(e) => onCheckboxClick(e, member.id)}/> {(member.licence_number ? String(member.licence_number).padStart(5, '0') : "-------") + " "} - {(member.licence != null && member.licence.pay)? : <>  } + {(member.licence != null && member.licence.pay) ? : <>  }
    {member.fname} {member.lname}
    @@ -272,18 +397,19 @@ function FiltreBar({data, clubFilter, setClubFilter, source, stateFilter, setSta {source !== "club" && }
    -
    + {source !== "club" &&
    -
    +
    }
    } diff --git a/src/main/webapp/src/pages/admin/AdminRoot.jsx b/src/main/webapp/src/pages/admin/AdminRoot.jsx index 74ab595..ee9bc53 100644 --- a/src/main/webapp/src/pages/admin/AdminRoot.jsx +++ b/src/main/webapp/src/pages/admin/AdminRoot.jsx @@ -10,7 +10,7 @@ import {NewClubPage} from "./club/NewClubPage.jsx"; import {ClubPage} from "./club/ClubPage.jsx"; import {AffiliationReqList} from "./affiliation/AffiliationReqList.jsx"; import {StatsPage} from "./StatsPage.jsx"; -import {ValidateList} from "../ValidateList.jsx"; +import {PayAndValidateList} from "../PayAndValidateList.jsx"; export function AdminRoot() { return <> @@ -37,7 +37,7 @@ export function getAdminChildren() { }, { path: 'member/validate', - element: + element: }, { path: 'club', 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/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 +} From 1e37c43dcd3ad7e2bfbd46c44bdaa60842232ba0 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Fri, 15 Aug 2025 18:25:05 +0200 Subject: [PATCH 11/11] feat: add checkout table auto clean + disable pay button for pre deployment test --- .../ffsaf/domain/service/CheckoutService.java | 14 ++++++++++++++ src/main/webapp/src/pages/MemberList.jsx | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java index 20de61a..ce2ee6d 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java @@ -13,6 +13,7 @@ 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; @@ -21,6 +22,8 @@ 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 @@ -158,4 +161,15 @@ public class CheckoutService { .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/webapp/src/pages/MemberList.jsx b/src/main/webapp/src/pages/MemberList.jsx index 2020d40..ccc6202 100644 --- a/src/main/webapp/src/pages/MemberList.jsx +++ b/src/main/webapp/src/pages/MemberList.jsx @@ -98,7 +98,7 @@ export function MemberList({source}) { {source === "admin" && } - {source === "club" && + {source === "club" && false && // TODO: enable when payment is ready }