Journal des modifications
Les évolutions de Cohez.io, de la plus récente à la plus ancienne.
173 versions publiées.
Versions 31 à 40 sur 173
0.25.1.3 — 2026-09-17
Corrections
- Trouvaille de la revue finale de la branche
draft-ttl-no-fallback: le formulaire de campagne affichait encore un placeholder trompeur (« Par défaut : 7 jours ») laissant croire à un délai de repli qui vient d'être supprimé (0.25.1.0-0.25.1.2). Placeholder et texte d'aide corrigés (Create.vue/Edit.vue) pour indiquer qu'un champ vide signifie « aucune expiration automatique ». Refs mortes àeffectiveDraftTtlDays()/config/registrations.phpet au warning devenu obsolète nettoyées dansDocs/Features/Reservation-Quota-Lifecycle.mdetReservation-Deadlines-Howto.md.
0.25.1.2 — 2026-09-17
Modifications
- Une campagne sans délai d'expiration des réservations en attente configuré (
draft_ttl_daysvide) ne pose plus aucunedraft_expires_atsur ses inscriptions Draft, au lieu de retomber silencieusement sur un défaut global de 7 jours.Campaign::effectiveDraftTtlDays()(toujours un entier, avec repliconfig('registrations.draft_ttl_days')) est remplacée parCampaign::draftExpiresAt(): ?CarbonImmutable, qui renvoienullen l'absence de configuration explicite.BackfillDraftDeadlinesCommandignore désormais les Draft dont la campagne n'a pas dedraft_ttl_days, au lieu de leur en inventer un. La configuration globaleREGISTRATION_DRAFT_TTL_DAYS/config/registrations.phpest supprimée, devenue sans consommateur.
0.25.1.1 — 2026-09-17
Corrections
- Deux créations ou mises à jour de fiche salarié concurrentes pouvaient contourner les gardes anti-doublon (#287, 2026-09-17).
EmployeeController::store()/update()verrouillent désormais laPersonvisée (Employee::lockPersonForActiveConflictCheck()) et le compte utilisateur ciblé (lockForUpdate()+ revérification) pendant la fenêtre check-then-write, en complément de la nouvelle contrainte uniqueusers_employee_id_uniqueposée en filet de sécurité côté base. - La recherche de personne ou de compte par e-mail ratait les adresses accentuées en majuscules (#287, 2026-09-17).
EmployeePersonResolver::resolve()etEmployeeController::linkStaffAccount()appliquaientmb_strtolower()(Unicode-aware) côté PHP à l'e-mail recherché maisLOWER()(ASCII-only sous PostgreSQL) côté SQL à la colonne — sur un e-mail commePÉREZ@EXAMPLE.COM, les deux replis divergeaient et la recherche ratait la ligne existante, créant un doublon silencieux.LOWER()s'applique désormais aux deux opérandes, côté SGBD.
0.25.1.0 — 2026-09-16
Ajouts
- Le sélecteur de coach d'un cours distingue désormais un salarié d'un adhérent, et se cherche au clavier au lieu de se parcourir dans une liste déroulante (#289, 2026-09-16).
CoachPickerremplace le<select>par une combobox recherchable, filtrée côté client, qui classe les personnes assignables en trois sections — salarié actif, adhérent, autre — car deux homonymes de statuts différents étaient jusqu'ici impossibles à départager. Une fois la sélection faite, un rappel discret sous le champ garde trace de la catégorie retenue, sans jamais affirmer à tort qu'une personne n'est « ni salariée ni adhérente ».CourseLabelBadgeetCourseLabelDotréduisent la couleur libre d'un label de cours à un simple repère visuel plutôt qu'à un porteur de texte : mesurée sur les 13 labels existants, 7 des 8 couleurs distinctes tombaient sous le contraste de lecture minimal en thème clair, 4 sur 8 en thème sombre. - Un cours sans proposition (offre / tarif) liée explique désormais où en créer une, au lieu de faire disparaître la carte qui l'affiche (#289, 2026-09-16). La carte des propositions liées disparaissait entièrement quand le cours n'en avait aucune ; elle reste maintenant affichée avec un état vide qui indique le chemin — créer la proposition depuis une campagne, dans une rubrique existante — via un bouton vers la liste des campagnes de la saison, sous la même permission
view_settingsque la page courante. PRODUCT.mdest ajouté au dépôt (#289, 2026-09-16). Contexte produit durable — audiences, raison d'être, positionnement, contraintes d'exploitation, engagements de marque, principes produit — destiné à servir de référence aux travaux de conception à venir.
Corrections
- La page des salariés sépare enfin les fiches salariés des comptes du back-office qui n'en ont pas, et le compteur de ces derniers reste juste sous recherche et filtre (#289, 2026-09-16). Les deux populations étaient auparavant présentées comme une seule liste, sans dire ce qu'il fallait faire de la seconde ; chacune a désormais sa section nommée et son propre état vide. Le compteur affiché s'appuie sur une requête de base partagée avec la liste, pour ne plus jamais diverger d'elle, et signale le plafond de 500 résultats plutôt que de le taire.
- La liste des cours d'une saison et le sélecteur de personnes assignables à un cours restent rapides quel que soit l'effectif du tenant (#289, 2026-09-16). La liste passait tous les cours de la saison en une seule requête et calculait les effectifs inscrits sur leur totalité ; elle est désormais paginée à 20 cours, comme la liste des salariés. Le calcul des personnes assignables (jusqu'à environ 700 attendues chez Cholet Water-Polo) n'est plus exécuté à chaque affichage de la page mais seulement à l'ouverture de la modale « Modifier le cours », qui reste utilisable pendant son chargement.
- La configuration d'un cours ne peut plus renvoyer une page d'erreur blanche pour un dossier salarié incomplet, les formulaires de cours se soumettent désormais à la touche Entrée, le bouton de création ne s'affiche plus à qui n'a pas le droit de créer, et la liste des cours redirige vers la dernière page valide au lieu d'afficher un écran vide après suppression (#289, 2026-09-16). Une personne dont le dossier salarié avait perdu son rattachement (fiche supprimée) faisait planter le calcul des personnes assignables. Les dialogues de cours (création, modification, créneau) purgent désormais leur saisie et leurs erreurs sur toute fermeture — croix, touche Échap ou clic hors de la boîte —, pas seulement sur le bouton « Annuler » ; leur pied de page passe à l'intérieur du
<form>avec un boutontype="submit", car il soumettait auparavant par un simple@clickhors formulaire, insensible à la touche Entrée.CourseController::index()expose désormaiscanCreate, commeEmployeeController, pour ne plus afficher un déclencheur qui échouait ensuite en 403. Une page de liste devenue hors bornes après suppression de cours affichait silencieusement « Aucun cours pour cette saison » ; elle redirige maintenant vers la dernière page valide. - La hiérarchie des titres de page est rétablie sur l'ensemble de l'application (#289, 2026-09-16). Aucune des pages de l'espace connecté ne portait de véritable titre de niveau 1, et les titres de carte démarraient directement en niveau 3 sans parent (RGAA 9.1 / WCAG 1.3.1, 2.4.6) : un lecteur d'écran ne pouvait pas se repérer dans le plan d'une page par ses titres.
- L'indicateur de focus clavier redevient visible et suffisamment contrasté sur les champs, boutons et menus — à l'exception connue du sélecteur de code 2FA —, et le mouvement réduit demandé par l'utilisateur est désormais respecté (#289, 2026-09-16) (RGAA 10.7 / WCAG 1.4.11 et 2.4.7). L'ancien anneau semi-transparent tombait sous le seuil de contraste requis et restait invisible en mode contraste élevé de Windows ; il est remplacé par un contour opaque. De nombreux composants neutralisaient cet anneau localement sans le savoir : un simple
outline-noneprioritaire en CSS suffit à l'annuler, une trappe corrigée composant par composant au fil de cette branche. Le réglage système « préférence pour un mouvement réduit » est maintenant honoré globalement ; l'indicateur de chargement (spinner), qui se figeait après un seul tour sous ce réglage et laissait croire à un plantage, continue de tourner lentement. - Les messages de confirmation et d'erreur (« toasts ») sont désormais annoncés aux lecteurs d'écran, et restent affichés le temps de les lire (#289, 2026-09-16) (RGAA 7.5 / WCAG 4.1.3). Le message apparaissait uniquement à l'écran ; il est maintenant porté par une région vocale dédiée. Sa durée d'affichage passe de 4 secondes fixes à 6 (succès) ou 9 (erreur), et se met en pause tant que la souris ou le clavier reste sur le toast.
- Le lien d'évitement vers le contenu principal est ajouté, et la cible tactile du bouton qui replie la barre latérale est agrandie à 44 px sous mobile (#289, 2026-09-16) (RGAA 12.7). Le lien « Aller au contenu » est le premier élément focusable de la mise en page et amène le focus sur le
<main>réel de la page ; il portait lui-même unfocus:outline-nonelocal qui annulait l'indicateur de focus global, désormais retiré. - Les boîtes de dialogue ne débordent plus de l'écran sans pouvoir défiler, et le contraste des bordures de champ atteint le seuil requis (#289, 2026-09-16). Une boîte plus haute que la fenêtre rendait son bouton de validation inatteignable en paysage téléphone ou à fort zoom (WCAG 1.4.10) ; elle est désormais bornée en hauteur avec défilement interne. La bordure des champs de formulaire, mesurée jusqu'à 1,49:1 sur certains fonds, passe à plus de 3:1 (WCAG 1.4.11).
- Plusieurs écrans (dialogue de créneau, en-tête et liste des salariés) ne débordent plus horizontalement à 320 px de large (#289, 2026-09-16) (WCAG 1.4.10 Reflow). Le dialogue « Nouveau créneau » imposait deux colonnes fixes qui écrasaient champs et libellés à 320 px ; il adopte désormais le motif déjà en place ailleurs — une colonne par défaut, deux au-delà du point de rupture
sm. L'en-tête et les lignes de la liste des salariés recevaient le même traitement incomplet ; les deux sections s'empilent désormais à l'identique en largeur réduite.
Notes techniques
- L'indicateur de focus de
InputOTPSlot(écrans de saisie du code 2FA) reste non conforme, et ne peut pas être corrigé côté application. L'élément réellement focusé est un<input>caché posé par la librairie sous-jacente, dont l'outlineest fixé en ligne : hors de portée d'une règle CSS globale ou locale. MeetingReportEditor.vueest volontairement resté hors du retrait desoutline-none. Son éditeur est uncontenteditabledont la boîte de focus ne coïncide pas avec l'anneau porté par le conteneur : retireroutline-noney aurait ajouté un second anneau visible autour du premier.- Deux échecs de contraste préexistants restent hors périmètre : le survol du variant
outlinedu bouton et le texte de substitution (« placeholder ») des champs restent sous 4,5:1 sur certains fonds (WCAG 1.4.3), constatés en corrigeant la bordure des champs mais non traités ici.
0.25.0.2 — 2026-09-16
Corrections
- Un membre déjà inscrit à une campagne via un autre panier provoquait une erreur serveur 500 au lieu d'un refus explicite (2026-09-16). L'index partiel PostgreSQL
registrations_member_campaign_uniqueporte sur(member_id, campaign_id)tous paniers confondus, maisCartMutationService::addMember()ne gardait que le panier courant : un membre déjà inscrit à la même campagne via un AUTRE panier passait la garde applicative et lecreate()violait l'index (SQLSTATE 23505non rattrapé). La garde anti-doublon est élargie au grain campagne — une seule requête, prédicat aligné surRegistrationStatus::terminalStatuses(), une inscription annulée continuant de libérer le couple — et uncatch (UniqueConstraintViolationException)entoure désormais lecreate()comme filet anti-course, deux ajouts concurrents sur deux paniers distincts pouvant tous deux passer la garde puisquelockForUpdate()ne sérialise que la ligne Cart. - Le sélecteur de membres du panier proposait encore un membre que la garde anti-doublon refusait ensuite (2026-09-16).
PanierController::createCartMember()filtrait les membres proposés à l'échelle du panier plutôt que de la campagne ; il utilise désormais une sous-requête corréléewhereNotExists(au lieu depluck()+whereNotIn(), pour rester performant sur les grosses campagnes) au travers du helper partagéactiveOnCampaignSubquery(), également appliqué àbuildPanierPayload()/availableMembersqui portait le même défaut. UnorderBy('created_at')rend en outre le message d'erreur déterministe en cas de conflit concurrent. - Le message d'erreur du conflit anti-doublon exposait l'UUID brut du panier concurrent (2026-09-16).
CartMutationServiceréutilise désormaisPanierListService::ref()pour afficher la référence courte du panier concurrent (ex.A1B2C3D4), cohérente avec le reste de l'UI panier, au lieu de l'identifiant interne. Réf. PR #291.
0.25.0.1 — 2026-09-04
Ajouts
- Chaque méthode de paiement affiche désormais des instructions de règlement, identiques sur les trois surfaces qui les rendent (#286, 2026-09-04).
PaymentInstructionsResolverremplaceBankTransferInstructionsResolver— supprimé sans résidu — et produit unPaymentInstructionsSetde 0 à 2 blocs (acompte / reste à régler) à chaînes déjà rendues ; l'e-mail de confirmation de panier, l'e-mail de relance et l'écran de succès public se contentent d'afficher : trois partials Blade (HTML, texte, markdown) et le composant VuePaymentInstructions.vue. Auparavant, seul le virement produisait des instructions ; chèque, espèces, carte hors ligne, HelloAsso et moyen personnalisé n'en affichaient aucune. - La
descriptiond'un moyen de paiement personnalisé survit désormais au submit du formulaire d'inscription publique (#286, 2026-09-04). Elle est portée par le bloc d'instructions rendu sur les trois surfaces au lieu de disparaître une fois l'inscription passée.
Corrections
- La carte bancaire hors ligne annonçait un règlement en ligne (#286, 2026-09-04).
PaymentInstructionsResolver::actionText()dégroupe désormaisPaymentMethod::CreditCard(« Règlement par carte bancaire sur place, auprès de l'association. ») dePaymentMethod::HelloAsso(paiement en ligne), auparavant confondues sous la même phrase. - L'ordre du chèque se replie sur le nom du tenant (#286, 2026-09-04).
tenant_payment_methodsne porte pas encore de champ dédié au destinataire ;PaymentInstructionsResolver::checkPayee()rendtenant->name. Le paramètre$row, inutilisé pour l'instant, est conservé pour y brancher ce champ sans changer les appelants. - Les deux canaux de paiement d'une règle d'acompte sont désormais distingués (#286, 2026-09-04).
CampaignDepositRule::settlement_methodest le canal de l'acompte (« Moyen de règlement de l'acompte » côté admin), tandis que la méthode choisie par l'adhérent — qui déclenche la règle viaapplicable_payment_methods— reste celle du reste à régler. Quand les deux blocs sont rendus, chacun porte son étiquette de portée (« Pour l'acompte », puis « Pour le solde ») : les deux e-mails alignent les blocs sans séparateur visuel, et un second bloc nu s'y lit comme une seconde consigne d'acompte. L'étiquetage du bloc du reste est décidé après filtrage : resté seul, ce bloc couvre tout le montant réclamé et non un reliquat, donc il ne porte aucune étiquette — comme la ligne « Moyen de paiement : … » qu'affichait l'e-mail auparavant. Le docblock deCampaignDepositRule, qui décrivaitsettlement_methodcomme la méthode du solde, est corrigé. - L'e-mail de relance annonçait un total de panier que le bloc « Pour l'acompte » contredisait en silence (#286, 2026-09-08). Sous règle d'acompte, la relance affichait le solde entier du panier puis, juste en dessous, un bloc d'instructions étiqueté « Pour l'acompte » ne portant aucun chiffre : le lecteur rattachait le total au canal de l'acompte et pouvait virer l'intégralité sur le mauvais canal.
CartPaymentReminderMailcalcule désormais le montant réellement exigible — par ligne, l'acompte restant (deposit_amount_due - total_paid) tant qu'il est strictement positif et inférieur au solde, sinon le solde — et le mail ajoute « À régler maintenant (acompte) : … » suivi du reliquat, uniquement quand cette somme est strictement inférieure au total. Garde d'homogénéité : la ligne n'apparaît que si toutes les inscriptions relancées sont en contexte d'acompte. Les blocs d'instructions sont résolus sur la seule ligne représentative ; sur un panier mixte, une somme étiquetée « acompte » ne correspondrait à aucun des canaux affichés, donc le total reste seul et le comportement est inchangé. Correctif du montant réclamé côté relance, hors périmètre initial des instructions de paiement de cette PR. - La ligne représentative d'un panier pouvait être une ligne gratuite dont la méthode de paiement avait été effacée, faisant partir la confirmation sans instructions (#286, 2026-09-04).
whereInsur des UUID ne garantit aucun ordre : le panier est désormais trié parmember_index, et la ligne représentative choisie par le nouveau helperCartRepresentative::pick()— la première ligne au montant dû strictement positif, ou la première ligne à défaut d'un panier entièrement gratuit. - Le mail de confirmation d'un panier réglé (0 €, ou soldé) ne porte plus la ligne « Moyen de paiement : — » (#286, 2026-09-04). Un panier
Validatedet soldé (hasOutstandingBalance()àfalse) fait court-circuiterPaymentInstructionsResolver::forCart()enPaymentInstructionsSet::empty(): aucun bloc d'instructions n'est rendu, volontairement — pas de paiement dû, pas d'instructions. Un panierValidatedmais encore partiellement payé continue de produire le bloc de son reste à régler. À l'inverse, un panier encore dû dont aucune méthode ne se résout reçoit un bloc générique (« Moyen de paiement : — » et phrase de règlement générique), comme avant : le jeu vide signifie donc « plus rien à régler », jamais « méthode inconnue ».FreeCartSettlementTest > I-1est aligné sur ce comportement. - Un moyen de paiement dépublié pouvait voir son RIB ressortir dans les mails et sur l'écran de succès public (#286, 2026-09-08). Les deux requêtes
TenantPaymentMethoddePaymentInstructionsResolver::resolveMethodKey()ne filtraient niis_enabledniis_public; elles passent désormais par le scopepublic(), le même que le reste de la surface publique. Le canal le plus exposé estsettlement_method: il est choisi par l'admin dansCampaignDepositRule::eligiblePaymentMethods(), qui rend tous les enums sauf HelloAsso sans consulter la configuration du tenant — il ne passait donc par aucun filtre de publication ailleurs. Ligne absente ⇒ pas d'échec : le libellé de la méthode reste rendu, seuls les détails (IBAN, BIC…) disparaissent au profit de la phrase générique. AucunorderByn'est ajouté : l'index partieluq_tenant_std_paymentgarantit au plus une ligne par (tenant, méthode standard). - Une panne de résolution retirait toute la section paiement des mails au lieu du seul bloc de détails (#286, 2026-09-08). Le
catch (Throwable)dePaymentInstructionsResolver::forCart()rendaitPaymentInstructionsSet::empty(), qui signifie « plus rien à régler » pour les trois surfaces — la confirmation et la relance annonçaient donc l'absence de paiement dû sur un panier encore dû. Avant ce résolveur,PublicCartConfirmationMailcalculait sa ligne « Moyen de paiement » de son côté et une panne ne coûtait que le bloc RIB. Lecatchrenvoie désormais le bloc générique tant qu'un solde subsiste ; le jeu vide n'est conservé que si le panier est soldé — ou si la lecture du solde échoue elle aussi, seul cas où il reste l'état sûr.PublicCartConfirmationMailcharge en outrepaymentsexplicitement :$settledNoticeappellehasOutstandingBalance(), et ne dépendre que du chargement en effet de bord du résolveur rendait cette lecture sensible à l'ordre des appels.
0.25.0.0 — 2026-09-04
Ajouts
- Le registre des salariés existe, et un salarié peut participer à une réunion sans qu'on lui fabrique une fiche d'adhérent (#277, 2026-09-02). Nouvelle table
employeeset pivotemployee_employee_types— un salarié cumule plusieurs types —, enumsEmployeeTypeetEmployeeStatus,EmployeePolicy, pages InertiaEmployees/Index|Show|Form.users.employee_id(nullable) relie un compte à sa fiche RH ;meeting_participants.person_idajoute un quatrième mode de participation, mutuellement exclusif des trois autres. Un rôlecoachet une permissionview_own_coursesouvrent un calendrier en lecture seule, vers lequel la connexion redirige directement : il n'expose que les métadonnées des cours, aucune donnée personnelle d'adhérent. - Les comptes
user_type = 'employee'sans fiche RH sont listés sur le registre, en mode dégradé (#280, 2026-09-02).EmployeeController::index()exposeunlinkedStaff— les comptes du tenant dontemployee_idest nul —, rendus avec un badge « Sans fiche RH » et un bouton « Créer la fiche ». Aucune donnée n'est écrite et aucun backfill n'est lancé : c'est un révélateur d'un écart existant, pas une migration. Le formulaire ne pré-remplitperson_idque si un seulpersons.emailcorrespond (comparaison en minuscules, en une requête, sans N+1). La section disparaît dès qu'un filtre de statut est posé, et au-delà de la première page, où elle n'aurait plus de sens. - Kit de formation EPGV 44 et profils de règlement du jeu de démonstration (#273, 2026-08-27).
Docs/Formations/EPGV-44/reçoit un déroulé animateur, des fiches pratiques et un support projeté. Côté données,EpgvSeederrépartit les inscriptions sur des profils de règlement contrastés — 7 soldées, 2 partielles, 1 chèque annoncé mais non encaissé, 2 sans aucun paiement — pour que le tableau de bord de démonstration affiche 3 impayés plutôt qu'un jeu uniformément soldé, qui ne montrait aucun des écrans de relance.createPaymentReceived()etcreatePaymentPending()acceptent désormais des paramètres optionnels. - Comptes et réunion de formation du tenant
demo-epgv-44(#275, 2026-08-27). Six comptesadmin(mot de passepassword), un helpercreateEmployeeUser(), et la réunion « Présentation et formation Cohézio » avec ses six participants : la démonstration part d'un tenant peuplé au lieu d'écrans vides.
Corrections
- Les compteurs du tableau de bord ignoraient le statut des inscriptions (#278, 2026-09-01).
seasonRegistrations,unpaidCountetunpaidMemberspassent par deux helpers privés qui appliquentwhereNotIn('status', RegistrationStatus::terminalStatuses()). Mesuré sur une copie de la production : 527 inscriptions de saison annoncées pour 467 réelles, et 69 impayés annoncés pour 13. Un dirigeant relançait donc cinq fois trop de monde.collectedAmountn'est volontairement pas filtré : un paiementreceivedreste encaissé après l'annulation de l'inscription qui l'a motivé — ne pas « compléter » ce correctif en l'y ajoutant. - Une inscription annulée interdisait toute réinscription du même membre sur la même campagne (#279, 2026-09-03). La contrainte UNIQUE totale
(member_id, campaign_id)est remplacée par un index unique partiel,where status not in ('cancelled', 'abandoned'). Mesuré surlezards-animes: 60 couples (membre, campagne) ne portaient plus que des inscriptions annulées — 60 personnes bloquées, avec unSQLSTATE 23505pour seule explication.RegistrationDuplicateCheckerest aligné surterminalStatuses(); c'est un no-op fonctionnel aujourd'hui, la méthode ne rendant quecancelled— l'index partiel EST le correctif.abandonedest nommé d'avance à dessein :statusest unvarchar(255)et non un enum PostgreSQL, l'ajouter àterminalStatuses()suffira le jour venu. - Findings de la revue de code du registre des salariés (#281, 2026-09-02). Treize sur quatorze corrigés. Le tableau de bord vide désormais
unpaidMembers,recentActivityetclosingSoonCampaignscôté serveur pour les rôles sansview_members: uncoachrecevait la charge utile complète et on ne comptait que sur le front pour ne pas l'afficher.mapPerson()est réduit à id / prénom / nom / e-mail, avec un test qui fige la forme exacte de la charge utile (sansetc()), pour qu'un champ rajouté fasse rougir la suite. Sont également corrigés : la double invitation sur une même fiche salarié, l'unicité d'un participant par réunion, l'eager-load departicipants.persondansGenerateMeetingReportJob, le plafonnement deunlinkedStaff(), la validation d'employee_iden query-string comme UUID du tenant, et la seconde sortie de connexion de Fortify — celle du 2FA — qui ne redirigeait pas le coach. - La liste des adhérents annonçait « En attente » à des adhérents dont l'adhésion court (#283, 2026-09-04). Le badge et la pilule de filtre portant l'axe administratif (
members.status) sont retirés demembers/Index.vueet deMemberFilters.vue; il ne reste que l'état dérivé des périodes dememberships. Motif : aucun écran n'écrit cette colonne — ni la création, ni le formulaire d'édition, et les boutons Désactiver / Réactiver touchentis_active—, si bien qu'elle restait figée sur sa valeur de création. Le trou est dans le front, pas dans le domaine :Member::changeStatus(),UpdateMemberUseCaseet la routemembers.update-statussavent l'écrire dès que la charge utile porte le champ. Le paramètrestatusreste donc honoré côté serveur etapplyFiltersle reconduit d'une requête à la suivante, sans quoi un signet le portant n'aurait survécu qu'à un seul chargement.PersonMemberTab.vueaffiche encore le badge hérité : hors périmètre, connu.MembersLegacyStatusAxisHiddenTestverrouille le retrait sur le source — le réexposer suppose d'abord qu'un écran pose la valeur.
Notes techniques
- Cette version est d'abord un rattrapage de documentation. Les sept premières PR ci-dessus sont parties en production entre le 27 août et le 3 septembre 2026 sans commit de release ; chacune est datée de son merge. Seule #283 est livrée par cette version : elle n'était pas déployée quand ce fichier a été écrit, et c'est pour ne pas reproduire le retard de documentation qu'il vient de combler qu'elle y figure. Les sept premières sont regroupées sous un seul numéro plutôt que réparties sur des versions intermédiaires que
VERSIONn'a jamais portées et qu'aucun déploiement n'a servies. Le segment mineur bouge parce que le registre des salariés ajoute des tables et un module. Course::coachEmployee()a été renomméefindActiveCoachEmployee(), et ce n'est pas cosmétique. Une méthode au nom de relation qui rend unEmployeeau lieu d'uneRelationfait appelergetResults()par Eloquent dès qu'un eager-load ou un accès en propriété la croise : erreur fatale, pas avertissement.- Le
down()de la migration de l'index partiel lève uneRuntimeException. Restaurer une contrainte UNIQUE totale échouerait dès qu'un couple (membre, campagne) porte une inscription annulée et une active — soit exactement ce que cette version rend possible. La migration est à considérer comme irréversible en production. recentActivity()n'est toujours pas filtré par statut : les inscriptions annulées continuent d'y figurer. Connu, hors périmètre de #278, à traiter avant que la saison 2026-2027 passeActive.Employees/Form.vueexpose encoreperson_iden UUID brut. Connu, non corrigé : le champ attend un sélecteur de personne.- Les PR #225, #237 et #266 à #269 ne sont listées nulle part ici : toutes ont été mergées entre le 21 et le 24 août 2026, donc hors de la fenêtre couverte par cette version. #225 et #237 sont par ailleurs des montées de dépendances Dependabot, que ce fichier ne détaille jamais — il documente l'activation de Dependabot et les gates de CI, pas les bumps individuels ; #266 à #269 sont des changements internes sans effet sur l'application (fichiers temporaires Livewire ignorés, notes de roadmap, retrait des références JetBrains, discipline de workflow).
0.24.0.0 — 2026-08-25
Ajouts
registrations:import {tenant} {file} --campaign=<uuid> --mapping=<json> [--apply] [--scan]importe en masse des inscriptions historiques depuis un export HelloAsso converti au format CSV pivot. Elle répond au rapatriement de Cholet Water-Polo (environ 700 inscriptions, échéance 2026-09-01).PivotCsvReaderlit le fichier ligne à ligne en générateur, jamais chargé entier en mémoire ;TierMappingrelie chaque libellé de tarif du fichier à une proposition existante ;ImportPayerResolverdétermine le payeur et les représentants légaux des mineurs.ImportPreflight::build()est une fonction pure et en lecture seule qui produit unImportPlan— par défaut la commande simule et affiche ce plan sans rien écrire ;--applydéclenche l'écriture, après confirmation interactive, parImportRegistrationsUseCase, seul écrivain, sous verrou. L'écriture est idempotente au niveau panier : rejouer le même fichier après un échec partiel ne duplique rien et ne demande aucun nettoyage manuel.--scanprofile un fichier (en-tête, distribution des tarifs) avant de lancer un import, sans exiger--campaignni--mapping. Un test dédié garantit que la commande n'est jamais automatisée (nipost_build.sh, ni le planificateur) : c'est un outil d'opérateur, pas un job récurrent.- Le préflight confronte désormais chaque identité du fichier à la base, en lecture seule, avant d'autoriser la moindre écriture. Chaque adhérent est classé en quatre dispositions par rapport à ce que
PersonDeduplicationService::findOrCreate()(INTOUCHÉ) retrouverait réellement : Réutilisée (fiche existante retrouvée à l'identique), Ambiguë — un e-mail est fourni, une fiche du même e-mail et du même prénom existe, mais la correspondance exacte échoue (casse d'e-mail ou date de naissance divergente) —, Aveugle — aucun e-mail dans le fichier alors qu'une fiche candidate existe déjà sur prénom/nom/date de naissance —, ou Créée. Ambiguë et Aveugle sont bloquantes ; un avertissement agrégé annonce « N adhérent(s) du fichier rejoindront une fiche existante, M seront créés. ». Motif :PersonDeduplicationServicecompare l'e-mail à la casse exacte près mais le prénom insensible à la casse, et ne cherche rien quand l'e-mail est nul — sans cette confrontation, l'import aurait créé des doublons en silence dans ces trois cas précis. - Une même proposition visée deux fois par un même adhérent dans le fichier, à des montants différents, fait échouer le préflight — jamais cumulée.
attachPropositions()(ImportRegistrationsUseCase) ne conserve que le premierunit_amountvu par proposition et cumule les quantités : deux lignes à des libellés de tarif distincts pointant par erreur vers la même proposition auraient fait disparaître de l'argent en silence, sans qu'aucune contrainte SQL ne le détecte.validatePropositionAmounts()refuse ce cas en amont et détaille les lignes et montants en conflit — les cumuler (quantité × montant) aurait masqué l'incohérence de saisie au lieu de la signaler. - Les UUID de proposition passés via
--mapping=sont normalisés en minuscules dansTierMapping.Str::isUuid()est insensible à la casse, maispropositions.idest stocké en minuscules (colonneuuidnative sous PostgreSQL) : sans cette normalisation, un UUID saisi en majuscules passait la garde de forme puis échouait silencieusement learray_diff()(sensible à la casse) devalidatePropositions(), rapporté à tort comme « introuvable ou hors de la campagne ». Le symptôme est le même sur les deux moteurs, seul le chemin diffère : sous PostgreSQL la colonneuuidnative normalise la casse, la requête trouve donc la ligne et la rend en minuscules — c'estarray_diff()qui la déclare ensuite absente ; sous SQLite, oùidest du texte, c'est la requête elle-même qui ne trouve rien. Dans les deux cas l'opérateur lisait « introuvable » sur une proposition valide.
Notes techniques
- Le cast Eloquent
datesans format explicite a failli rendre la garde anti-doublon elle-même aveugle. Il sérialise l'écriture avec l'heure (2016-05-02 00:00:00) même pour une colonnedatenative : unwhereIn()sur des chaînesAAAA-MM-JJnues ne retrouve donc rien sous SQLite — comparaison de chaînes brute, invisible en CI, PostgreSQL n'ayant pas ce travers. Les requêtes de confrontation de la classification « Aveugle » utilisentwhereDate(), la même fonctiondate(colonne)quePersonDeduplicationService::findOrCreate()applique déjà pour sa propre correspondance exacte — mêmes garanties cross-moteur. Sans ce choix, la garde anti-faux-négatif décrite ci-dessus aurait elle-même été un faux négatif : verte en CI, muette en production.
0.23.3.0 — 2026-08-20
Retraits
- Les colonnes
members.current_membership_yearetmembers.membership_expires_atsont supprimées de la base.membershipsest la seule source de vérité de l'adhésion depuis 0.16.17.0, et 0.23.2.0 en a retiré la dernière lecture applicative. Il ne restait ici que le SQL : aucun code applicatif ne change dans cette version. - L'index
members_membership_expires_at_indexest retiré EXPLICITEMENT avant la colonne, et ce n'est pas une précaution décorative : PostgreSQL le supprimerait en cascade, mais SQLite refuse unALTER TABLE … DROP COLUMNsur une colonne indexée. Sans ce retrait, la suite de tests casserait là où la production passerait — exactement la divergence que ce dépôt cherche à ne pas fabriquer.
Notes techniques
- Contraction livrée dans un déploiement SÉPARÉ du retrait des lectures, et l'ordre n'est pas négociable.
clevercloud/post_build.shlancemigrate --forcependant que l'instance précédente sert encore le trafic. Livrer ce DROP en même temps que le code qui lisait encore les colonnes aurait fait leverSQLSTATE 42703 undefined columnsur l'instance en ligne le temps de la bascule. C'est la raison pour laquelle 0.23.2.0 ne contenait aucundropColumnmalgré un diff qui en aurait eu l'occasion. - Le
down()restaure le schéma, pas les données. Les colonnes reviennent nullables et vides ; leur contenu n'est récupérable que depuis une sauvegarde. Ce n'est pas une perte : plus aucun écrivain applicatif ne les alimentait depuis 0.22.3.0, et le code antérieur à 0.23.2.0 y lisait déjànullpartout. Ledown()existe pour qu'un retour arrière du déploiement retrouve une table que ce code sait ouvrir. - Les assertions de schéma sont RETOURNÉES, pas retirées.
MembersMigrationTestaffirme désormais l'absence des deux colonnes (toBeFalse) au lieu de leur présence : une colonne recréée par mégarde doit faire rougir la suite, pas passer inaperçue. Les assertions de nullabilité, elles, disparaissent — elles n'ont plus de sujet. - Deux assertions de
LegacyRenewRoutesRemovedTestsont supprimées parce qu'elles étaient devenues tautologiques.->and($member->current_membership_year)->toBeNull()était discriminant tant que la colonne existait — la factory ne l'alimentait plus, donc toute valeur non nulle prouvait une réouverture. Une fois la colonne partie, Eloquent rendnullpour un attribut inconnu et l'assertion passe en ne testant plus rien. L'oracle discriminant est intégralement porté par le second cas du fichier, qui gèle la surface d'écriture ($fillable, règles de validation) — le mode strict d'assignation de masse n'étant pas activé sur ce projet, une ré-entrée dans$fillablerouvrirait silencieusement l'écriture.
0.23.2.0 — 2026-08-20
Modifications
- La liste des adhérents filtre désormais sur DEUX axes indépendants, et non plus sur un seul champ qui confondait les deux questions. L'axe administratif (
members.status: En attente / Actif / Suspendu / Parti) décrit la relation du membre avec l'association ; l'axe adhésion (MembershipState: Adhérent / Adhésion à venir / Adhésion expirée / Non adhérent) est dérivé des périodes dememberships. Ils se combinent en intersection côté serveur, chacun avec son paramètre d'URL (status,membership). Le filtre unique précédent rendait « Adhésion expirée » indisponible dès qu'on voulait aussi trier sur « Suspendu ». Les deux axes coexistent définitivement :members.statusn'est pas une étape transitoire de la migration, et les libellés le disent —statuses.activepasse de « Adhésion active » à « Actif »,statuses.suspendedde « Adhésion suspendue » à « Suspendu ». Le vocabulaire d'adhésion est reversé dans un bloc de traduction distinct,membership_states. - Le filtre d'adhésion est un prédicat SQL, pas un tri après pagination. Deux portées ajoutées à
Membership—startingAfter()etendedBefore()— complètentactiveOn()pour reproduire en SQL la précédence deMembershipStanding::resolve(): Adhérent > À venir > Expirée > Aucune, bornes inclusives,end_dateNULL jamais expirée (whereNotNullexplicite plutôt qu'une comparaison où NULL déciderait),tenant_idexplicite dans chaque sous-requête. Contrairement àactiveOn(), les deux nouvelles portées sont des prédicats de date purs : elles ne filtrent pas les annulations, l'appelant compose avecactive()— une période annulée ne doit jamais rendre un membre « adhésion à venir ». Filtrer en PHP après la pagination aurait rendu des pages incomplètes et un total faux. ListMembersQueryévalue les deux axes sur UNE seule date. UnCarbonImmutable::now()est hissé en tête d'exécution et sert au filtre SQL comme à la projection ligne à ligne : sans lui, une page pouvait afficher un état que le filtre venait d'exclure. Une valeur d'axe inconnue n'invente aucun filtre — la liste reste complète plutôt que vide.- La cascade orpheline de
memberships:syncne devine plus, elle signale. La branche qui reconstituait une saison depuismembers.membership_expires_atest retirée, avecSeasonIndex::endingExactlyOn()et le compteur « orphelins via date d'expiration ». Motif : la colonne n'a plus d'écrivain applicatif et disparaît demembersà la version suivante — ancrer une période dessus revenait à la caler sur une valeur gelée. Il ne reste aucune source de déduction : un adhérent au statut actif sans inscription validée exploitable est reporté à l'opérateur. Aucune donnée n'est en jeu : la réconciliation ne peut annuler que les lignes héritées (season_idNULL), et un adhérent sans cible sort avant la réconciliation — les périodes déjà écrites par cette branche (6 adhérents en production, sourceImport, toutes porteuses d'unseason_id) sont conservées telles quelles.
Retraits
MemberStatus::EXPIREDquitte l'enum. Une migration normalise d'abord les lignes résiduellesmembers.status = 'expired'vers'active': le champ est casté en enum PHP sur le modèle, une seule ligne survivante lèverait unValueErrorfatal au premier accès en lecture. Volume constaté sur la copie anonymisée de production (2026-08-20) : 23 lignes, toutes portées par des tenants de démonstration (club-asso 12, demo-cholet-waterpolo 8, asso-culture 1, demo-ligue-56 1, demo-epgv-44 1) — aucune surlezards-animes. Ledown()est vide et documenté comme irréversible : la distinction expired/active n'est plus représentable sur cet axe. La migration écrit viaDB::table()et non Eloquent, volontairement : le global scopeBelongsToTenantrestreindrait la requête au tenant courant — inexistant en migration — et ne balaierait aucune ligne.UpdateMemberRequestvalide désormais avecRule::enum(MemberStatus::class)au lieu d'une liste recopiée qui aurait continué d'accepter'expired'en silence.- Les colonnes
current_membership_yearetmembership_expires_atne sont plus lues NI écrites par aucun chemin applicatif : retirées de l'entité de domaineMember, deMemberMapper, deMemberResponse, deMemberSectionData, du$fillableet descasts()du modèle Eloquent, des types TypeScript, des libellés de champ et des noms d'attribut de validation. Les six seeders de démonstration etMemberFactorycessent de les alimenter. Le DROP SQL est délibérément différé à la version suivante :post_build.shlancemigrate --forcependant que l'instance précédente sert encore le trafic, si bien qu'undropColumnlivré ici ferait lire des colonnes disparues au code encore en ligne (SQLSTATE 42703). Retirer les lectures et supprimer les colonnes doivent être deux déploiements distincts, dans cet ordre.
Notes techniques
- Un test de partition garde l'équivalence filtre SQL ↔ statut affiché. Pour chaque état S,
{ ids rendus par le filtre S }doit égaler{ ids dont l'état projeté vaut S }— et l'oracle est unexecute()sans filtre, donc la projection réellement affichée sur la page, pas un appel direct au dépôt. La cohorte compte 24 adhérents sous horloge gelée, dont les cas de borne (période commençant le jour même, finissant le jour même, débutant demain, finie hier), les périodes sans fin, les recouvrements multiples et neuf combinaisons d'annulations. Trois assertions supplémentaires : l'union des quatre états couvre la cohorte et les états sont deux à deux disjoints ; aucune fuite inter-tenant ; les deux axes se combinent. Vérifié par mutation : desserrer la borne basse deactiveOn()(<=→<) ou retirer->active()de la sous-requête fait tomber le test dans les deux cas. - Une divergence SQL/VO est documentée et tenue hors cohorte. Une période inversée (
start_date > end_date) est classéeupcomingpar la précédence SQL alors que le value object refuse de l'hydrater (InvalidMembershipPeriodException). Elle est inatteignable sur PostgreSQL —daterangelèveSQLSTATE 22000— donc sans effet en production, et seulement productible sur SQLite. Le cas est épinglé par un test dédié qui assère sur les portées, pas surexecute(): assérer l'exception aurait gravé un plantage dans le contrat de la liste. vue-tscne garde pas le câblage d'un filtre.'membership'manquait dans les deux appelsrequest()->only()deMemberController: la pilule était inerte via HTTP et se réinitialisait au rechargement, sans qu'aucune des trois portes — PHPStan,vue-tsc, Pest — ne rougisse, le test de filtre appelantListMembersQuery::execute()directement. Corrigé et couvert par un test de niveau HTTP, falsifié en le rejouant sur le code d'origine (Property [filters.membership] does not exist).DemoSeedersAreIdempotentTesta été réécrit, pas supprimé. Les seeders écrivent désormais des périodes d'adhésion ; le décor construit à la main entrait en collision avec l'index unique partiel sur deux jeux de données et faussait un compte sur trois autres. Sa précondition est resserrée sur les saisons du tenant testé : unwhereNotNullnu passait avec une période ancrée sur la saison d'un autre tenant, où la brancheNO ACTIONde la clé étrangère n'est jamais exercée.vendor/bin/pinta de nouveau réécrit un PHPDoc enuse. Sa règlefully_qualified_stringsa transformé les références{@see}de la nouvelle migration en imports de classes. Contourné en citant les noms entre accents graves. Même piège qu'en 0.23.1.0, autre fichier.