Free

Membiarkan Claude Membangun Fitur DM 1:1 yang Siap Diluncurkan

Bagaimana Claude membangun DM 1:1 siap rilis dalam semalam: ruang lingkup, tiga lapis anti-pelecehan, Turbo per-viewer, dan polish yang harus aku tambahkan.


Pickful merilis DM hari ini. Obrolan satu lawan satu, teks plus gambar, pengiriman real-time, jendela tarik balik 2 menit, rate limit berbasis tingkatan poin, tiga mode privasi, menghormati blokir—38 file, +1376 baris, 41 spec, satu PR.

Tulisan ini bukan "Claude hebat banget". Ini soal apa yang sebenarnya terjadi ketika kamu minta Claude menambahkan fitur ke produk sosial yang sudah berjalan setahun lebih, di mana pengguna pasti akan mencoba saling melecehkan: keputusan apa yang harus kamu ambil sendiri, detail apa yang Claude lewatkan di percobaan pertama, dan apa yang harus aku tambal kemudian.

Tetapkan Ruang Lingkup Sebelum Menulis Satu Baris pun

DM tidak sama dengan komentar. Komentar adalah alun-alun publik—apa pun yang kamu lempar, semua orang lihat. DM adalah ruang privat dengan tepat dua orang di dalamnya. Artinya setiap celah di model data, policy otorisasi, atau channel push langsung berarti "orang asing bisa menempelkan iklan di wajah seseorang". Jadi aku tidak bilang "buat chat". Aku kunci dulu ruang lingkupnya dengan Claude:

  • Hanya 1:1—grup membawa manajemen anggota, @mention, siapa-lihat-siapa; kompleksitas minimal ×3
  • Tanpa tanda dibaca—sensitif privasi, tunda dulu, tapi sisakan ruang untuk toggle opt-in nanti
  • Tanpa reaksi emoji—di ruang 1:1 orang tinggal kirim stiker, reaksi prioritas rendah

Claude langsung paham "asah dulu pisaunya sebelum memutuskan pohon mana yang ditebang". Aku akan kembali ke prinsip ini berkali-kali: AI menulis kode cepat, tapi batas-batas produk adalah keputusan manusia.

Sepasang Pengguna = Satu Percakapan: Cegah Duplikasi (A,B) dan (B,A)

Jebakan klasik dalam memodelkan DM: A kirim ke B dan membuat (A→B); B balas dan membuat (B→A); sekarang ada dua baris untuk percakapan yang sama dan tidak ada yang sinkron.

Solusi Claude aku ambil mentah-mentah:

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 plus indeks unik di (user_one_id, user_two_id)—siapa pun yang bicara duluan, keduanya mendarat di baris yang sama. Sederhana, tanpa migrasi untuk merekonsiliasi kembar, tanpa kerja layer aplikasi menggabung record cermin.

Lookup peer(viewer) jatuh bersih:

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

"Siapa pihak lain" selalu satu baris ternary. Mendorong invariant ke layer database jauh lebih kokoh daripada memaksakannya di kode aplikasi.

State Dibagi di Kedua Sisi

Dua bagian state percakapan secara hakikat per-pengguna:

  • Status dibaca—A baca tidak berarti B juga baca
  • Status disembunyikan—A menghapus percakapan tidak boleh menghilangkannya di sisi B

Rancangan pertama Claude punya tabel conversation_states satu baris per pengguna. Aku hentikan—tabel itu permanen dibatasi 2 baris per percakapan, selalu 2. Tabel terpisah plus join tidak sepadan. Empat kolom langsung kami pasang di 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

Baca-tulis di-dispatch berdasarkan viewer di sisi mana:

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

Sedikit viewer dispatch di kode, hilang satu tabel join dan satu set indeks dari schema. Relasi yang kardinalitasnya permanen terbatas N tidak layak punya tabel sendiri—itu pertimbangan desain database: Claude usulkan, kamu putuskan.

Anti-Pelecehan Sudah Tebal dari Hari Pertama

Aku tulis eksplisit di prompt: "DM hanya dirilis jika pertahanan anti-pelecehan default aktif, semua toggle mengarah ke sisi aman." Claude mengembalikan tiga lapis pertahanan yang tidak aku ubah satu pun:

Lapis Mekanisme Lokasi implementasi
Blokir oleh pengguna blocked_users.exists?(id: other.id) short-circuit can_be_dmed_by?
Tiga mode privasi enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } User concern
Rate limit per tingkat poin <50pts → 10/j, <500pts → 60/j, sisanya 300/j dm_hourly_limit

Lapis ketiga aku yang minta. Spam dari akun sekali pakai adalah bencana yang pasti datang di hari pertama DM di produk sosial mana pun. Versi pertama Claude punya "semua orang 60/j" rata—aku minta dibuat berjenjang berdasarkan reputasi poin: hampir bisu untuk akun baru tanpa biaya, longgar untuk pengguna yang benar-benar aktif. Logika "jangan paksakan keadilan aritmatika di kode, desain keadilan antar tingkatan pengguna" itu pertimbangan produk, bukan engineering.

Gerbang lengkap ada di Pundit policy, tidak ada 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

Setiap return false berkorespondensi dengan satu skenario pelecehan konkret. Kodenya terbaca seperti checklist—dan begitulah seharusnya policy terlihat.

Tarik Balik 2 Menit, Soft Delete, Jejak Audit

Tarik balik wajib (typo, gambar salah, orang salah), tapi detailnya mudah meleset:

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

Poin-poinnya:

  • Soft delete—stempel deleted_at, kosongkan konten, baris tetap
  • Gambar dihapus beneranpurge_later antrekan pembersihan ke background, hemat storage
  • Jendela keras 2 menit—tidak ada penggalian pesan 6 bulan lalu untuk ditarik
  • Baris tetap—untuk compliance/audit; Turbo broadcast mengganti render jadi placeholder "ditarik"

"Hapus kontennya, simpan recordnya" adalah pola standar messaging privat. Claude benar di percobaan pertama, tapi image.purge_later adalah perbaikanku—versi aslinya image.purge, yang akan memblokir respons.

Turbo Menyiarkan Per-Viewer, Bukan Per-Percakapan

Untuk real-time aku minta Claude pakai Hotwire/Turbo karena seluruh app di stack itu. Yang menarik adalah cara memotong channel 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

Nama channel menyertakan viewer.id—satu pesan disiarkan dua kali, ke dua stream berbeda, dirender dengan viewer berbeda tiap kalinya.

Kenapa? Karena partial pesan bercabang pada "apakah aku yang kirim?" dan "boleh tarik balik?"—hasil render-nya secara hakikat berbeda per-viewer. Kalau cuma broadcast satu payload dan biarkan klien yang putuskan, kita harus pajang sender_id ke front-end atau menulis banyak CSS kondisional—tidak ada yang sebersih merender dua kali di server.

Badge inbox pakai model yang sama:

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

Satu stream per pengguna, masing-masing memperbarui counter belum-dibaca-nya sendiri.

Apa yang Harus Aku Tambal Setelah PR Claude di-Merge

Setelah PR masuk, aku duduk dengan produk asli sekitar dua jam dan menemukan tiga hal yang tidak diantisipasi Claude. Aku gabung di commit polish:

1. Percakapan kosong tidak boleh muncul di kedua sisi

Aku klik tombol "Kirim DM" orang lain. Baris percakapan dibuat, tapi aku belum menulis apa-apa—dan lawan bicara sudah lihat percakapan kosong di inbox-nya. Aneh secara sosial: "kalau tidak mau bicara, jangan ganggu".

Tambah satu scope:

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

Percakapan dengan last_message_at IS NULL tidak terlihat di kedua sisi. Pesan pertama memanggil bump_last_message!, timestamp tertanam, percakapan muncul. Claude tidak terpikir—dia fokus "berjalan?", bukan "bagaimana kalau user berubah pikiran di tengah jalan?".

2. Tombol "Kirim DM" di profil tertumpuk di bawah Follow dan sempit

Layout Claude menumpuk tombol vertikal, ditambah nama tooltip heroicon "Chat-bubble-left-right" bocor keluar. Aku ubah ke berdampingan dengan Follow, hanya ikon, dengan title eksplisit menutup tooltip yang bocor.

3. Avatar pecah ukuran di inbox dan header thread

.avatar-wrapper punya width lock bentrok dengan utility w-X h-X Tailwind. Selaraskan dengan konvensi partial user_card: copot wrapper, langsung pakai w-X h-X rounded-full object-cover.

Ketiganya kecil sendiri-sendiri. Digabung mereka adalah jarak antara "berfungsi" dan "benar-benar pas". Claude menulis kode cepat, tapi polishing yang baru ketahuan setelah dipakai langsung tidak bisa didelegasikan—dia tidak punya mata, jari, atau ingatan otot dari halaman lain di app yang sama.

Yang Aku Pelajari dari Membangun Ini

1. Penentuan ruang lingkup adalah pekerjaan manusia, bukan AI.

"Tanpa grup, tanpa tanda dibaca, tanpa reaksi" lebih bernilai daripada setiap baris kode yang Claude tulis. Longgarkan satu saja, timeline berlipat. Claude tidak akan menawarkan pemotongan ruang lingkup karena dia tidak tahu jadwal release-mu.

2. Dorong invariant ke database.

[a.id, b.id].sort dengan indeks unik, validasi distinct_participants, cascade FK—semua usulan Claude, tapi hanya setelah aku menyatakan "tidak ada percakapan cermin", "dilarang DM ke diri sendiri", "delete cascade bersih". Semakin ketat constraint, semakin sedikit bug yang bertahan.

3. Anti-pelecehan diekspresikan di spec.

Dari 41 spec, ~60% adalah tes policy: blokir berfungsi, mode privasi berfungsi, rate limit berfungsi. Spec-spec itu bukan menguji "fitur jalan?"—menguji "abuse diblok?". Satu skenario satu spec. Claude bisa membuat daftarnya, tapi hanya setelah kamu sebut skenario mana yang ingin dipertahankan.

4. Polishing UX butuh tangan langsung di produk.

Dua jam interaksi nyata mengungkap tiga hal yang code review atau test suite hijau tidak akan tangkap. Tidak ada pengganti.


Dari "aku mau ini" ke "pengguna pakai": PR utama jatuh 21:53, polish 23:36—satu malam. Tapi sumber daya yang langka bukan waktu menulis kode, melainkan waktu memikirkan tuntas ruang lingkup, otorisasi, dan pertahanan anti-pelecehan. Menyelesaikan kode hanyalah awal; memakai produk sendiri dan mengamplas duri-durinya adalah yang membuat layak diluncurkan.