Come Claude ha costruito DM 1:1 pronti per la produzione in una sera: scope, tre strati anti-abuso, broadcast Turbo per viewer e il polish che ho dovuto aggiungere.
Pickful ha rilasciato i messaggi diretti oggi. Chat uno-a-uno, testo più immagini, consegna in tempo reale, finestra di ritrattazione di 2 minuti, rate limit a fasce di punti, tre modalità di privacy, rispetto del blocco — 38 file, +1376 righe, 41 spec, una sola PR.
Questo articolo non è "Claude è incredibile". Parla di cosa succede davvero quando chiedi a Claude di aggiungere una feature a un prodotto sociale in piedi da più di un anno, dove gli utenti proveranno seriamente a molestarsi: quali decisioni devi prendere tu, cosa Claude si perde alla prima passata, cosa ho dovuto rattoppare dopo.
I DM non sono i commenti. I commenti sono una piazza pubblica — quello che lanci lo vedono tutti. I DM sono una stanza privata con esattamente due persone. Quindi ogni buco nel modello dati, nella policy di autorizzazione o nel canale push si traduce direttamente in "uno sconosciuto può attaccare pubblicità in faccia a qualcuno". Per cui non ho detto "fai una chat". Ho prima blindato lo scope con Claude:
Claude ha colto subito "affila la lama prima di decidere quale albero abbattere". Lo riuserò una dozzina di volte: l'AI scrive codice veloce, ma i confini del prodotto sono una decisione umana.
(A,B) e (B,A) duplicateLa trappola classica della modellazione DM: A scrive a B e crea (A→B); B risponde e crea (B→A); ora esistono due righe per la stessa conversazione e niente è sincronizzato.
La soluzione di Claude l'ho presa di peso:
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 più un indice unico su (user_one_id, user_two_id) — chiunque parli per primo, entrambi atterrano sulla stessa riga. Semplice, niente migration per riconciliare gemelli, niente join di record speculari a livello applicativo.
Il lookup peer(viewer) viene pulito:
def peer(viewer)
viewer.id == user_one_id ? user_two : user_one
end
"Chi è l'altro" è sempre un ternario di una riga. Spingere l'invariante nel layer database è molto più solido che imporlo nel codice applicativo.
Due pezzi dello stato della conversazione sono intrinsecamente per utente:
Nella prima bozza Claude scrisse una tabella conversation_states una riga per utente. L'ho fermato — quella tabella ha sempre al massimo 2 righe per conversazione, sempre 2. Una tabella in più con un join non ne vale la pena. Abbiamo messo le quattro colonne direttamente in 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
Lettura e scrittura fanno dispatch in base al lato del viewer:
def column_name(viewer, suffix)
viewer.id == user_one_id ? :"user_one_#{suffix}" : :"user_two_#{suffix}"
end
Un po' di viewer dispatch nel codice, una tabella join e un set di indici in meno nello schema. Una relazione la cui cardinalità è permanentemente limitata a N non si merita una tabella propria — è un giudizio di design del database: Claude propone, tu decidi.
L'ho scritto esplicito nel prompt: "I DM escono solo se le difese anti-abuso sono attive per default, tutti i toggle puntano al lato sicuro". Claude ha restituito tre strati di difesa che non ho cambiato di una virgola:
| Strato | Meccanismo | Dove vive |
|---|---|---|
| Blocco dall'utente | blocked_users.exists?(id: other.id) corto circuita |
can_be_dmed_by? |
| Tre modalità di privacy | enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } |
User concern |
| Rate limit a fasce di punti | <50pts → 10/h, <500pts → 60/h, resto 300/h | dm_hourly_limit |
Il terzo strato l'ho chiesto io. Lo spam da account usa-e-getta è il disastro garantito al primo giorno dei DM in qualsiasi prodotto sociale. La prima passata di Claude aveva "60/h per tutti" fissi — gli ho fatto scaglionarli per punti di reputazione: quasi muti gli account nuovi a costo zero, generosi per gli utenti veramente attivi. La logica "non imporre l'equità aritmetica nel codice, progetta l'equità tra fasce di utenti" è un giudizio di prodotto, non d'ingegneria.
Il cancello completo vive in una policy Pundit, niente 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
Ogni return false corrisponde a uno scenario d'abuso concreto. Il codice si legge come una checklist — che è come deve apparire una policy.
Ritrattare è obbligatorio (refusi, immagine sbagliata, persona sbagliata), ma sui dettagli si inciampa facile:
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
I pezzi:
deleted_at, svuota il contenuto, conserva la rigapurge_later mette in coda la pulizia allegati in background, risparmia storage"Cancella il contenuto, conserva il record" è il pattern standard del messaging privato. Claude ha scritto la logica giusta al primo colpo, ma image.purge_later è stata mia correzione — l'originale era image.purge, che blocca la response.
Per il real-time ho detto a Claude di usare Hotwire/Turbo perché tutta l'app è su quello stack. Il punto interessante è come si tagliano i canali di 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
Il nome del canale include viewer.id — lo stesso messaggio viene trasmesso due volte, su due stream distinti, renderizzando con un viewer diverso ogni volta.
Perché? Perché la partial del messaggio si dirama su "l'ho mandato io?" e "lo posso ritrattare?" — il render risulta genuinamente diverso per viewer. Se trasmetti un solo payload e lasci decidere al client, o esponi sender_id al frontend o scrivi un mucchio di CSS condizionale — nessuna pulita quanto renderizzare due volte sul server.
Il badge dell'inbox usa lo stesso modello:
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 }
)
)
Uno stream per utente, ciascuno aggiorna il proprio contatore di non letti.
Una volta entrato il PR mi sono seduto col vero prodotto un paio d'ore e ho trovato tre cose che Claude non aveva previsto. Le ho infilate in un commit di polish:
1. Le conversazioni vuote non devono apparire da nessuno dei due lati
Ho cliccato il bottone "Invia DM" di un'altra persona. La riga della conversazione è stata creata, ma io non avevo ancora scritto nulla — e l'altra persona vedeva già una conversazione vuota nella sua inbox. Socialmente strano: "se non vuoi dire niente, non disturbarmi".
Aggiunto uno scope:
scope :visible_to, ->(user) {
for_user(user)
.where.not(last_message_at: nil)
.where(...hidden_at IS NULL...)
}
Le conversazioni con last_message_at IS NULL non sono visibili da nessun lato. Il primo messaggio vero chiama bump_last_message!, timestamp impresso, e la conversazione affiora. Claude non ci ha pensato — era concentrato su "funziona?", non su "e se l'utente cambia idea a metà flusso?".
2. Il bottone "Invia DM" sul profilo era impilato sotto Follow e troppo stretto
Il layout di Claude impilava i bottoni verticalmente, più il nome del tooltip dell'heroicon "Chat-bubble-left-right" perdeva. L'ho messo affianco a Follow, solo icona, con title esplicito che copre il tooltip leaked.
3. Gli avatar collassavano in inbox e header del thread
.avatar-wrapper aveva un width lock che litigava con le utility w-X h-X di Tailwind. Allineato alla convenzione della partial user_card: via il wrapper, direttamente w-X h-X rounded-full object-cover.
Ognuno dei tre è piccolo da solo. Insieme sono la distanza tra "funziona" e "ci sta davvero". Claude scrive codice veloce, ma il tipo di polish che scopri solo usando il prodotto non si delega — non ha occhi, dita, né memoria muscolare delle altre pagine della stessa app.
1. Lo scope è lavoro umano, non da AI.
"Niente gruppi, niente conferme di lettura, niente reaction" valeva più di ogni riga di codice scritta da Claude. Allenta uno solo, il calendario raddoppia. Claude non propone tagli di scope da solo perché non conosce il tuo calendario di release.
2. Spingi gli invarianti nel database.
[a.id, b.id].sort con indice unico, validazione distinct_participants, cascade FK — tutto proposto da Claude, ma solo dopo che ho detto "niente conversazioni speculari", "niente self-DM", "delete cascade pulito". Più stretto è il constraint, meno bug sopravvivono.
3. L'anti-abuso si esprime nelle spec.
Delle 41 spec, ~60% sono test di policy: blocco effettivo, modalità privacy effettiva, rate limit effettivo. Quelle spec non testano "la feature gira?" — testano "l'abuso è bloccato?". Una spec per scenario. Claude può elencare la lista, ma solo dopo che gli dici quali scenari stai difendendo.
4. Il polish UX richiede mani sul vero prodotto.
Due ore di interazione reale hanno tirato fuori tre cose che né code review né suite di test verde avrebbero mai preso. Non c'è sostituto.
Da "voglio questo" a "gli utenti lo usano": PR principale alle 21:53, polish alle 23:36 — una serata. Ma la risorsa scarsa non era il tempo per scrivere codice, era il tempo per pensare a fondo scope, autorizzazione e difese anti-abuso. Finire il codice è solo l'inizio; usare il prodotto da te e limare le sbavature è ciò che lo rende rilasciabile.