Aller au contenu

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:

  • ProfileStatus
  • ProfileVerified
  • Moderation
  • ModerationReason
  • DenyReason

Problème principal: ces champs ne représentent pas des concepts homogènes.

  • ProfileStatus est un état externe exploité par l'application.
  • Moderation ressemble à une policy: "ce profil doit repasser en revue quand certains champs changent".
  • ModerationReason est un ensemble de raisons "en attente de revue".
  • DenyReason est un ensemble de raisons "refusées".
  • ProfileVerified ne veut pas dire "profil validé": un profil refusé peut avoir ProfileVerified=true.

Cible conceptuelle

On veut raisonner avec ces concepts:

  • Completion
  • complete ou incomplete
  • ReviewPolicy
  • review_on_change=true|false
  • PendingReviewReasons
  • ensemble de raisons en attente de relecture
  • DeniedReasons
  • ensemble de raisons refusées
  • ReviewState
  • incomplete
  • pending_review
  • denied
  • approved
  • banned

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:
    • incomplete
    • pending_review
    • denied
    • approved
    • banned

Important:

  • ProfileState est la seule vérité externe à exposer comme état global du profil.
  • Completion, ReviewOnChange, PendingReviewReasons et DeniedReasons sont des concepts internes/métier.
  • ProfileVerified n'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:
  • PendingReviewReasons
  • DeniedReasons
  • 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_review et denied, sauf banned

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
  • Moderation
  • review_on_change
  • ModerationReason
  • PendingReviewReasons
  • DenyReason
  • DeniedReasons
  • ProfileVerified
  • 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:

  • description non nulle
  • gender non nul
  • birthDate non nulle
  • un avatar
  • au moins une location
  • au moins une practice

Implication:

  • isProfileComplete devra être aligné sur cette définition lors du refacto

Raisons suivies

Les raisons suivies aujourd'hui sont:

  • pseudo
  • description
  • avatar
  • ranking

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 PendingReviewReasons ou DeniedReasons, 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:

  1. si banned -> banned
  2. sinon si !complete -> incomplete
  3. sinon si DeniedReasons != empty -> denied
  4. sinon si PendingReviewReasons != empty -> pending_review
  5. sinon -> approved

Effet des mutations métier

Signup

Effets:

  • création d'un profil incomplet
  • review_on_change=true
  • PendingReviewReasons contient au minimum pseudo
  • DeniedReasons vide
  • état externe: incomplete

Mise à jour pseudo

Effets:

  • met à jour la valeur
  • si review_on_change=true, ajoute pseudo à PendingReviewReasons
  • retire pseudo de DeniedReasons

Mise à jour description

Effets:

  • met à jour la valeur
  • si review_on_change=true, ajoute description à PendingReviewReasons
  • retire description de DeniedReasons

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, ajoute avatar à PendingReviewReasons
  • retire avatar de DeniedReasons

Suppression avatar

Effets:

  • retire l'avatar
  • retire avatar de PendingReviewReasons
  • retire avatar de DeniedReasons

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 à PendingReviewReasons si une revue est requise
  • retire ranking de DeniedReasons

Suppression du dernier classement en attente/refusé

Effets:

  • si plus aucun classement ne justifie une revue ou un refus, retire ranking de PendingReviewReasons
  • retire ranking de DeniedReasons si 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 ranking devenues sans objet

Moderation OK

Préconditions:

  • profil complet
  • état externe actuel pending_review

Effets:

  • toutes les dimensions suivies sont considérées revues
  • PendingReviewReasons vide
  • DeniedReasons vide
  • review_on_change=false
  • état externe final approved

Moderation NOK

Préconditions:

  • profil complet
  • état externe actuel pending_review

Effets:

  • DeniedReasons reçoit les dimensions refusées
  • PendingReviewReasons est purgé des dimensions traitées
  • review_on_change reste true
  • état externe final denied si 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_review si le profil est complet et qu'il n'y a plus de refus
  • denied si d'autres refus subsistent
  • incomplete si le profil n'est plus complet

Invariants à faire respecter par le futur service

  • approved implique:
  • profil complet
  • DeniedReasons vide
  • PendingReviewReasons vide
  • pending_review implique:
  • profil complet
  • DeniedReasons vide
  • PendingReviewReasons non vide
  • denied implique:
  • profil complet
  • DeniedReasons non vide
  • incomplete implique:
  • profil non complet
  • banned implique:
  • 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 ProfileStatus change
  • 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:

  • ProfileIncompleteNotificationType
  • ProfilePendingReviewNotificationType
  • ProfileApprovedNotificationType
  • ProfileDeniedNotificationType
  • ProfileBannedNotificationType

Remarque:

  • les types legacy DeniedModerationNotificationType, ValidatedModerationNotificationType, BannedModerationNotificationType peuvent 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_review
  • denied -> pending_review
  • approved -> pending_review
  • pending_review -> approved
  • pending_review -> denied
  • * -> banned

Transitions qui ne doivent pas notifier:

  • incomplete -> incomplete
  • pending_review -> pending_review
  • denied -> denied
  • approved -> 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:

  1. charger l'aggregate
  2. appliquer la mutation
  3. recalculer l'état canonique
  4. comparer oldStatus et newStatus
  5. si la transition est notifiable:
  6. créer la notification système
  7. enqueue la diffusion Mercure
  8. 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/profile
  • services/avatar
  • services/practice
  • services/location
  • services/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é

  1. Écrire les tests unitaires purs de la machine d'état.
  2. Introduire services/profile sans changer les routes.
  3. Rebrancher UpdatePerson.
  4. Rebrancher AddPracticesToPerson, UpdatePractice, RemovePractice.
  5. Rebrancher AddLocationToPerson, RemoveLocation.
  6. Rebrancher UpdatePersonAvatar, RemovePersonAvatar.
  7. Rebrancher ModerationOkPerson et DenyModeration.
  8. 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.