Free

Dejar que Claude construya un DM 1:1 listo para producción

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.

Define el alcance antes de escribir una línea

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:

  • Solo 1:1 —los grupos traen gestión de miembros, @menciones, quién ve qué; complejidad mínima ×3
  • Sin recibos de lectura —sensible para la privacidad, lo dejamos fuera pero con sitio para un toggle opt-in en el futuro
  • Sin reacciones con emoji —en una sala de 1:1 ya se mandan stickers, las reacciones son baja prioridad

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.

Un par = una conversación: evitar (A,B) y (B,A) duplicadas

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

Estado partido entre ambos lados

Hay dos piezas de estado de la conversación que son intrínsecamente por usuario:

  • Leído —que A haya leído no implica que B lo haya hecho
  • Oculto —que A borre la conversación no la puede hacer desaparecer del lado de B

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.

Anti-acoso de fábrica desde el día uno

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.

Retracción de 2 minutos, borrado suave, rastro auditable

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:

  • Borrado suave —marca deleted_at, vacía el contenido, conserva la fila
  • Imagen sí se borrapurge_later encola la limpieza, ahorra almacenamiento
  • Ventana dura de 2 minutos —nada de retractar mensajes de hace 6 meses
  • La fila se queda —para cumplimiento y auditoría; Turbo broadcast cambia el render por un placeholder "retractado"

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

Turbo emite por viewer, no por conversación

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.

Lo que tuve que parchear después de mergear el PR de Claude

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.

Lo que aprendí construyendo esto

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.