Notre Couple — invitations et rapprochement
Objectif
Le parcours partenaire conserve une réponse publique neutre tout en donnant à l'invitant un suivi utile. Le modèle distingue volontairement le statut public d'une demande de son chemin interne d'acheminement.
Une adresse cible peut correspondre à :
- aucune identité locale au moment de l'envoi : invitation e-mail avec token ;
- un compte actif libre :
couple_join_requestauthentifiée ; - un compte momentanément indisponible : aucune demande actionnable n'est créée.
Dans les trois cas, l'invitant voit la même demande publique et le même reçu request_recorded. L'état privé du compte cible n'est jamais exposé.
Migration 0014
0014_partner_request_lifecycle.sql ajoute l'enveloppe couple_partner_requests et relie facultativement les deux chemins internes par partner_request_id.
L'enveloppe porte uniquement :
- le couple et l'utilisateur émetteur côté serveur ;
- l'adresse canonique cible ;
- les prénom/adresse de présentation saisis par l'invitant ;
- le statut public
pending,accepted,closedouexpired; - les dates d'envoi, d'expiration et de décision.
La migration reste additive et compatible avec un déploiement rolling. Les anciennes couple_join_requests reçoivent une date d'expiration par backfill mais ne sont pas transformées en demandes publiques historiques.
Store unique de création
Le runtime public passe exclusivement par partner_request_store.record_partner_request() pour enregistrer ou remplacer une demande partenaire.
Les deux formes de payload restent acceptées pendant la compatibilité rolling :
- ancien client : adresse e-mail uniquement ;
- client courant : adresse e-mail + prénom de présentation du partenaire.
Elles utilisent exactement la même enveloppe de suivi, les mêmes événements d'audit, les mêmes règles anti-énumération et le même mécanisme de rapprochement réciproque. Les anciens helpers couple_store.request_partner() et couple_identity_profile.request_partner_profile() ne sont plus des chemins du runtime public.
L'ordre de verrouillage d'une création est contractuel :
- advisory lock de la paire d'e-mails canoniques triée ;
- advisory lock de l'e-mail cible ;
- lignes
usersconcernées verrouillées en ordre d'e-mail canonique ; - couple
pendingde l'initiateur ; - enveloppes et chemins internes de demande.
Les acceptations/refus utilisent le même verrou cible avant la ligne utilisateur. Ils peuvent donc retarder une création concurrente sans former de cycle. Le store vérifie également lui-même que l'initiateur possède un prénom auto-déclaré ; cette règle ne dépend pas uniquement de l'UI.
Suivi côté invitant
GET /api/v1/couple/partner-requests retourne uniquement les demandes émises par l'utilisateur authentifié. GET /api/v1/couple expose aussi partner_requests pour permettre le rendu direct de l'historique dans la mire de couple.
Le frontend affiche une section Suivi des invitations avec un vocabulaire volontairement générique :
En attente;Acceptée;Terminée;Expirée.
Aucun statut public ne distingue « compte inconnu », « compte existant », « déjà en couple » ou le type de transport utilisé.
Expiration
La durée par défaut reste alignée sur INVITATION_TTL, soit 7 jours.
À l'expiration :
- l'enveloppe publique devient
expired; - un token e-mail encore actif est révoqué et l'invitation interne devient
expired; - une
couple_join_requestinterne est révoquée ; - aucune demande expirée ne reste actionnable.
Le statut interne d'une join request reste compatible avec le schéma historique ; l'état expired est un contrat public porté par l'enveloppe 0014.
Invitations réciproques
Deux invitations opposées constituent un consentement explicite réciproque : si A invite B et que B invite A pendant que les deux demandes sont encore valides, aucun troisième clic d'acceptation n'est requis.
Le rapprochement est transactionnel :
- un advisory lock est dérivé de la paire d'e-mails canoniques triée afin que A→B et B→A utilisent la même clé ;
- les deux couples
pendingsont verrouillés dans un ordre déterministe ; - le couple gagnant est le plus ancien, puis l'UUID départage une égalité ;
- la membership du créateur du couple perdant passe à
removed; - ce membre rejoint le couple gagnant ;
- le couple gagnant devient
active, le doublonpendingdevientdeleted; - les deux demandes réciproques deviennent
acceptedet les autres chemins concurrents sont soldés.
Cette règle ne s'applique que lorsque les deux gestes sont explicites et concernent exactement la même paire d'adresses.
Confidentialité et concurrence
Les règles suivantes restent invariantes :
- le navigateur ne fournit jamais un
couple_idouuser_idfaisant autorité ; - les informations de profil privées de la cible ne sont pas lues pour construire le suivi de l'invitant ;
- une cible indisponible reste publiquement
pendingjusqu'à fermeture/expiration ; - l'index PostgreSQL
one_active_couple_per_userreste le dernier garde-fou de cohérence ; - les verrous applicatifs évitent de prendre l'exception d'unicité comme mécanisme normal de synchronisation.
Validation attendue
Les tests PostgreSQL couvrent :
- état public identique pour adresse inconnue, compte libre et compte déjà rattaché ;
- même contrat de suivi/audit entre payload email-only et payload profil ;
- remplacement concurrent d'une demande avec un seul
pendingfinal ; - expiration d'une join request ;
- rapprochement réciproque séquentiel ;
- A→B et B→A simultanés, y compris avec les deux formes de payload, sans deadlock ;
- acceptation concurrente avec une nouvelle création sans demande actionnable résiduelle ;
- un seul couple actif et aucune demande publique
pendingaprès rapprochement ; - rendu frontend du suivi et de l'information d'activation automatique.
Références : #208, #209, PR #207, #205 et #210.