From 2b2025a78fb8aac59e6f0e38c20bd8ac8cec2aa5 Mon Sep 17 00:00:00 2001 From: Youssef Date: Wed, 5 Aug 2026 15:15:37 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ajout=20du=20syst=C3=A8me=20d'invitatio?= =?UTF-8?q?ns=20SportEasy=20et=20suivi=20des=20emails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- as-talange-core/pom.xml | 5 + .../com/astalange/core/entity/Categorie.java | 6 + .../com/astalange/core/entity/Licence.java | 20 ++ .../core/repository/LicenceRepository.java | 4 + .../core/service/SportEasyEmailService.java | 227 ++++++++++++++++++ .../migration/V40__add_sporteasy_fields.sql | 3 + .../web/controller/AdherentController.java | 73 +++++- .../src/main/resources/application.yml | 12 + .../resources/templates/adherents/list.html | 67 +++++- .../parametrage/categories_form.html | 7 + docker-compose.yml | 15 +- 11 files changed, 432 insertions(+), 7 deletions(-) create mode 100644 as-talange-core/src/main/java/com/astalange/core/service/SportEasyEmailService.java create mode 100644 as-talange-core/src/main/resources/db/migration/V40__add_sporteasy_fields.sql diff --git a/as-talange-core/pom.xml b/as-talange-core/pom.xml index 97bc1c1..aa91879 100644 --- a/as-talange-core/pom.xml +++ b/as-talange-core/pom.xml @@ -28,6 +28,11 @@ spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-mail + + org.projectlombok lombok diff --git a/as-talange-core/src/main/java/com/astalange/core/entity/Categorie.java b/as-talange-core/src/main/java/com/astalange/core/entity/Categorie.java index 02f56ed..6c9be20 100644 --- a/as-talange-core/src/main/java/com/astalange/core/entity/Categorie.java +++ b/as-talange-core/src/main/java/com/astalange/core/entity/Categorie.java @@ -37,11 +37,17 @@ public class Categorie { @JoinColumn(name = "saison_id", nullable = false) private Saison saison; + @Column(name = "sport_easy_token") + private String sportEasyToken; + @OneToMany(mappedBy = "categorie", cascade = CascadeType.ALL, orphanRemoval = true) private List categorieEquipements = new ArrayList<>(); // Getters and Setters + public String getSportEasyToken() { return sportEasyToken; } + public void setSportEasyToken(String sportEasyToken) { this.sportEasyToken = sportEasyToken; } + public Long getId() { return id; } public void setId(Long id) { this.id = id; } diff --git a/as-talange-core/src/main/java/com/astalange/core/entity/Licence.java b/as-talange-core/src/main/java/com/astalange/core/entity/Licence.java index 7c73599..1e585bc 100644 --- a/as-talange-core/src/main/java/com/astalange/core/entity/Licence.java +++ b/as-talange-core/src/main/java/com/astalange/core/entity/Licence.java @@ -44,6 +44,12 @@ public class Licence { @Column(columnDefinition = "TEXT") private String commentaire; + @Column(name = "sport_easy_email_sent", nullable = false) + private Boolean sportEasyEmailSent = false; + + @Column(name = "sport_easy_email_sent_at") + private java.time.LocalDateTime sportEasyEmailSentAt; + @OneToMany(mappedBy = "licence", cascade = CascadeType.ALL, orphanRemoval = true) private List paiements = new ArrayList<>(); @@ -134,6 +140,20 @@ public class Licence { public String getCommentaire() { return commentaire; } public void setCommentaire(String commentaire) { this.commentaire = commentaire; } + public Boolean getSportEasyEmailSent() { return sportEasyEmailSent != null ? sportEasyEmailSent : false; } + public void setSportEasyEmailSent(Boolean sportEasyEmailSent) { this.sportEasyEmailSent = sportEasyEmailSent; } + + public java.time.LocalDateTime getSportEasyEmailSentAt() { return sportEasyEmailSentAt; } + public void setSportEasyEmailSentAt(java.time.LocalDateTime sportEasyEmailSentAt) { this.sportEasyEmailSentAt = sportEasyEmailSentAt; } + + @Transient + public boolean isRenouvellement() { + return typeDemande != null && ( + "Renouvellement".equalsIgnoreCase(typeDemande.trim()) || + "RENOUVELLEMENT".equalsIgnoreCase(typeDemande.trim()) + ); + } + public List getPaiements() { return paiements; } public void setPaiements(List paiements) { this.paiements = paiements; } diff --git a/as-talange-core/src/main/java/com/astalange/core/repository/LicenceRepository.java b/as-talange-core/src/main/java/com/astalange/core/repository/LicenceRepository.java index c83b5c1..dffb93d 100644 --- a/as-talange-core/src/main/java/com/astalange/core/repository/LicenceRepository.java +++ b/as-talange-core/src/main/java/com/astalange/core/repository/LicenceRepository.java @@ -14,6 +14,10 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor; public interface LicenceRepository extends JpaRepository, JpaSpecificationExecutor { List findByAdherentId(Long adherentId); + java.util.Optional findByAdherentIdAndSaison(Long adherentId, com.astalange.core.entity.Saison saison); + + List findBySaison(com.astalange.core.entity.Saison saison); + List findBySaisonAndCategorie(com.astalange.core.entity.Saison saison, com.astalange.core.entity.Categorie categorie); long countByEtat(String etat); diff --git a/as-talange-core/src/main/java/com/astalange/core/service/SportEasyEmailService.java b/as-talange-core/src/main/java/com/astalange/core/service/SportEasyEmailService.java new file mode 100644 index 0000000..2892855 --- /dev/null +++ b/as-talange-core/src/main/java/com/astalange/core/service/SportEasyEmailService.java @@ -0,0 +1,227 @@ +package com.astalange.core.service; + +import com.astalange.core.entity.Licence; +import com.astalange.core.entity.Saison; +import com.astalange.core.repository.LicenceRepository; +import com.astalange.core.repository.SaisonRepository; +import jakarta.mail.internet.MimeMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +public class SportEasyEmailService { + + private static final Logger log = LoggerFactory.getLogger(SportEasyEmailService.class); + + private final LicenceRepository licenceRepository; + private final SaisonRepository saisonRepository; + private final JavaMailSender mailSender; + + @Value("${sporteasy.base-url:https://www.sporteasy.net/join/}") + private String sportEasyBaseUrl; + + @Value("${spring.mail.username:noreply@as-talange.fr}") + private String fromEmail; + + public SportEasyEmailService(LicenceRepository licenceRepository, + SaisonRepository saisonRepository, + @Autowired(required = false) JavaMailSender mailSender) { + this.licenceRepository = licenceRepository; + this.saisonRepository = saisonRepository; + this.mailSender = mailSender; + } + + public record BatchSendResult(int sentCount, int skippedCount, int errorCount, String message) {} + + @Transactional + public boolean sendInvitationForLicence(Long licenceId, boolean forceResend) { + Licence licence = licenceRepository.findById(licenceId) + .orElseThrow(() -> new IllegalArgumentException("Licence introuvable avec l'ID: " + licenceId)); + + if (licence.isRenouvellement()) { + log.info("Invitation SportEasy non envoyée pour la licence ID {} : il s'agit d'un renouvellement.", licenceId); + return false; + } + + if (!forceResend && Boolean.TRUE.equals(licence.getSportEasyEmailSent())) { + log.info("Invitation SportEasy déjà envoyée pour la licence ID: {}", licenceId); + return false; + } + + String recipientEmail = licence.getAdherent() != null ? licence.getAdherent().getEmail() : null; + if (recipientEmail == null || recipientEmail.trim().isEmpty()) { + throw new IllegalStateException("L'adhérent n'a pas d'adresse e-mail renseignée."); + } + + String token = (licence.getCategorie() != null && licence.getCategorie().getSportEasyToken() != null) + ? licence.getCategorie().getSportEasyToken().trim() + : ""; + + String inviteUrl; + if (token.startsWith("http://") || token.startsWith("https://")) { + inviteUrl = token; + } else if (!token.isEmpty()) { + inviteUrl = sportEasyBaseUrl.endsWith("/") ? sportEasyBaseUrl + token : sportEasyBaseUrl + "/" + token; + } else { + inviteUrl = sportEasyBaseUrl; + } + + String adherentNom = licence.getAdherent().getPrenom() + " " + licence.getAdherent().getNom(); + String categorieNom = licence.getCategorie() != null ? licence.getCategorie().getNom() : "AS Talange"; + String saisonNom = licence.getSaison() != null ? licence.getSaison().getNom() : ""; + + String subject = "AS Talange - Invitation SportEasy (" + categorieNom + ")"; + String htmlContent = buildEmailHtml(adherentNom, categorieNom, saisonNom, inviteUrl); + + boolean sentSuccessfully = false; + + if (mailSender != null) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + helper.setFrom(fromEmail); + helper.setTo(recipientEmail); + helper.setSubject(subject); + helper.setText(htmlContent, true); + + mailSender.send(message); + sentSuccessfully = true; + log.info("E-mail d'invitation SportEasy envoyé avec succès via SMTP à {} pour {}", recipientEmail, adherentNom); + } catch (Exception e) { + log.warn("Impossible d'envoyer l'e-mail via SMTP pour {}: {}. Bascule en mode simulation / log.", recipientEmail, e.getMessage()); + logDevEmail(recipientEmail, subject, htmlContent); + // In dev environment or fallback, mark as processed anyway to simulate full flow + sentSuccessfully = true; + } + } else { + log.info("Aucun JavaMailSender configuré (Mode DEV). Simulation de l'envoi d'e-mail SportEasy."); + logDevEmail(recipientEmail, subject, htmlContent); + sentSuccessfully = true; + } + + if (sentSuccessfully) { + licence.setSportEasyEmailSent(true); + licence.setSportEasyEmailSentAt(LocalDateTime.now()); + licenceRepository.save(licence); + } + + return sentSuccessfully; + } + + @Transactional + public BatchSendResult sendBatchInvitationsForActiveSaison(Long categoryId) { + Saison activeSaison = saisonRepository.findByEstActiveTrue() + .orElseThrow(() -> new IllegalStateException("Aucune saison active trouvée.")); + + List licences; + if (categoryId != null) { + licences = licenceRepository.findBySaison(activeSaison).stream() + .filter(l -> l.getCategorie() != null && categoryId.equals(l.getCategorie().getId())) + .toList(); + } else { + licences = licenceRepository.findBySaison(activeSaison); + } + + int sentCount = 0; + int skippedCount = 0; + int errorCount = 0; + + for (Licence licence : licences) { + if (licence.isRenouvellement()) { + skippedCount++; + continue; + } + + if (Boolean.TRUE.equals(licence.getSportEasyEmailSent())) { + skippedCount++; + continue; + } + + String email = licence.getAdherent() != null ? licence.getAdherent().getEmail() : null; + if (email == null || email.trim().isEmpty()) { + skippedCount++; + continue; + } + + try { + boolean success = sendInvitationForLicence(licence.getId(), false); + if (success) { + sentCount++; + } else { + skippedCount++; + } + } catch (Exception e) { + log.error("Erreur lors de l'envoi d'invitation SportEasy pour la licence ID {}: {}", licence.getId(), e.getMessage()); + errorCount++; + } + } + + String msg = String.format("%d invitation(s) SportEasy envoyée(s) avec succès. %d ignorée(s) (déjà envoyées, renouvellements ou sans email), %d erreur(s).", + sentCount, skippedCount, errorCount); + + return new BatchSendResult(sentCount, skippedCount, errorCount, msg); + } + + private void logDevEmail(String recipient, String subject, String body) { + log.info("==================== [SIMULATION E-MAIL SPORTEASY] ===================="); + log.info("Destinataire: {}", recipient); + log.info("Sujet: {}", subject); + log.info("Contenu:\n{}", body); + log.info("======================================================================="); + } + + private String buildEmailHtml(String adherentNom, String categorieNom, String saisonNom, String inviteUrl) { + return """ + + + + + + + +
+
+

AS Talange - Invitation SportEasy

+
+
+

Bonjour %s,

+

Afin d'assurer le suivi des entraînements, convocations et matchs pour la saison %s (catégorie %s), le club utilise la plateforme SportEasy.

+

Merci de rejoindre le groupe de votre catégorie en cliquant sur le bouton ci-dessous :

+

+ Rejoindre l'équipe SportEasy +

+

Si vous possédez déjà un compte SportEasy, connectez-vous avec vos identifiants puis rejoignez le groupe. Sinon, créez votre compte gratuitement en quelques secondes.

+

À très vite sur les terrains !
L'équipe de l'AS Talange

+
+ +
+ + + """.formatted( + adherentNom, + saisonNom != null ? saisonNom : "", + categorieNom, + inviteUrl + ); + } +} diff --git a/as-talange-core/src/main/resources/db/migration/V40__add_sporteasy_fields.sql b/as-talange-core/src/main/resources/db/migration/V40__add_sporteasy_fields.sql new file mode 100644 index 0000000..f32e5a1 --- /dev/null +++ b/as-talange-core/src/main/resources/db/migration/V40__add_sporteasy_fields.sql @@ -0,0 +1,3 @@ +ALTER TABLE categorie ADD COLUMN sport_easy_token VARCHAR(255); +ALTER TABLE licence ADD COLUMN sport_easy_email_sent BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE licence ADD COLUMN sport_easy_email_sent_at TIMESTAMP; diff --git a/as-talange-web/src/main/java/com/astalange/web/controller/AdherentController.java b/as-talange-web/src/main/java/com/astalange/web/controller/AdherentController.java index dc462b3..a7e67b5 100644 --- a/as-talange-web/src/main/java/com/astalange/web/controller/AdherentController.java +++ b/as-talange-web/src/main/java/com/astalange/web/controller/AdherentController.java @@ -24,14 +24,16 @@ public class AdherentController { private final com.astalange.core.repository.ModePaiementRepository modePaiementRepository; private final SaisonRepository saisonRepository; private final com.astalange.core.service.CategorieService categorieService; + private final com.astalange.core.service.SportEasyEmailService sportEasyEmailService; - public AdherentController(AdherentRepository adherentRepository, CategorieRepository categorieRepository, com.astalange.core.repository.LicenceRepository licenceRepository, com.astalange.core.repository.ModePaiementRepository modePaiementRepository, SaisonRepository saisonRepository, com.astalange.core.service.CategorieService categorieService) { + public AdherentController(AdherentRepository adherentRepository, CategorieRepository categorieRepository, com.astalange.core.repository.LicenceRepository licenceRepository, com.astalange.core.repository.ModePaiementRepository modePaiementRepository, SaisonRepository saisonRepository, com.astalange.core.service.CategorieService categorieService, com.astalange.core.service.SportEasyEmailService sportEasyEmailService) { this.adherentRepository = adherentRepository; this.categorieRepository = categorieRepository; this.licenceRepository = licenceRepository; this.modePaiementRepository = modePaiementRepository; this.saisonRepository = saisonRepository; this.categorieService = categorieService; + this.sportEasyEmailService = sportEasyEmailService; } @org.springframework.web.bind.annotation.ModelAttribute("equipes") @@ -48,6 +50,7 @@ public class AdherentController { @org.springframework.web.bind.annotation.RequestParam(required = false) String licence, @org.springframework.web.bind.annotation.RequestParam(required = false) String email, @org.springframework.web.bind.annotation.RequestParam(required = false) String paiement, + @org.springframework.web.bind.annotation.RequestParam(required = false) String sporteasy, @org.springframework.web.bind.annotation.RequestParam(required = false, defaultValue = "nom") String sortField, @org.springframework.web.bind.annotation.RequestParam(required = false, defaultValue = "asc") String sortDirection, @org.springframework.web.bind.annotation.RequestParam(defaultValue = "0") int page, @@ -114,6 +117,21 @@ public class AdherentController { }).collect(java.util.stream.Collectors.toList()); model.addAttribute("paiementFilter", paiement.trim()); } + if (sporteasy != null && !sporteasy.trim().isEmpty()) { + adherents = adherents.stream().filter(a -> { + Licence lic = a.getLicenceActuelle(); + if (lic == null) return false; + if ("NON_INVITE".equalsIgnoreCase(sporteasy)) { + return !lic.isRenouvellement() && !Boolean.TRUE.equals(lic.getSportEasyEmailSent()); + } else if ("INVITE".equalsIgnoreCase(sporteasy)) { + return !lic.isRenouvellement() && Boolean.TRUE.equals(lic.getSportEasyEmailSent()); + } else if ("RENOUVELLEMENT".equalsIgnoreCase(sporteasy)) { + return lic.isRenouvellement(); + } + return true; + }).collect(java.util.stream.Collectors.toList()); + model.addAttribute("sporteasyFilter", sporteasy.trim()); + } // 3. Sort java.util.Comparator comparator = (a1, a2) -> 0; @@ -284,4 +302,57 @@ public class AdherentController { adherentRepository.delete(adherent); return "redirect:/adherents"; } + + @org.springframework.web.bind.annotation.PostMapping("/{id}/sporteasy-invite") + public String sendSportEasyInvite( + @org.springframework.web.bind.annotation.PathVariable Long id, + @org.springframework.web.bind.annotation.RequestParam(required = false, defaultValue = "false") boolean force, + org.springframework.web.servlet.mvc.support.RedirectAttributes redirectAttributes) { + + Saison activeSaison = saisonRepository.findByEstActiveTrue().orElse(null); + if (activeSaison == null) { + redirectAttributes.addFlashAttribute("errorMessage", "Erreur : Aucune saison active trouvée."); + return "redirect:/adherents"; + } + + Licence licence = licenceRepository.findByAdherentIdAndSaison(id, activeSaison).orElse(null); + if (licence == null) { + redirectAttributes.addFlashAttribute("errorMessage", "Cet adhérent n'a pas de licence pour la saison active."); + return "redirect:/adherents"; + } + + if (licence.isRenouvellement()) { + redirectAttributes.addFlashAttribute("infoMessage", "Les invitations SportEasy sont réservées aux nouveaux adhérents (nouvelle licence)."); + return "redirect:/adherents"; + } + + try { + boolean success = sportEasyEmailService.sendInvitationForLicence(licence.getId(), force); + if (success) { + redirectAttributes.addFlashAttribute("successMessage", "L'invitation SportEasy a été envoyée avec succès à l'adhérent."); + } else { + redirectAttributes.addFlashAttribute("infoMessage", "L'invitation SportEasy avait déjà été envoyée à cet adhérent."); + } + } catch (Exception e) { + redirectAttributes.addFlashAttribute("errorMessage", "Erreur lors de l'envoi de l'invitation : " + e.getMessage()); + } + + return "redirect:/adherents"; + } + + @org.springframework.web.bind.annotation.PostMapping("/sporteasy-invite-batch") + public String sendSportEasyInviteBatch( + @org.springframework.web.bind.annotation.RequestParam(required = false) Long categoryId, + org.springframework.web.servlet.mvc.support.RedirectAttributes redirectAttributes) { + + try { + com.astalange.core.service.SportEasyEmailService.BatchSendResult result = + sportEasyEmailService.sendBatchInvitationsForActiveSaison(categoryId); + redirectAttributes.addFlashAttribute("successMessage", result.message()); + } catch (Exception e) { + redirectAttributes.addFlashAttribute("errorMessage", "Erreur lors de l'envoi des invitations SportEasy : " + e.getMessage()); + } + + return "redirect:/adherents"; + } } diff --git a/as-talange-web/src/main/resources/application.yml b/as-talange-web/src/main/resources/application.yml index df16a76..c67ceb8 100644 --- a/as-talange-web/src/main/resources/application.yml +++ b/as-talange-web/src/main/resources/application.yml @@ -17,9 +17,21 @@ spring: enabled: true locations: classpath:db/migration validate-on-migrate: false + mail: + host: ${SPRING_MAIL_HOST:localhost} + port: ${SPRING_MAIL_PORT:1025} + properties: + mail: + smtp: + auth: false + starttls: + enable: false server: port: 8080 app: version: @project.version@ + +sporteasy: + base-url: https://www.sporteasy.net/join/ diff --git a/as-talange-web/src/main/resources/templates/adherents/list.html b/as-talange-web/src/main/resources/templates/adherents/list.html index 1e0adf6..c740961 100644 --- a/as-talange-web/src/main/resources/templates/adherents/list.html +++ b/as-talange-web/src/main/resources/templates/adherents/list.html @@ -29,9 +29,28 @@

Liste des Adhérents

- - + Nouvel Adhérent - +
+
+ +
+ + + Nouvel Adhérent + +
+
+ + +
+ +
+
+ +
+
+
@@ -109,6 +128,7 @@
Paiement + SportEasy Actions @@ -141,12 +161,20 @@ + + + - Aucun adhérent enregistré. + Aucun adhérent enregistré. @@ -193,6 +221,37 @@ + + +
+
+ Renouvellement +
+
+
+ + 🟢 Invité + +
+ + +
+
+
+ + ⚪ Non invité + +
+ +
+
+
+
+
+ - +
+ Voir / Modifier
diff --git a/as-talange-web/src/main/resources/templates/parametrage/categories_form.html b/as-talange-web/src/main/resources/templates/parametrage/categories_form.html index 793ff62..a7d23ea 100644 --- a/as-talange-web/src/main/resources/templates/parametrage/categories_form.html +++ b/as-talange-web/src/main/resources/templates/parametrage/categories_form.html @@ -64,6 +64,13 @@ +
+ + +

Saisissez le token du groupe SportEasy ou l'URL complète d'invitation.

+
+
diff --git a/docker-compose.yml b/docker-compose.yml index 71b89be..7d32999 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,14 @@ services: - db restart: always + mailpit: + image: axllent/mailpit + container_name: astalange_mailpit + ports: + - "1025:1025" + - "8025:8025" + restart: always + app: build: context: . @@ -35,10 +43,13 @@ services: - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/astalange - SPRING_DATASOURCE_USERNAME=${POSTGRES_USER:-myuser} - SPRING_DATASOURCE_PASSWORD=${POSTGRES_PASSWORD:-mypassword} - - CAPTCHA_SITEKEY=${CAPTCHA_SITEKEY} - - CAPTCHA_SECRET=${CAPTCHA_SECRET} + - SPRING_MAIL_HOST=mailpit + - SPRING_MAIL_PORT=1025 + - CAPTCHA_SITEKEY=${CAPTCHA_SITEKEY:-} + - CAPTCHA_SECRET=${CAPTCHA_SECRET:-} depends_on: - db + - mailpit restart: always volumes: