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.
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:
"Ö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.
(A,B) ve (B,A) ikizlerini önlemekDM 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.
Konuşma state'inin iki parçası özünde kullanıcı başına:
İ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.
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.
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:
deleted_at zaman damgası, içeriği boşalt, satırı korupurge_later ek temizliğini arka plana atar, depolama tasarrufu"İç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.
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.
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.
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.