Comment Claude a livré des DM 1:1 prêts à expédier en une soirée : périmètre, trois couches anti-abus, Turbo par viewer et le polish que j'ai dû ajouter après.
Pickful a livré les messages privés aujourd'hui. Chat en 1 à 1, texte plus images, livraison en temps réel, fenêtre de rétractation de 2 minutes, rate limit par paliers de points, trois modes de confidentialité, respect du blocage — 38 fichiers, +1376 lignes, 41 specs, une seule PR.
Cet article n'est pas du "Claude est incroyable". Il parle de ce qui se passe vraiment quand vous demandez à Claude d'ajouter une fonctionnalité à un produit social qui tourne depuis plus d'un an, où les utilisateurs vont absolument essayer de se harceler : quelles décisions vous devez prendre vous-même, ce que Claude rate au premier passage, ce que j'ai dû rattraper après coup.
Les DM ne sont pas des commentaires. Les commentaires, c'est la place publique — ce qu'on y jette, tout le monde le voit. Les DM, c'est une pièce privée avec exactement deux personnes. Donc chaque trou dans le modèle de données, la policy d'autorisation, ou le canal de push se traduit directement en "un inconnu peut coller une pub sur la tronche de quelqu'un". Du coup, je n'ai pas dit "construis un chat". J'ai d'abord verrouillé le périmètre avec Claude :
Claude a saisi tout de suite le "affûte la lame avant de décider quel arbre tu coupes". J'allais réutiliser ce principe une douzaine de fois : l'IA écrit du code vite, mais les frontières du produit sont une décision humaine.
(A,B) et (B,A)Le piège classique de la modélisation DM : A envoie un message à B et crée (A→B) ; B répond et crée (B→A) ; il existe maintenant deux lignes pour la même conversation et rien n'est synchronisé.
La solution que Claude a proposée, je l'ai prise telle quelle :
def self.between!(a, b)
raise ArgumentError, "cannot create conversation with self" if a.id == b.id
one_id, two_id = [a.id, b.id].sort
find_or_create_by!(user_one_id: one_id, user_two_id: two_id)
end
[a.id, b.id].sort plus un index unique sur (user_one_id, user_two_id) — peu importe qui parle en premier, les deux atterrissent sur la même ligne. Simple, pas de migration pour réconcilier des jumelles, pas de jointure d'enregistrements miroirs en couche applicative.
Le lookup peer(viewer) tombe propre :
def peer(viewer)
viewer.id == user_one_id ? user_two : user_one
end
"Qui est l'autre" est toujours un ternaire d'une ligne. Pousser l'invariant dans la couche base de données est bien plus robuste que de l'imposer dans le code applicatif.
Deux morceaux de l'état de la conversation sont intrinsèquement par utilisateur :
Le premier brouillon de Claude proposait une table conversation_states avec une ligne par utilisateur. Je l'ai arrêté — cette table est plafonnée pour toujours à 2 lignes par conversation, toujours 2. Une table en plus avec un join ne vaut pas le coup. On a mis les quatre colonnes directement dans conversations :
t.datetime :user_one_last_read_at
t.datetime :user_two_last_read_at
t.datetime :user_one_hidden_at
t.datetime :user_two_hidden_at
Lecture et écriture dispatchent selon le côté du viewer :
def column_name(viewer, suffix)
viewer.id == user_one_id ? :"user_one_#{suffix}" : :"user_two_#{suffix}"
end
Un peu de viewer dispatch dans le code, une table de jointure et un set d'index en moins dans le schéma. Une relation dont la cardinalité est plafonnée pour toujours à N ne mérite pas sa propre table — c'est une décision de design DB : Claude propose, vous décidez.
Je l'ai mis explicitement dans le prompt : "Les DM ne sortent que si les défenses anti-abus sont actives par défaut, tous les toggles côté sûr." Claude a rendu trois couches de défense que je n'ai pas modifiées d'un iota :
| Couche | Mécanisme | Où ça vit |
|---|---|---|
| Blocage par l'utilisateur | blocked_users.exists?(id: other.id) court-circuite |
can_be_dmed_by? |
| Trois modes de confidentialité | enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } |
User concern |
| Rate limit par paliers de points | <50pts → 10/h, <500pts → 60/h, reste 300/h | dm_hourly_limit |
La troisième couche c'est moi qui l'ai demandée. Le spam depuis des comptes jetables est la catastrophe garantie du premier jour de DM dans n'importe quel produit social. La première passe de Claude était "60/h pour tout le monde" en dur — je lui ai demandé de l'étager par réputation : quasi muets pour les nouveaux comptes coût zéro, généreux pour les utilisateurs vraiment actifs. Cette logique de "n'imposez pas l'équité arithmétique dans le code, designez l'équité entre paliers d'utilisateurs" est un jugement produit, pas d'ingénierie.
Le portail complet vit dans une policy Pundit, sans bypass :
def create?
return false unless user
return false unless record.conversation&.participant?(user)
recipient = record.conversation.peer(user)
return false unless recipient
return false unless recipient.can_be_dmed_by?(user)
return false if user.dm_rate_limited?
true
end
Chaque return false correspond à un scénario d'abus concret. Le code se lit comme une checklist — c'est à ça qu'une policy doit ressembler.
La rétractation est obligatoire (faute de frappe, mauvaise image, mauvaise personne), mais les détails se loupent vite :
RETRACT_WINDOW = 2.minutes
def retractable_by?(user)
!deleted? && sender_id == user&.id && created_at >= RETRACT_WINDOW.ago
end
def retract!
update!(deleted_at: Time.current, content: nil)
image.purge_later if image.attached?
end
Les morceaux :
deleted_at, contenu vidé, ligne préservéepurge_later met le nettoyage de pièce jointe en background, économise du stockage"Efface le contenu, garde l'enregistrement" est le pattern standard de la messagerie privée. Claude a écrit la logique correctement du premier coup, mais image.purge_later c'est moi qui l'ai corrigé — son original image.purge bloque la réponse.
Pour le temps réel j'ai dit à Claude d'utiliser Hotwire/Turbo puisque toute l'app est sur cette stack. L'intéressant c'est comment découper les canaux de broadcast :
def broadcast_to_thread
[conversation.user_one, conversation.user_two].each do |viewer|
broadcast_append_to(
"conversation_#{conversation_id}_user_#{viewer.id}",
target: "conversation_#{conversation_id}_messages",
partial: "direct_messages/direct_message",
locals: { message: self, viewer: viewer }
)
end
end
Le nom du canal contient viewer.id — un même message est broadcasté deux fois, sur deux streams distincts, en rendant avec un viewer différent à chaque fois.
Pourquoi ? Parce que la partial du message branche sur "est-ce que c'est moi qui l'ai envoyé ?" et "est-ce que je peux le rétracter ?" — le rendu est génuinement différent par viewer. Si on broadcaste un payload unique et qu'on laisse le client décider, soit on expose sender_id au front, soit on écrit un paquet de CSS conditionnel — aucune solution aussi propre que de rendre deux fois côté serveur.
Le badge de l'inbox utilise le même modèle :
Turbo::StreamsChannel.broadcast_replace_to(
"user_#{recipient_id}_inbox",
target: "dm_inbox_badge",
html: ApplicationController.render(
partial: "shared/dm_inbox_badge",
locals: { unread: recipient.total_unread_dm_count }
)
)
Un stream par utilisateur, chacun rafraîchit son propre compteur de non-lus.
Une fois la PR passée, je me suis posé avec le vrai produit pendant deux heures et j'ai trouvé trois choses que Claude n'avait pas anticipées. Tout est passé dans un commit de polish :
1. Une conversation vide ne doit pas s'afficher des deux côtés
J'ai cliqué le bouton "Envoyer un DM" d'une autre personne. La ligne de conversation a été créée, mais je n'avais encore rien écrit — et l'autre personne voyait déjà une conversation vide dans son inbox. Socialement bizarre : "si tu n'as rien à dire, ne me dérange pas".
Ajout d'un scope :
scope :visible_to, ->(user) {
for_user(user)
.where.not(last_message_at: nil)
.where(...hidden_at IS NULL...)
}
Les conversations avec last_message_at IS NULL sont invisibles des deux côtés. Le premier vrai message appelle bump_last_message!, le timestamp est posé, la conversation remonte. Claude n'y a pas pensé — il se concentrait sur "ça marche ?", pas sur "et si l'utilisateur change d'avis au milieu du flux ?".
2. Le bouton "Envoyer un DM" sur le profil était empilé sous Follow, trop serré
Le layout de Claude empilait les boutons verticalement, en plus le nom du tooltip heroicon "Chat-bubble-left-right" sortait. Je l'ai mis à côté de Follow, en icône seule, avec un title explicite pour écraser le tooltip qui fuit.
3. Les avatars s'écrasaient dans l'inbox et le header du thread
.avatar-wrapper avait un width lock en bagarre avec les utilities w-X h-X de Tailwind. Aligné sur la convention de la partial user_card : on enlève le wrapper, on applique directement w-X h-X rounded-full object-cover.
Chacune des trois est petite isolément. Ensemble c'est la distance entre "ça marche" et "ça colle vraiment". Claude écrit du code vite, mais le genre de polish qu'on ne repère qu'en utilisant le produit ne se délègue pas — il n'a pas d'yeux, ni de doigts, ni de mémoire musculaire des autres pages de la même app.
1. Le périmètre est un travail humain, pas un travail d'IA.
"Pas de groupes, pas d'accusé, pas de reaction" valait plus que chaque ligne de code que Claude a écrite. Détend un seul de ces points et le calendrier double. Claude ne propose pas de couper le périmètre tout seul parce qu'il ne connaît pas votre calendrier de release.
2. Pousse les invariants dans la base.
[a.id, b.id].sort plus index unique, validation distinct_participants, cascade FK — tout proposé par Claude, mais seulement après que j'ai dit "pas de conversations miroirs", "pas de DM à soi-même", "delete cascade propre". Plus la contrainte est stricte, moins il reste de bugs.
3. L'anti-abus s'exprime dans les specs.
Sur les 41 specs, ~60% sont des tests de policy : blocage appliqué, mode de confidentialité appliqué, rate limit appliqué. Ces specs ne testent pas "la feature tourne ?" — elles testent "l'abus est-il bloqué ?". Un spec par scénario. Claude peut énumérer la liste, mais seulement après que vous lui ayez dit quels scénarios vous défendez.
4. Le polish UX exige les mains sur le vrai produit.
Deux heures d'interaction réelle ont fait remonter trois choses qu'aucune code review ni suite de tests verte n'aurait jamais attrapées. Pas de substitut.
De "je veux ça" à "les utilisateurs s'en servent" : la PR principale est tombée à 21:53, le polish à 23:36 — une seule soirée. Mais la ressource rare ce n'était pas le temps d'écrire du code, c'était le temps de penser à fond périmètre, autorisation et défenses anti-abus. Finir le code n'est qu'un début ; utiliser le produit soi-même et limer les bavures, c'est ce qui le rend livrable.