Aller au contenu

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_request authentifié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, closed ou expired ;
  • 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 :

  1. advisory lock de la paire d'e-mails canoniques triée ;
  2. advisory lock de l'e-mail cible ;
  3. lignes users concernées verrouillées en ordre d'e-mail canonique ;
  4. couple pending de l'initiateur ;
  5. 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_request interne 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 :

  1. 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é ;
  2. les deux couples pending sont verrouillés dans un ordre déterministe ;
  3. le couple gagnant est le plus ancien, puis l'UUID départage une égalité ;
  4. la membership du créateur du couple perdant passe à removed ;
  5. ce membre rejoint le couple gagnant ;
  6. le couple gagnant devient active, le doublon pending devient deleted ;
  7. les deux demandes réciproques deviennent accepted et 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_id ou user_id faisant 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 pending jusqu'à fermeture/expiration ;
  • l'index PostgreSQL one_active_couple_per_user reste 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 pending final ;
  • 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 pending après rapprochement ;
  • rendu frontend du suivi et de l'information d'activation automatique.

Références : #208, #209, PR #207, #205 et #210.