Référence: Profile State Machine¶
Objectif¶
Sortir la logique de validité de profil du legacy controllers/profile.go, controllers/practice.go et controllers/moderation.go pour la recentrer dans un service métier unique.
Le but n'est pas de renommer tout de suite les champs SQL. La première étape est de figer une machine d'état canonique, puis de brancher les endpoints dessus.
Constat sur l'existant¶
La validité d'un profil repose aujourd'hui sur plusieurs champs dans models.Person:
ProfileStatusProfileVerifiedModerationModerationReasonDenyReason
Problème principal: ces champs ne représentent pas des concepts homogènes.
ProfileStatusest un état externe exploité par l'application.Moderationressemble à une policy: "ce profil doit repasser en revue quand certains champs changent".ModerationReasonest un ensemble de raisons "en attente de revue".DenyReasonest un ensemble de raisons "refusées".ProfileVerifiedne veut pas dire "profil validé": un profil refusé peut avoirProfileVerified=true.
Cible conceptuelle¶
On veut raisonner avec ces concepts:
CompletioncompleteouincompleteReviewPolicyreview_on_change=true|falsePendingReviewReasons- ensemble de raisons en attente de relecture
DeniedReasons- ensemble de raisons refusées
ReviewStateincompletepending_reviewdeniedapprovedbanned
Sémantique cible figée¶
La sémantique cible à retenir pour la suite est la suivante:
Completion- répond à la question: "le profil contient-il toutes les données obligatoires ?"
ReviewOnChange- répond à la question: "une modification de certaines dimensions doit-elle repasser par une revue ?"
PendingReviewReasons- répond à la question: "quelles dimensions sont actuellement en attente de revue ?"
DeniedReasons- répond à la question: "quelles dimensions ont été refusées et attendent une correction ?"
ProfileState- répond à la question: "quel est l'état externe visible du profil pour l'application ?"
- valeurs cibles:
incompletepending_reviewdeniedapprovedbanned
Important:
ProfileStateest la seule vérité externe à exposer comme état global du profil.Completion,ReviewOnChange,PendingReviewReasonsetDeniedReasonssont des concepts internes/métier.ProfileVerifiedn'est pas un concept cible.- un booléen du type "verified" ne doit plus piloter la logique publique du profil.
Nommage cible conceptuel¶
Le naming cible à retenir, avant tout renommage effectif du code ou de la DB, est:
ProfileStatus- cible conceptuelle:
ProfileState Moderation- cible conceptuelle:
ReviewOnChange ModerationReason- cible conceptuelle:
PendingReviewReasons DenyReason- cible conceptuelle:
DeniedReasons ProfileVerified- pas de champ cible direct
- ce legacy doit disparaître ou devenir un dérivé technique transitoire
Autrement dit:
- on garde un état global
ProfileState - on garde une policy
ReviewOnChange - on garde deux ensembles de raisons:
PendingReviewReasonsDeniedReasons- on ne garde pas de concept métier autonome nommé
ProfileVerified
Ce que veut dire "approved"¶
approved ne veut pas dire "ce profil ne changera plus jamais".
approved veut dire:
- le profil est complet
- aucune dimension suivie n'est en attente de revue
- aucune dimension suivie n'est refusée
Si ReviewOnChange=true, un changement sur une dimension suivie peut donc faire repasser un profil approved en pending_review.
Ce que veut dire "denied"¶
denied ne veut pas dire "le profil entier est inutilisable à vie".
denied veut dire:
- le profil est complet
- au moins une dimension suivie a été refusée
- l'utilisateur doit corriger ces dimensions pour sortir de cet état
Ce que veut dire "incomplete"¶
incomplete n'est pas un état de revue.
incomplete veut dire:
- il manque au moins une donnée obligatoire pour qu'un profil soit considéré complet
- cet état a priorité sur
pending_reviewetdenied, saufbanned
Donc un profil peut conserver des raisons internes de revue ou de refus tout en restant exposé publiquement comme incomplete.
Compatibilité avec l'existant¶
Dans un premier temps, on garde les champs SQL existants et on leur donne cette interprétation:
ProfileStatus- état externe canonique
Moderationreview_on_changeModerationReasonPendingReviewReasonsDenyReasonDeniedReasonsProfileVerified- champ legacy transitoire
- à lire comme "aucune revue en attente sur les dimensions suivies"
- ne doit plus piloter directement la logique publique
Définition d'un profil complet¶
La cible fonctionnelle est:
descriptionnon nullegendernon nulbirthDatenon nulle- un
avatar - au moins une
location - au moins une
practice
Implication:
isProfileCompletedevra être aligné sur cette définition lors du refacto
Raisons suivies¶
Les raisons suivies aujourd'hui sont:
pseudodescriptionavatarranking
Le service métier doit les manipuler comme un type métier explicite, même si la persistance reste un bitmask au début.
Règles canoniques¶
1. Incomplete gagne toujours¶
Si le profil n'est pas complet, l'état externe doit être incomplete, sauf si le profil est banned.
Implications:
- un profil incomplet ne doit jamais être
approved - un profil incomplet ne doit jamais être
pending_review - un profil incomplet peut conserver des
PendingReviewReasonsouDeniedReasons, mais elles ne pilotent pas l'état externe tant que la complétude n'est pas atteinte
2. Banned gagne sur tout¶
Si le profil est banni, l'état externe est banned, quel que soit le reste.
3. Denied prime sur pending et approved¶
Si le profil est complet et qu'il existe au moins une raison dans DeniedReasons, l'état externe est denied.
4. Pending prime sur approved¶
Si le profil est complet, qu'il n'y a aucun refus, et qu'il existe au moins une raison dans PendingReviewReasons, l'état externe est pending_review.
5. Approved est l'état résiduel sain¶
Si le profil est complet, non banni, sans refus, sans attente de revue, l'état externe est approved.
Table de décision canonique¶
Ordre d'évaluation:
- si
banned->banned - sinon si
!complete->incomplete - sinon si
DeniedReasons != empty->denied - sinon si
PendingReviewReasons != empty->pending_review - sinon ->
approved
Effet des mutations métier¶
Signup¶
Effets:
- création d'un profil incomplet
review_on_change=truePendingReviewReasonscontient au minimumpseudoDeniedReasonsvide- état externe:
incomplete
Mise à jour pseudo¶
Effets:
- met à jour la valeur
- si
review_on_change=true, ajoutepseudoàPendingReviewReasons - retire
pseudodeDeniedReasons
Mise à jour description¶
Effets:
- met à jour la valeur
- si
review_on_change=true, ajoutedescriptionàPendingReviewReasons - retire
descriptiondeDeniedReasons
Mise à jour birthDate / gender¶
Effets:
- met à jour la valeur
- ne crée pas de raison de revue dédiée dans l'état actuel du produit
Ajout avatar¶
Effets:
- ajoute ou remplace l'avatar
- si
review_on_change=true, ajouteavataràPendingReviewReasons - retire
avatardeDeniedReasons
Suppression avatar¶
Effets:
- retire l'avatar
- retire
avatardePendingReviewReasons - retire
avatardeDeniedReasons
Remarque:
- comme l'avatar entre dans la complétude, sa suppression rend le profil
incomplete
Ajout d'une pratique sans classement¶
Effets:
- ajoute une pratique directement considérée comme vérifiée
- n'ajoute pas de raison de revue
Ajout ou modification d'un classement¶
Effets:
- ajoute
rankingàPendingReviewReasonssi une revue est requise - retire
rankingdeDeniedReasons
Suppression du dernier classement en attente/refusé¶
Effets:
- si plus aucun classement ne justifie une revue ou un refus, retire
rankingdePendingReviewReasons - retire
rankingdeDeniedReasonssi plus aucun classement refusé ne subsiste
Ajout d'une location¶
Effets:
- impacte uniquement la complétude
Suppression de la dernière location¶
Effets:
- rend le profil
incomplete
Suppression de la dernière pratique¶
Effets:
- rend le profil
incomplete - nettoie les raisons
rankingdevenues sans objet
Moderation OK¶
Préconditions:
- profil complet
- état externe actuel
pending_review
Effets:
- toutes les dimensions suivies sont considérées revues
PendingReviewReasonsvideDeniedReasonsvidereview_on_change=false- état externe final
approved
Moderation NOK¶
Préconditions:
- profil complet
- état externe actuel
pending_review
Effets:
DeniedReasonsreçoit les dimensions refuséesPendingReviewReasonsest purgé des dimensions traitéesreview_on_changerestetrue- état externe final
deniedsi au moins un refus subsiste
Correction d'un motif refusé¶
Effets:
- quand un champ refusé est modifié, on retire sa raison de
DeniedReasons - si
review_on_change=true, on ajoute ce même champ àPendingReviewReasons - l'état externe redevient:
pending_reviewsi le profil est complet et qu'il n'y a plus de refusdeniedsi d'autres refus subsistentincompletesi le profil n'est plus complet
Invariants à faire respecter par le futur service¶
approvedimplique:- profil complet
DeniedReasonsvidePendingReviewReasonsvidepending_reviewimplique:- profil complet
DeniedReasonsvidePendingReviewReasonsnon videdeniedimplique:- profil complet
DeniedReasonsnon videincompleteimplique:- profil non complet
bannedimplique:- aucune autre règle ne doit reprendre la main
Notifications¶
Les notifications liées au profil doivent être pilotées par les transitions d'état externes, pas par les mutations techniques champ par champ.
Principe:
- notifier uniquement quand
ProfileStatuschange - ne pas notifier sur une mutation qui laisse le profil dans le même état
- émettre les notifications depuis le service métier, dans la même transaction que la mutation
Types cibles¶
Types de notifications à introduire:
ProfileIncompleteNotificationTypeProfilePendingReviewNotificationTypeProfileApprovedNotificationTypeProfileDeniedNotificationTypeProfileBannedNotificationType
Remarque:
- les types legacy
DeniedModerationNotificationType,ValidatedModerationNotificationType,BannedModerationNotificationTypepeuvent servir de base transitoire - la cible fonctionnelle reste un naming centré sur l'état du profil, pas sur le jargon "moderation"
Transitions notifiables¶
Transitions qui doivent émettre une notification:
* -> incomplete- seulement si l'état précédent n'était pas déjà
incomplete incomplete -> pending_reviewdenied -> pending_reviewapproved -> pending_reviewpending_review -> approvedpending_review -> denied* -> banned
Transitions qui ne doivent pas notifier:
incomplete -> incompletepending_review -> pending_reviewdenied -> deniedapproved -> approved
Contenus recommandés¶
Exemples de contenu:
ProfileIncompleteNotificationType- "Votre profil est incomplet. Ajoutez les informations manquantes pour le finaliser."
ProfilePendingReviewNotificationType- "Votre profil est complet et en attente de validation."
ProfileApprovedNotificationType- "Votre profil a été validé."
ProfileDeniedNotificationType- "Votre profil n'a pas été validé. Corrigez les éléments signalés."
ProfileBannedNotificationType- "Votre profil a été suspendu."
Pour denied, un contenu plus précis peut être injecté selon le motif de refus.
Point de branchement¶
Le service métier profil doit suivre ce schéma:
- charger l'aggregate
- appliquer la mutation
- recalculer l'état canonique
- comparer
oldStatusetnewStatus - si la transition est notifiable:
- créer la notification système
- enqueue la diffusion Mercure
- enqueue la push mobile si souhaité
Références techniques existantes¶
Pattern à réutiliser:
- création d'une notification système:
NotificationSvc.CreateSystemNotification(...)- diffusion Mercure:
NotificationSvc.Publish(...)- diffusion push:
NotificationSvc.SendExpoPushToPerson(...)
Le pattern de référence actuel est celui de la modération.
Architecture cible¶
1. Service de machine d'état pur¶
Package proposé:
services/profile
Responsabilités:
- calculer la complétude
- recalculer l'état externe
- gérer les ensembles de raisons
- exposer une API pure testable sans HTTP ni DB
API possible:
type MutationKind string
const (
MutationSignup MutationKind = "signup"
MutationUpdateIdentity MutationKind = "update_identity"
MutationAddLocation MutationKind = "add_location"
MutationRemoveLocation MutationKind = "remove_location"
MutationAddPractice MutationKind = "add_practice"
MutationUpdatePractice MutationKind = "update_practice"
MutationRemovePractice MutationKind = "remove_practice"
MutationSetAvatar MutationKind = "set_avatar"
MutationRemoveAvatar MutationKind = "remove_avatar"
MutationModerationApprove MutationKind = "moderation_approve"
MutationModerationDeny MutationKind = "moderation_deny"
)
type Service interface {
Recompute(person *models.Person) error
Apply(person *models.Person, mutation Mutation) error
}
2. Services applicatifs par use case¶
Packages proposés:
services/profileservices/avatarservices/practiceservices/locationservices/moderation
Responsabilités:
- charger les agrégats
- vérifier les droits
- ouvrir la transaction
- modifier les données
- appeler les règles de cycle de vie du package
profile - persister
3. Contrôleurs thin¶
Les contrôleurs ne doivent plus:
- recalculer
ProfileStatus - manipuler
ModerationReason - manipuler
DenyReason
Ils doivent seulement:
- parser l'input
- appeler le service applicatif
- mapper les erreurs vers HTTP
Ordre de refacto recommandé¶
- Écrire les tests unitaires purs de la machine d'état.
- Introduire
services/profilesans changer les routes. - Rebrancher
UpdatePerson. - Rebrancher
AddPracticesToPerson,UpdatePractice,RemovePractice. - Rebrancher
AddLocationToPerson,RemoveLocation. - Rebrancher
UpdatePersonAvatar,RemovePersonAvatar. - Rebrancher
ModerationOkPersonetDenyModeration. - Une fois stable, renommer ou supprimer les champs legacy mal nommés.
Point de vigilance¶
La première victoire ne doit pas être "renommer des champs". La première victoire doit être:
- une seule machine d'état
- des tests de transitions
- plus aucune logique de statut dispersée dans les contrôleurs
Ensuite seulement, on nettoie la persistance et les noms.