Cómo Claude construyó DMs 1:1 listos para producción en una noche: alcance, tres capas anti-abuso, broadcasts Turbo por viewer y el pulido que añadí después.
Pickful lanzó hoy los mensajes directos. Chat 1 a 1, texto e imágenes, entrega en tiempo real, ventana de retracción de 2 minutos, límite por tramos de puntos, tres modos de privacidad, respeto al bloqueo —38 archivos, +1376 líneas, 41 specs, un solo PR.
Este artículo no es "Claude es asombroso". Es sobre lo que pasa realmente cuando le pides a Claude que añada una función a un producto social con más de un año en marcha, donde la gente intentará acosarse en serio: qué decisiones tienes que tomar tú mismo, qué se le escapa a Claude en la primera pasada y qué tuve que arreglar después.
Los DM no son comentarios. Los comentarios son una plaza pública: lo que sueltas lo ve cualquiera. Los DM son una habitación privada con exactamente dos personas. Eso significa que cada hueco en el modelo de datos, la política de autorización o el canal de notificación se traduce directamente en "un desconocido puede echar mierda a la cara de otro". Así que no dije "haz un chat". Cerré el alcance con Claude primero:
Claude pilló al instante el "afila la hoja antes de decidir qué árbol talas". Lo iba a usar una docena de veces más: la IA escribe código rápido, pero las fronteras del producto las decide una persona.
(A,B) y (B,A) duplicadasLa trampa clásica al modelar DM: A le manda a B y crea (A→B); B responde y crea (B→A); ahora hay dos filas para la misma conversación y nada está sincronizado.
La solución que propuso Claude la tomé tal cual:
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 más un índice único sobre (user_one_id, user_two_id) —da igual quién hable primero, los dos lados aterrizan en la misma fila. Simple, sin migración para reconciliar gemelas, sin que la capa de aplicación tenga que unir registros espejo.
La consulta peer(viewer) sale limpia:
def peer(viewer)
viewer.id == user_one_id ? user_two : user_one
end
"Quién es el otro" siempre es un ternario de una línea. Empujar el invariante a la capa de base de datos es mucho más robusto que hacerlo en el código de aplicación.
Hay dos piezas de estado de la conversación que son intrínsecamente por usuario:
El primer borrador de Claude tenía una tabla conversation_states con una fila por usuario. Lo paré: esa tabla tiene como máximo 2 filas por conversación, siempre 2. Una tabla extra más un join no merece la pena. Pusimos las cuatro columnas directamente en 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
Lectura y escritura despachan según en qué lado esté el viewer:
def column_name(viewer, suffix)
viewer.id == user_one_id ? :"user_one_#{suffix}" : :"user_two_#{suffix}"
end
Un poco de viewer dispatch en código, una tabla join e índices menos en el esquema. Una relación con cardinalidad permanentemente acotada a N no merece su propia tabla —es un juicio de diseño de base de datos: Claude propone, tú decides.
Lo dejé explícito en el prompt: "Los DM solo se lanzan si las defensas anti-abuso vienen activas por defecto, todos los toggles apuntando al lado seguro." Claude devolvió tres capas de defensa que no toqué ni una coma:
| Capa | Mecanismo | Dónde vive |
|---|---|---|
| Bloqueo iniciado por usuario | blocked_users.exists?(id: other.id) corta en seco |
can_be_dmed_by? |
| Tres modos de privacidad | enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } |
User concern |
| Rate limit por tramos de puntos | <50pts → 10/h, <500pts → 60/h, resto 300/h | dm_hourly_limit |
La tercera capa la pedí yo. El spam desde cuentas desechables es el desastre garantizado del primer día de DMs en cualquier producto social. La primera pasada de Claude tenía un "60/h para todos" plano —le hice escalonarlo por reputación: casi mudos los nuevos de coste cero, holgados para los que están activos de verdad. Es un juicio de "no obligues a la equidad en el código, diseña la equidad entre tramos de usuario" —producto, no ingeniería.
La compuerta entera vive en una policy de Pundit, sin 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
Cada return false corresponde a un escenario concreto de abuso. El código se lee como una checklist —que es como tiene que parecer una policy.
Retractar no es opcional (erratas, imagen equivocada, persona equivocada), pero los detalles son fáciles de pifiar:
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
Las piezas:
deleted_at, vacía el contenido, conserva la filapurge_later encola la limpieza, ahorra almacenamiento"Limpia el contenido, conserva el registro" es el patrón estándar de mensajería privada. Claude clavó la lógica a la primera, pero image.purge_later fue mi arreglo —su versión original tenía image.purge, que bloquea la respuesta.
Para el tiempo real le dije a Claude que usara Hotwire/Turbo, ya que la app está sobre esa pila. Lo interesante es cómo trocear los canales 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
El nombre del canal incluye viewer.id —un mensaje se emite dos veces, a dos streams distintos, renderizando con un viewer diferente cada vez.
¿Por qué? Porque la partial del mensaje se ramifica según "¿lo mandé yo?" y "¿puedo retractarlo?" —el render resulta genuinamente distinto por viewer. Si emitimos un payload único y dejamos que decida el cliente, o exponemos sender_id al frontend o escribimos un montón de condicionales CSS —ninguna opción tan limpia como renderizar dos veces en el servidor.
El badge de inbox usa el mismo 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 }
)
)
Un stream por usuario, cada uno refrescando su propio contador de no leídos.
Una vez dentro el PR me senté a usar el producto de verdad un par de horas y encontré tres cosas que Claude no había anticipado. Las metí en un commit de pulido:
1. Las conversaciones vacías no pueden verse desde ningún lado
Pulsé el botón "Enviar DM" de otra persona. Se creó la fila de la conversación, pero yo no había escrito nada todavía —y la otra persona ya veía una conversación vacía en su inbox. Socialmente raro: "si no vas a decir nada, no me molestes".
Añadí un scope:
scope :visible_to, ->(user) {
for_user(user)
.where.not(last_message_at: nil)
.where(...hidden_at IS NULL...)
}
Las conversaciones con last_message_at IS NULL no son visibles para nadie. El primer mensaje real llama a bump_last_message!, que pone el timestamp y la conversación aparece. Claude no lo pensó —estaba centrado en "¿funciona?", no en "¿y si el usuario cambia de idea a mitad del flujo?".
2. El botón "Enviar DM" en el perfil estaba apilado bajo Follow y agobiado
El layout de Claude apilaba los botones en vertical, y el nombre del tooltip del heroicon "Chat-bubble-left-right" se filtraba. Lo cambié a la par de Follow, solo icono, con un title explícito que tapa el tooltip filtrado.
3. Los avatares se colapsaban en inbox y cabecera del hilo
.avatar-wrapper tenía un width lock peleando con las utilidades w-X h-X de Tailwind. Me ajusté a la convención de la partial user_card: fuera el wrapper, w-X h-X rounded-full object-cover directamente.
Cada una de las tres es pequeña por separado. Juntas son la distancia entre "funciona" y "se siente bien de verdad". Claude escribe código rápido, pero el tipo de pulido que solo detectas usando el producto no se puede delegar —no tiene ojos, ni dedos, ni memoria muscular de otras pantallas de la misma app.
1. El alcance es trabajo humano, no de la IA.
"Sin grupos, sin recibos de lectura, sin reacciones" valió más que cada línea de código que escribió Claude. Si aflojas alguno, los plazos se duplican. Claude no se ofrece a recortar alcance porque no conoce tu calendario de release.
2. Empuja los invariantes a la base de datos.
[a.id, b.id].sort con el índice único, validación distinct_participants, cascade del FK —todo lo propuso Claude, pero solo después de que yo dijera "no existen conversaciones espejo", "no DM a uno mismo", "los borrados arrastran limpio". Cuanto más estricta sea la restricción, menos bugs sobreviven.
3. El anti-abuso va en los specs.
De los 41 specs, ~60% son tests de policy: bloqueo aplicado, modo de privacidad aplicado, rate limit aplicado. Esos specs no comprueban "si la funcionalidad va" —comprueban "si el abuso se frena". Un spec por escenario. Claude puede enumerarte la lista, pero solo después de que le digas qué escenarios estás defendiendo.
4. El pulido de UX requiere manos sobre el producto.
Dos horas de interacción real sacaron tres problemas que ni una revisión de código ni una suite verde habrían cazado. No hay sustituto.
De "lo quiero" a "los usuarios lo usan": el PR principal cayó a las 21:53, el polish a las 23:36 —una sola noche. Pero el recurso escaso no fue escribir código, fue el tiempo de pensar a fondo alcance, autorización y defensas anti-abuso. Terminar el código es solo el principio; usar el producto tú mismo y limarle las rebabas es lo que lo hace lanzable.