Wie Claude an einem Abend produktionsreife 1:1-DMs gebaut hat: Scope, drei Anti-Missbrauchs-Ebenen, Turbo pro Viewer und der Polish, den ich danach ergänzen musste.
Pickful hat heute Direktnachrichten ausgeliefert. Eins-zu-eins-Chat, Text plus Bilder, Echtzeit-Zustellung, 2-Minuten-Zurückziehfenster, punktegestaffeltes Rate-Limit, drei Privacy-Modi, Block-aware — 38 Dateien, +1376 Zeilen, 41 Specs, ein PR.
Dieser Artikel ist kein "Claude ist großartig". Er handelt davon, was wirklich passiert, wenn du Claude bittest, einem über ein Jahr alten Social-Produkt ein Feature hinzuzufügen, bei dem Nutzer absolut versuchen werden, einander zu belästigen: welche Entscheidungen du selbst treffen musst, was Claude beim ersten Durchgang übersieht und was ich danach flicken musste.
DMs sind keine Kommentare. Kommentare sind ein öffentlicher Platz — was du rauswirfst, sieht jeder. DMs sind ein privater Raum mit genau zwei Personen. Das heißt, jede Lücke im Datenmodell, in der Authorization-Policy oder im Push-Kanal übersetzt sich direkt in "ein Fremder kann jemandem Werbung ins Gesicht klatschen". Also habe ich nicht "bau einen Chat" gesagt. Ich habe zuerst den Scope mit Claude festgezurrt:
Claude hat "schärfe erst die Klinge, dann entscheide, welchen Baum du fällst" sofort verstanden. Dieses Prinzip habe ich noch ein Dutzend Mal wiederverwendet: AI schreibt Code schnell, aber die Produktgrenzen sind eine menschliche Entscheidung.
(A,B) und (B,A) vermeidenDie klassische DM-Modellfalle: A schreibt B und legt (A→B) an; B antwortet und legt (B→A) an; jetzt existieren zwei Zeilen für dieselbe Konversation und nichts ist synchron.
Claudes Lösung habe ich direkt übernommen:
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 Unique-Index auf (user_one_id, user_two_id) — egal, wer zuerst spricht, beide landen in derselben Zeile. Einfach, keine Migration zum Aufräumen von Zwillingen, kein Application-Layer-Join von Spiegel-Records.
Der peer(viewer)-Lookup fällt sauber raus:
def peer(viewer)
viewer.id == user_one_id ? user_two : user_one
end
"Wer ist der andere" ist immer ein Einzeiler-Ternary. Invarianten in den DB-Layer zu drücken ist deutlich robuster, als sie im Application-Code zu erzwingen.
Zwei Teile des Konversationszustands sind intrinsisch pro Nutzer:
Im ersten Entwurf wollte Claude eine conversation_states-Tabelle mit einer Zeile pro Nutzer. Ich habe gestoppt — diese Tabelle ist pro Konversation auf 2 Zeilen begrenzt, für immer. Eine Extra-Tabelle plus Join lohnt sich nicht. Wir haben die vier Spalten direkt in conversations gelegt:
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
Lese-/Schreibzugriff dispatched, je nachdem auf welcher Seite der Viewer steht:
def column_name(viewer, suffix)
viewer.id == user_one_id ? :"user_one_#{suffix}" : :"user_two_#{suffix}"
end
Etwas mehr Viewer-Dispatch im Code, eine Join-Tabelle und ein Index-Set weniger im Schema. Eine Beziehung mit dauerhaft auf N begrenzter Kardinalität verdient keine eigene Tabelle — das ist eine DB-Design-Entscheidung: Claude schlägt vor, du entscheidest.
Ich habe es explizit in den Prompt geschrieben: "DMs gehen nur live, wenn die Anti-Missbrauchsabwehr per Default aktiv ist, alle Toggles zur sicheren Seite zeigen." Claude lieferte drei Verteidigungsebenen zurück, an denen ich kein Komma geändert habe:
| Ebene | Mechanismus | Wo es lebt |
|---|---|---|
| Nutzer-initiierter Block | blocked_users.exists?(id: other.id) Kurzschluss |
can_be_dmed_by? |
| Drei Privacy-Modi | enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } |
User concern |
| Punktegestaffeltes Rate-Limit | <50pts → 10/h, <500pts → 60/h, sonst 300/h | dm_hourly_limit |
Die dritte Ebene habe ich verlangt. Spam von Wegwerf-Accounts ist die garantierte Katastrophe am ersten Tag von DMs in jedem Social-Produkt. Claudes erste Version hatte flach "60/h für alle" — ich habe ihn gestaffelt nach Reputation: fast stumm für kostenlose neue Accounts, großzügig für wirklich aktive Nutzer. Diese "Erzwinge Fairness nicht arithmetisch im Code, designe Fairness zwischen Nutzergruppen"-Logik ist Produkt-, keine Engineering-Entscheidung.
Das volle Gate sitzt in einer Pundit-Policy, kein 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
Jedes return false entspricht einem konkreten Missbrauchsszenario. Der Code liest sich wie eine Checkliste — so muss eine Policy aussehen.
Zurückziehen ist Pflicht (Tippfehler, falsches Bild, falsche Person), aber bei Details fällt man leicht hin:
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
Die Teile:
deleted_at-Zeitstempel, Inhalt leeren, Zeile behaltenpurge_later schiebt Anhang-Cleanup in den Hintergrund, spart Storage"Lösche den Inhalt, behalte den Record" ist das Standardmuster für private Messaging. Claude hat die Logik beim ersten Versuch richtig geschrieben, aber image.purge_later war meine Korrektur — sein Original image.purge blockiert die Response.
Für Echtzeit habe ich Claude gesagt, er soll Hotwire/Turbo nutzen, weil die ganze App auf diesem Stack läuft. Interessant ist, wie man die Broadcast-Channels zuschneidet:
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
Der Channel-Name enthält viewer.id — dieselbe Nachricht wird zweimal gebroadcastet, auf zwei verschiedenen Streams, jedes Mal mit einem anderen viewer gerendert.
Warum? Weil die Message-Partial auf "habe ich das gesendet?" und "kann ich es zurückziehen?" verzweigt — das Render-Ergebnis ist pro Viewer genuin verschieden. Wenn wir nur ein Payload broadcasten und den Client entscheiden lassen, müssen wir entweder sender_id ans Frontend rausgeben oder einen Haufen conditional CSS schreiben — keine Variante so sauber wie zweimal serverseitig rendern.
Das Inbox-Badge nutzt dasselbe Modell:
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 }
)
)
Ein Stream pro Nutzer, jeder aktualisiert seinen eigenen Ungelesen-Counter.
Sobald der PR drin war, habe ich mich zwei Stunden mit dem echten Produkt hingesetzt und drei Dinge gefunden, die Claude nicht antizipiert hatte. Habe sie in einem Polish-Commit zusammengepackt:
1. Leere Konversationen dürfen auf keiner Seite auftauchen
Ich habe den "DM senden"-Button von jemand anderem geklickt. Die Konversationszeile wurde angelegt, aber ich hatte noch nichts geschrieben — und die andere Person sah bereits eine leere Konversation in ihrer Inbox. Sozial seltsam: "Wenn du nichts sagen willst, störe mich nicht."
Ein Scope hinzugefügt:
scope :visible_to, ->(user) {
for_user(user)
.where.not(last_message_at: nil)
.where(...hidden_at IS NULL...)
}
Konversationen mit last_message_at IS NULL sind beidseitig unsichtbar. Die erste echte Nachricht ruft bump_last_message!, stempelt den Timestamp, und die Konversation taucht auf. Claude hat das nicht überlegt — er hat sich auf "funktioniert es?" konzentriert, nicht auf "was, wenn der Nutzer mitten im Flow umentscheidet?".
2. Der "DM senden"-Button auf dem Profil war unter Follow gestapelt und gedrängt
Claudes Layout stapelte die Buttons vertikal, plus der "Chat-bubble-left-right"-Heroicon-Tooltip-Name leakte raus. Ich habe es neben Follow gesetzt, nur Icon, mit explizitem title, der den geleakten Tooltip überschreibt.
3. Avatare brachen in Größe in Inbox und Thread-Header
.avatar-wrapper hatte einen Width-Lock im Streit mit Tailwinds w-X h-X-Utilities. An die Konvention der user_card-Partial angepasst: Wrapper raus, direkt w-X h-X rounded-full object-cover nutzen.
Jede der drei ist isoliert klein. Zusammen sind sie der Abstand zwischen "funktioniert" und "passt wirklich". Claude schreibt Code schnell, aber die Sorte Polish, die du nur bemerkst, wenn du das Produkt benutzt, kannst du nicht delegieren — er hat keine Augen, keine Finger, kein Muskelgedächtnis von anderen Seiten derselben App.
1. Scope ist eine menschliche Aufgabe, keine AI-Aufgabe.
"Keine Gruppen, keine Lesebestätigungen, keine Reactions" war jede Zeile Code wert, die Claude geschrieben hat. Lockere eines, und der Zeitplan verdoppelt sich. Claude bietet nicht von sich aus Scope-Schnitte an, weil er deinen Release-Plan nicht kennt.
2. Drück Invarianten in die DB.
[a.id, b.id].sort plus Unique-Index, distinct_participants-Validation, FK-Cascade — alles Claudes Vorschlag, aber erst nachdem ich gesagt habe "keine Spiegel-Konversationen", "kein Self-DM", "Delete cascadiert sauber". Je strenger das Constraint, desto weniger Bugs überleben.
3. Anti-Missbrauch wird in den Specs ausgedrückt.
Von den 41 Specs sind ~60% Policy-Tests: Block wirkt, Privacy-Modus wirkt, Rate-Limit wirkt. Diese Specs testen nicht "läuft das Feature?" — sie testen "wird Missbrauch geblockt?". Ein Spec pro Szenario. Claude kann die Liste aufzählen, aber erst nachdem du ihm gesagt hast, welche Szenarien du verteidigst.
4. UX-Polish braucht Hände am echten Produkt.
Zwei Stunden echte Interaktion förderten drei Dinge zutage, die Code-Review oder grüne Test-Suite nie abgefangen hätten. Kein Ersatz.
Von "ich will das" zu "Nutzer nutzen es": Haupt-PR um 21:53, Polish um 23:36 — ein Abend. Aber die knappe Ressource war nicht die Zeit zum Schreiben des Codes, sondern die Zeit, Scope, Authorization und Anti-Missbrauchsabwehr zu Ende zu denken. Den Code fertig zu schreiben ist erst der Anfang; das Produkt selbst zu nutzen und die Grate abzuschleifen ist das, was ihn lieferbar macht.