feat: ajout du système d'invitations SportEasy et suivi des emails
AS Talange CI/CD Pipeline / Build & Run Unit Tests (push) Successful in 4m47s
AS Talange CI/CD Pipeline / Deploy to Test Environment (push) Successful in 6m46s

This commit is contained in:
2026-08-05 15:15:37 +02:00
parent 63c71d504e
commit 2b2025a78f
11 changed files with 432 additions and 7 deletions
@@ -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<Adherent> 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";
}
}
@@ -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/
@@ -29,9 +29,28 @@
<div class="flex-1 overflow-auto p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-xl font-bold text-gray-900">Liste des Adhérents</h3>
<a th:href="@{/adherents/new}" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors inline-block">
+ Nouvel Adhérent
</a>
<div class="flex items-center space-x-3">
<form th:action="@{/adherents/sporteasy-invite-batch}" method="post" onsubmit="return confirm('Envoyer les invitations SportEasy à tous les adhérents non invités de la saison active ?');">
<button type="submit" class="bg-indigo-50 text-indigo-700 hover:bg-indigo-100 border border-indigo-200 px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center space-x-1.5 shadow-2xs">
<svg class="w-4 h-4 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
<span>Inviter sur SportEasy</span>
</button>
</form>
<a th:href="@{/adherents/new}" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors inline-block">
+ Nouvel Adhérent
</a>
</div>
</div>
<!-- Messages d'information -->
<div th:if="${successMessage}" class="mb-4 p-4 bg-green-50 border-l-4 border-green-500 rounded-lg text-sm text-green-800 font-medium">
<span th:text="${successMessage}"></span>
</div>
<div th:if="${errorMessage}" class="mb-4 p-4 bg-red-50 border-l-4 border-red-500 rounded-lg text-sm text-red-800 font-medium">
<span th:text="${errorMessage}"></span>
</div>
<div th:if="${infoMessage}" class="mb-4 p-4 bg-blue-50 border-l-4 border-blue-500 rounded-lg text-sm text-blue-800 font-medium">
<span th:text="${infoMessage}"></span>
</div>
<!-- Table Container with Wrapper-level HTMX triggers for filters -->
@@ -109,6 +128,7 @@
</div>
</th>
<th class="py-3 px-6 font-medium text-center">Paiement</th>
<th class="py-3 px-4 font-medium text-center">SportEasy</th>
<th class="py-3 px-6 font-medium text-right">Actions</th>
</tr>
<!-- Filter Row -->
@@ -141,12 +161,20 @@
<option value="AUCUNE" th:selected="${paiementFilter == 'AUCUNE'}">Aucune licence</option>
</select>
</td>
<td class="py-2 px-3">
<select id="filterSportEasy" name="sporteasy" class="w-full border border-gray-300 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 focus:outline-none">
<option value="">Tous</option>
<option value="NON_INVITE" th:selected="${sporteasyFilter == 'NON_INVITE'}">Non invité</option>
<option value="INVITE" th:selected="${sporteasyFilter == 'INVITE'}">Invité</option>
<option value="RENOUVELLEMENT" th:selected="${sporteasyFilter == 'RENOUVELLEMENT'}">Renouvellement</option>
</select>
</td>
<td class="py-2 px-3"></td>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 text-sm">
<tr th:if="${#lists.isEmpty(adherents)}">
<td colspan="7" class="py-8 text-center text-gray-500">Aucun adhérent enregistré.</td>
<td colspan="8" class="py-8 text-center text-gray-500">Aucun adhérent enregistré.</td>
</tr>
<tr th:each="adherent : ${adherents}" class="hover:bg-gray-50 transition-colors adherent-row">
<td class="py-4 px-6 font-medium text-gray-900">
@@ -193,6 +221,37 @@
</div>
</div>
</td>
<!-- SportEasy Status Column -->
<td class="py-3 px-4 text-center">
<div th:if="${adherent.getLicenceActuelle() != null}">
<div th:if="${adherent.getLicenceActuelle().isRenouvellement()}" class="text-xs text-gray-400 italic">
Renouvellement
</div>
<div th:unless="${adherent.getLicenceActuelle().isRenouvellement()}">
<div th:if="${adherent.getLicenceActuelle().getSportEasyEmailSent()}" class="flex flex-col items-center space-y-1">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 border border-emerald-200"
th:title="${adherent.getLicenceActuelle().getSportEasyEmailSentAt() != null ? 'Envoyé le ' + #temporals.format(adherent.getLicenceActuelle().getSportEasyEmailSentAt(), 'dd/MM/yyyy HH:mm') : 'Invité'}">
🟢 Invité
</span>
<form th:action="@{/adherents/{id}/sporteasy-invite(id=${adherent.id})}" method="post">
<input type="hidden" name="force" value="true" />
<button type="submit" class="text-[10px] text-gray-500 hover:text-blue-600 underline">Renvoyer</button>
</form>
</div>
<div th:if="${!adherent.getLicenceActuelle().getSportEasyEmailSent()}" class="flex flex-col items-center space-y-1">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 border border-gray-200">
⚪ Non invité
</span>
<form th:action="@{/adherents/{id}/sporteasy-invite(id=${adherent.id})}" method="post">
<button type="submit" class="text-xs font-medium text-indigo-600 hover:text-indigo-800 bg-indigo-50 hover:bg-indigo-100 px-2.5 py-1 rounded-md border border-indigo-200 transition-colors">Inviter</button>
</form>
</div>
</div>
</div>
<div th:if="${adherent.getLicenceActuelle() == null}" class="text-xs text-gray-400 italic">
-
</div>
</td>
<td class="py-4 px-6 text-right flex justify-end space-x-3 items-center">
<a th:href="@{/adherents/{id}/edit(id=${adherent.id})}" class="text-indigo-600 hover:text-indigo-900 font-medium bg-indigo-50 px-3 py-1 rounded-lg">Voir / Modifier</a>
<form th:action="@{/adherents/{id}/delete(id=${adherent.id})}" method="post" onsubmit="return confirm('Supprimer cet adhérent ?');">
@@ -64,6 +64,13 @@
</div>
</div>
<div>
<label for="sportEasyToken" class="block text-sm font-medium text-gray-700 mb-1">Token ou Lien d'invitation SportEasy</label>
<input type="text" id="sportEasyToken" th:field="*{sportEasyToken}" placeholder="ex: https://www.sporteasy.net/join/ABC1234 ou juste ABC1234"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
<p class="text-xs text-gray-500 mt-1">Saisissez le token du groupe SportEasy ou l'URL complète d'invitation.</p>
</div>
<div class="pt-4 border-t border-gray-100">
<label class="block text-sm font-medium text-gray-700 mb-2">Équipements liés à la catégorie</label>