Free

Claude'a Yayına Hazır 1:1 DM Özelliği Kurdurmak

Claude'un bir gecede yayına hazır 1:1 DM kurması: kapsam, üç katmanlı anti-taciz, viewer başına Turbo yayın ve sonradan eklediğim polish.


Pickful bugün direkt mesajları yayınladı. Bire bir sohbet, metin + görsel, gerçek zamanlı teslim, 2 dakikalık geri çekme penceresi, puan kademeli rate limit, üç gizlilik modu, engelleme uyumlu — 38 dosya, +1376 satır, 41 spec, tek PR.

Bu yazı "Claude inanılmaz" değil. Bir yılı aşkın süredir çalışan, kullanıcıların birbirini taciz etmeye gerçekten kalkışacağı bir sosyal ürüne Claude'a özellik eklettiğinde gerçekten ne oluyor: hangi kararları kendin vermek zorundasın, Claude ilk geçişte neleri atlıyor, sonradan neyi yamamak zorunda kaldım.

Bir satır yazmadan önce kapsamı sıkıştır

DM yorum değil. Yorum kamusal meydan — attığını herkes görür. DM iki kişinin olduğu özel oda. Yani veri modelindeki, yetkilendirme policy'sindeki, push kanalındaki her boşluk doğrudan "tanıdık olmayan biri bir başkasının yüzüne reklam yapıştırabilir" anlamına geliyor. O yüzden "sohbet yap" demedim. Önce Claude'la kapsamı kilitledim:

  • Sadece 1:1 — grup, üye yönetimi, @mention, kim kimi görüyor demek; karmaşıklık en az ×3
  • Okundu bilgisi yok — gizlilik açısından hassas, erteliyoruz, ama sonradan opt-in toggle için yer bırakıyoruz
  • Emoji reaction yok — 1:1'de zaten sticker yolluyorlar, reaction düşük öncelikli

"Önce bıçağı bile, sonra hangi ağacı keseceğine karar ver" prensibini Claude hemen anladı. Bu prensibi onlarca kez tekrar kullanacaktım: AI kod yazmayı hızlı yapar ama ürünün sınırlarını insan çizer.

Bir çift = bir konuşma: (A,B) ve (B,A) ikizlerini önlemek

DM modellemenin klasik tuzağı: A, B'ye mesaj atıp (A→B) oluşturuyor; B yanıtlayıp (B→A) oluşturuyor; aynı konuşma için iki satır var ve hiçbir şey senkron değil.

Claude'un önerdiği çözümü doğrudan aldım:

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 artı (user_one_id, user_two_id) üzerinde tekil indeks — kim önce konuşursa konuşsun, ikisi de aynı satıra düşer. Basit, ikizleri uzlaştırmak için migration yok, uygulama katmanında ayna kayıtları birleştirmek yok.

peer(viewer) lookup'ı temiz çıkıyor:

def peer(viewer)
  viewer.id == user_one_id ? user_two : user_one
end

"Karşı taraf kim" hep tek satır ternary. Değişmezi DB katmanına itmek, uygulama kodunda zorlamaktan çok daha sağlam.

State'i iki tarafa ayrı ayrı taşı

Konuşma state'inin iki parçası özünde kullanıcı başına:

  • Okundu — A'nın okuması B'nin okuduğu anlamına gelmez
  • Gizlendi — A konuşmayı silmesi onu B tarafında yok edemez

İlk taslakta Claude kullanıcı başına bir satırlık conversation_states tablosu yazdı. Durdurdum — bu tablo konuşma başına kalıcı olarak 2 satır, her zaman 2. Ekstra tablo artı join'e değmez. Dört sütunu doğrudan conversations'a koyduk:

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

Okuma/yazma viewer'ın one mu two mu olduğuna göre dispatch ediyor:

def column_name(viewer, suffix)
  viewer.id == user_one_id ? :"user_one_#{suffix}" : :"user_two_#{suffix}"
end

Kodda biraz viewer dispatch, şemadan bir join tablosu ve bir indeks seti eksik. Sayısı kalıcı olarak N ile sınırlı bir ilişki kendi tablosunu hak etmez — bu bir DB tasarım kararı: Claude önerir, sen karar verirsin.

Anti-taciz ilk günden kalın

Prompt'a açıkça yazdım: "DM yalnızca taciz savunmaları varsayılan olarak açıkken yayınlanır, tüm toggle'lar güvenli tarafa." Claude tek harf değiştirmediğim üç katmanlı bir savunma getirdi:

Katman Mekanizma Konum
Kullanıcı engellemesi blocked_users.exists?(id: other.id) kısa devre can_be_dmed_by?
Üç gizlilik modu enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } User concern
Puan kademeli rate limit <50pts → 10/sa, <500pts → 60/sa, gerisi 300/sa dm_hourly_limit

Üçüncü katmanı ben istedim. Atılabilir hesap spam'i, herhangi bir sosyal üründe DM'in ilk günü garantili felaket. Claude'un ilk versiyonu "herkese 60/sa" sabitiydi — itibar puanlarına göre kademeli yapmasını istedim: sıfır maliyetli yeni hesapları neredeyse sessiz, gerçekten aktif kullanıcıları cömert. Bu "kodda aritmetik adaleti zorlama, kullanıcı kademelerinde adaleti tasarla" kararı bir ürün kararı, mühendislik değil.

Tam kapı Pundit policy'sinde, bypass yok:

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

Her return false somut bir taciz senaryosuna karşılık geliyor. Kod bir checklist gibi okunuyor — policy böyle görünmeli.

2 dakika geri çekme, soft delete, denetim izi

Geri çekme zorunlu (yazım hatası, yanlış görsel, yanlış kişi), ama detaylarda kolay tökezlenir:

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

Parçalar:

  • Soft deletedeleted_at zaman damgası, içeriği boşalt, satırı koru
  • Görsel gerçekten silinirpurge_later ek temizliğini arka plana atar, depolama tasarrufu
  • 2 dakikalık sert pencere — 6 ay önceki mesajları kazıp geri çekmek yok
  • Satır kalır — uyum/denetim için; Turbo broadcast render'ı "geri çekildi" placeholder ile değiştirir

"İçeriği temizle, kaydı koru" özel mesajlaşmanın standart deseni. Claude mantığı ilk seferde doğru yazdı, ama image.purge_later benim düzeltmemdi — orijinali image.purge'tı, response'u bloklar.

Turbo, konuşma başına değil, izleyici başına yayın yapar

Gerçek zamanlı için Claude'a Hotwire/Turbo kullanmasını söyledim çünkü tüm app bu stack üzerinde. İlginç olan broadcast kanallarını nasıl kestiğin:

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

Kanal adı viewer.id içeriyor — aynı mesaj iki kez, iki farklı stream'e yayınlanıyor, her seferinde farklı viewer ile render ediliyor.

Neden? Çünkü mesaj partial'ı "bunu ben mi gönderdim?" ve "geri çekebilir miyim?" üzerinde dallanıyor — render çıktısı viewer başına gerçekten farklı. Tek bir payload yayınlayıp istemcinin karar vermesini bırakırsak ya sender_id'yi front-end'e açmamız ya da bir sürü koşullu CSS yazmamız gerekir — sunucuda iki kez render etmek kadar temiz değil.

Inbox badge'i aynı modeli kullanıyor:

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 }
  )
)

Kullanıcı başına bir stream, her biri kendi okunmadı sayacını yeniliyor.

Claude'un PR'ı merge olduktan sonra yamaladığım şeyler

PR girdikten sonra gerçek ürünle birkaç saat oturdum ve Claude'un öngörmediği üç şey buldum. Bir polish commit'inde topladım:

1. Boş konuşmalar iki taraftan da görünmemeli

Başkasının "DM Gönder" butonuna bastım. Konuşma satırı oluşturuldu, ama henüz hiçbir şey yazmamıştım — ve diğer kişi inbox'ında boş bir konuşma görüyordu. Sosyal olarak garip: "Bir şey demeyeceksen rahatsız etme".

Bir scope ekledim:

scope :visible_to, ->(user) {
  for_user(user)
    .where.not(last_message_at: nil)
    .where(...hidden_at IS NULL...)
}

last_message_at IS NULL olan konuşmalar her iki taraftan da görünmüyor. İlk gerçek mesaj bump_last_message!'i çağırır, zaman damgası basılır, konuşma yüzeye çıkar. Claude bunu düşünmedi — sadece "çalışıyor mu" ile ilgilendi, "kullanıcı akış ortasında fikir değiştirirse ne olur" ile değil.

2. Profildeki "DM Gönder" butonu Follow'un altına dik dizilmiş ve sıkışıktı

Claude'un düzeni butonları dikey istifliyordu, ayrıca "Chat-bubble-left-right" heroicon'un tooltip adı sızıyordu. Follow ile yan yana, sadece ikon, sızan tooltip'i ezmek için açık title ile değiştirdim.

3. Avatarlar inbox ve thread header'da çöküyordu

.avatar-wrapper'da Tailwind'in w-X h-X utility'leriyle çatışan bir width lock vardı. user_card partial'ının konvansiyonuna uydum: wrapper'ı çıkar, doğrudan w-X h-X rounded-full object-cover kullan.

Üçünün her biri tek başına küçük. Birlikte "çalışıyor" ile "gerçekten oturuyor" arasındaki mesafe oluyorlar. Claude kodu hızlı yazar, ama yalnızca ürünü kullanarak fark edilen polish türü delege edilemez — gözleri, parmakları, aynı uygulamanın diğer sayfalarından kas hafızası yok.

Bunu inşa ederken öğrendiklerim

1. Kapsam belirlemek bir insan işi, AI işi değil.

"Grup yok, okundu yok, reaction yok" Claude'un yazdığı her satır koddan daha değerliydi. Birini gevşet, takvim ikiye katlanır. Claude kapsam kesmek için kendiliğinden teklif vermez çünkü senin release takvimini bilmiyor.

2. Değişmezleri DB'ye it.

[a.id, b.id].sort artı tekil indeks, distinct_participants validation, FK cascade — hepsi Claude'un önerisi, ama yalnızca ben "ayna konuşma yok", "kendine DM yok", "silme cascade temiz" dedikten sonra. Kısıt ne kadar sıkıysa hayatta kalan bug o kadar az.

3. Anti-taciz spec'lerde ifade edilir.

41 spec'in yaklaşık %60'ı policy testleri: engelleme çalışıyor, gizlilik modu çalışıyor, rate limit çalışıyor. Bu spec'ler "özellik çalışıyor mu" değil, "istismar engelleniyor mu" testidir. Senaryo başına bir spec. Claude listeyi sıralayabilir, ama ancak sen hangi senaryoları savunduğunu söyledikten sonra.

4. UX polish, ürüne fiilen el atmayı gerektirir.

PR merge sonrası iki saatlik gerçek etkileşim, code review veya yeşil test suite'in asla yakalamayacağı üç şeyi ortaya çıkardı. Bunun yedeği yok.


"Bunu istiyorum"dan "kullanıcılar kullanabiliyor"a: ana PR 21:53'te indi, polish 23:36'da — bir gece. Ama kıt kaynak kod yazma zamanı değildi, kapsam, yetkilendirme ve anti-taciz savunmalarını sonuna kadar düşünme zamanıydı. Kodu bitirmek sadece başlangıç; ürünü kendin kullanıp çapakları zımparalamak onu sevkiyat edilebilir yapan şey.