Como o Claude construiu DMs 1:1 prontos pra produção em uma noite: escopo, três camadas antiabuso, broadcasts Turbo por viewer e o polish que tive que adicionar.
A Pickful lançou DMs hoje. Conversa um a um, texto mais imagens, entrega em tempo real, janela de retração de 2 minutos, rate limit por faixas de pontos, três modos de privacidade, com respeito ao bloqueio — 38 arquivos, +1376 linhas, 41 specs, um único PR.
Este artigo não é "Claude é incrível". É sobre o que acontece de fato quando você pede pro Claude adicionar uma funcionalidade a um produto social com mais de um ano de estrada, em que os usuários vão tentar se assediar com tudo: quais decisões você tem que tomar sozinho, o que o Claude deixa passar na primeira leva e o que eu tive que costurar depois.
DM não é comentário. Comentário é praça pública — o que solta, qualquer um vê. DM é uma sala privada com exatamente duas pessoas. Ou seja, qualquer brecha no modelo de dados, na policy de autorização ou no canal de push vira "um estranho consegue lambuzar propaganda na cara de alguém". Então eu não disse "construa um chat". Travei o escopo com o Claude antes:
Claude pegou na hora o "afia a lâmina antes de decidir qual árvore cortar". Eu ia reutilizar isso uma dúzia de vezes: IA escreve código rápido, mas as fronteiras do produto quem decide é gente.
(A,B) e (B,A) duplicadosA armadilha clássica de modelagem de DM: A manda mensagem pra B e cria (A→B); B responde e cria (B→A); agora existem duas linhas pra mesma conversa e nada se sincroniza.
A solução do Claude entrou direto:
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 mais um índice único em (user_one_id, user_two_id) — não importa quem fala primeiro, os dois caem na mesma linha. Simples, sem migração pra reconciliar gêmeos, sem juntar registros espelho na camada de aplicação.
A consulta peer(viewer) sai limpa:
def peer(viewer)
viewer.id == user_one_id ? user_two : user_one
end
"Quem é o outro" é sempre um ternário de uma linha. Empurrar o invariante pra camada do banco é bem mais robusto do que reforçar isso no código.
Dois pedaços do estado da conversa são intrinsecamente por usuário:
O primeiro rascunho do Claude tinha uma tabela conversation_states com uma linha por usuário. Mandei parar — essa tabela tem no máximo 2 linhas por conversa, sempre 2. Uma tabela extra mais um join não compensa. Botamos as quatro colunas direto em 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
Leitura e escrita despacham conforme o viewer for one ou two:
def column_name(viewer, suffix)
viewer.id == user_one_id ? :"user_one_#{suffix}" : :"user_two_#{suffix}"
end
Um pouco mais de viewer dispatch no código, uma tabela join e um conjunto de índices a menos no schema. Uma relação cuja cardinalidade é permanentemente limitada a N não merece uma tabela própria — é um julgamento de design de banco: Claude propõe, você decide.
Coloquei no prompt sem rodeio: "DM só sobe se as defesas antiassédio vierem ativas por padrão, todos os toggles apontando pro lado seguro." Claude devolveu três camadas de defesa que eu não toquei em uma vírgula:
| Camada | Mecanismo | Onde mora |
|---|---|---|
| Bloqueio pelo usuário | blocked_users.exists?(id: other.id) corta na hora |
can_be_dmed_by? |
| Três modos de privacidade | enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } |
User concern |
| Rate limit por faixa de pontos | <50pts → 10/h, <500pts → 60/h, demais 300/h | dm_hourly_limit |
A terceira camada quem pediu fui eu. Spam de conta descartável é o desastre garantido do primeiro dia de DM em qualquer produto social. A primeira versão do Claude tinha "60/h pra todo mundo" travado — pedi pra escalonar por reputação: quase mudo pras contas novas de custo zero, generoso pros ativos de verdade. Esse "não imponha justiça aritmética no código, projete justiça entre faixas de usuário" é julgamento de produto, não de engenharia.
A porteira inteira mora numa policy do Pundit, sem desvio:
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
Cada return false corresponde a um cenário concreto de abuso. O código lê como checklist — que é como uma policy deve parecer.
Retratar não é opcional (erro de digitação, imagem errada, pessoa errada), mas os detalhes derrubam fácil:
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
Os pedaços:
deleted_at, esvazia o conteúdo, mantém a linhapurge_later joga a limpeza pro background, economiza storage"Apaga o conteúdo, guarda o registro" é o padrão de mensageria privada. Claude acertou a lógica de primeira, mas image.purge_later foi correção minha — a versão original era image.purge, que bloqueia a resposta.
Pro tempo real falei pro Claude usar Hotwire/Turbo, já que a app toda é nessa stack. O interessante é como recortar os canais 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
O nome do canal inclui viewer.id — a mesma mensagem é emitida duas vezes, em dois streams distintos, renderizando com um viewer diferente cada vez.
Por quê? Porque a partial da mensagem tem ramificações em "fui eu que mandei?" e "posso retratar?" — o render resulta genuinamente diferente por viewer. Se a gente emite um payload só e deixa o cliente decidir, ou expõe sender_id no front, ou escreve um monte de CSS condicional — nenhuma das opções é tão limpa quanto renderizar duas vezes no servidor.
O badge do inbox usa o mesmo modelo:
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 }
)
)
Um stream por usuário, cada um atualizando seu próprio contador de não lidos.
Com o PR dentro, passei umas duas horas usando o produto de verdade e achei três coisas que o Claude não tinha previsto. Subi tudo num commit de polish:
1. Conversa vazia não pode aparecer de nenhum dos lados
Cliquei no botão "Enviar DM" de outra pessoa. A linha da conversa foi criada, mas eu não tinha escrito nada — e a outra pessoa já via uma conversa vazia no inbox dela. Socialmente esquisito: "se você não vai falar nada, não me amola".
Adicionei um scope:
scope :visible_to, ->(user) {
for_user(user)
.where.not(last_message_at: nil)
.where(...hidden_at IS NULL...)
}
Conversas com last_message_at IS NULL ficam invisíveis pros dois lados. A primeira mensagem real chama bump_last_message!, carimba o timestamp e a conversa aparece. Claude não pensou nisso — focou em "funciona?", não em "e se o usuário desistir no meio?".
2. O botão "Enviar DM" no perfil tava empilhado abaixo do Follow e apertado
O layout do Claude empilhava os botões na vertical, e o nome do tooltip do heroicon "Chat-bubble-left-right" estava vazando. Mudei pra ficar lado a lado com o Follow, só ícone, com title explícito cobrindo o vazamento.
3. Avatares quebravam tamanho no inbox e no header da thread
.avatar-wrapper tinha um width lock brigando com as utilidades w-X h-X do Tailwind. Alinhei com a convenção da partial user_card: tira o wrapper, usa w-X h-X rounded-full object-cover direto.
Cada um isolado é pequeno. Juntos são a distância entre "funciona" e "encaixa de verdade". Claude escreve código rápido, mas esse tipo de polimento que só aparece usando o produto não dá pra delegar — ele não tem olhos, dedos, nem memória muscular das outras telas do mesmo app.
1. Escopo é trabalho de humano, não de IA.
"Sem grupos, sem leitura, sem reactions" valeu mais que cada linha de código que o Claude escreveu. Solta qualquer uma e o prazo dobra. Claude não vai oferecer corte de escopo porque não conhece seu cronograma de release.
2. Empurra os invariantes pro banco.
[a.id, b.id].sort com índice único, validação distinct_participants, cascade do FK — tudo proposto pelo Claude, mas só depois de eu dizer "não existem conversas espelho", "nada de DM pra si mesmo", "delete arrasta limpo". Quanto mais apertada a restrição, menos bug sobrevive.
3. Antiabuso mora nos specs.
Dos 41 specs, ~60% são testes de policy: bloqueio funciona, modo de privacidade funciona, rate limit funciona. Esses specs não testam "a feature roda" — testam "o abuso é barrado". Um spec por cenário. Claude consegue listar, mas só depois de você dizer quais cenários quer defender.
4. Polish de UX exige mão na massa.
Duas horas de interação real revelaram três coisas que nem code review nem suíte verde pegariam. Não tem substituto.
De "quero isso" a "usuários conseguem usar": PR principal entrou 21:53, polish 23:36 — uma noite. Mas o recurso escasso não foi o tempo de escrever código, foi o tempo de pensar a fundo escopo, autorização e defesas antiabuso. Acabar o código é só o começo; usar o produto você mesmo e tirar as rebarbas é o que torna a coisa lançável.