Free

Claude eine produktionsreife 1:1-Direktnachricht bauen lassen

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.

Scope vor der ersten Zeile festziehen

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:

  • Nur 1:1 — Gruppen bringen Mitgliederverwaltung, @Mentions, Wer-sieht-was; Komplexität mindestens ×3
  • Keine Lesebestätigungen — privacysensitiv, verschoben, aber Platz für ein Opt-in-Toggle später gelassen
  • Keine Emoji-Reactions — in einem 1:1-Raum verschicken Leute eh Sticker, Reactions sind niedrig priorisiert

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.

Ein Paar = eine Konversation: (A,B) und (B,A) vermeiden

Die 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.

Zustand auf beide Seiten geteilt

Zwei Teile des Konversationszustands sind intrinsisch pro Nutzer:

  • Gelesen — A liest, heißt nicht, dass B gelesen hat
  • Versteckt — A löscht die Konversation, sie darf nicht von B verschwinden

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.

Anti-Belästigung ab Tag eins dick

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.

2-Minuten-Zurückziehen, Soft Delete, Audit-Trail

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:

  • Soft Deletedeleted_at-Zeitstempel, Inhalt leeren, Zeile behalten
  • Bild echt löschenpurge_later schiebt Anhang-Cleanup in den Hintergrund, spart Storage
  • Hartes 2-Minuten-Fenster — kein Ausgraben von Nachrichten von vor 6 Monaten zum Zurückziehen
  • Zeile bleibt — für Compliance/Audit; Turbo-Broadcast ersetzt das Render durch einen "zurückgezogen"-Placeholder

"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.

Turbo broadcastet pro Viewer, nicht pro Konversation

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.

Was ich nach Claudes PR-Merge geflickt habe

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.

Was mich dieser Build gelehrt hat

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.