Free

ให้ Claude สร้างฟีเจอร์ DM 1:1 ให้พร้อมขึ้นโปรดักชัน

Claude สร้าง DM 1:1 พร้อมขึ้นโปรดักชันในคืนเดียว: ขอบเขต, ป้องกันการคุกคามสามชั้น, Turbo ต่อ viewer และ polish ที่ฉันต้องเติมทีหลัง.


Pickful ปล่อยข้อความตรงวันนี้ แชทแบบหนึ่งต่อหนึ่ง ข้อความบวกรูปภาพ ส่งแบบเรียลไทม์ หน้าต่างถอนข้อความ 2 นาที rate limit ตามระดับคะแนน โหมดความเป็นส่วนตัวสามระดับ เคารพการบล็อก — 38 ไฟล์ +1376 บรรทัด 41 spec PR เดียว

บทความนี้ไม่ใช่ "Claude เจ๋งสุด" แต่เป็นเรื่องสิ่งที่เกิดขึ้นจริงเมื่อขอให้ Claude เพิ่มฟีเจอร์เข้าไปในผลิตภัณฑ์โซเชียลที่รันมาเกินหนึ่งปี ที่ผู้ใช้จะลองคุกคามกันจริง ๆ — การตัดสินใจไหนคุณต้องทำเอง รายละเอียดไหน Claude พลาดในรอบแรก และอะไรที่ฉันต้องอุดทีหลัง

กำหนดขอบเขตก่อนเขียนโค้ดสักบรรทัด

DM ไม่ใช่คอมเมนต์ คอมเมนต์คือลานสาธารณะ — โยนอะไรไปทุกคนเห็น DM คือห้องส่วนตัวที่มีคนแค่สองคนเป๊ะ ๆ ฉะนั้นช่องโหว่ในโมเดลข้อมูล, policy การให้สิทธิ์, หรือช่อง push ใด ๆ แปลตรง ๆ ว่า "คนแปลกหน้าเอาโฆษณาแปะหน้าใครก็ได้" ฉันเลยไม่ได้บอกว่า "ทำแชท" แต่ล็อกขอบเขตกับ Claude ก่อน:

  • เฉพาะ 1:1 — กลุ่มลากเรื่องการจัดการสมาชิก, @mention, ใครเห็นอะไร; ความซับซ้อนอย่างน้อย ×3
  • ไม่มี read receipts — ละเอียดอ่อนเรื่องความเป็นส่วนตัว เลื่อนไปก่อน แต่เผื่อที่ไว้ทำ toggle opt-in ทีหลัง
  • ไม่มี emoji reaction — ในห้อง 1:1 คนส่ง sticker อยู่แล้ว reactions เป็นลำดับท้าย

Claude เข้าใจ "ลับมีดให้คมก่อนค่อยตัดสินใจว่าจะโค่นต้นไหน" ทันที หลักนี้ฉันกลับมาใช้อีกหลายโหล: AI เขียนโค้ดเร็ว แต่เส้นแบ่งของผลิตภัณฑ์เป็นการตัดสินใจของมนุษย์

หนึ่งคู่ = หนึ่งบทสนทนา: เลี่ยง (A,B) กับ (B,A) ซ้ำซ้อน

กับดักคลาสสิกของการโมเดล DM: A ส่งหา B แล้วสร้าง (A→B); B ตอบแล้วสร้าง (B→A); ตอนนี้มีสองแถวสำหรับบทสนทนาเดียวกันและไม่มีอะไร sync กัน

วิธีของ Claude ฉันรับเข้าทันที:

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 บวก unique index บน (user_one_id, user_two_id) — ใครจะพูดก่อนก็ลงแถวเดียวกัน ง่าย ไม่ต้อง migration แก้ฝาแฝด ไม่ต้อง join เรกคอร์ดสะท้อนบน application layer

Lookup peer(viewer) ออกมาสะอาด:

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

"อีกฝ่ายคือใคร" คือ ternary บรรทัดเดียวเสมอ ดันค่าคงที่ไปยังเลเยอร์ DB แข็งแกร่งกว่าบังคับใน application code เยอะ

สถานะแยกข้างละชุด

สถานะบทสนทนาสองส่วนเป็น per-user โดยธรรมชาติ:

  • อ่านแล้ว — A อ่านไม่ได้แปลว่า B อ่าน
  • ซ่อน — A ลบบทสนทนาไม่สามารถทำให้หายไปจากฝั่ง B

ดราฟต์แรก Claude เขียนตาราง conversation_states หนึ่งแถวต่อผู้ใช้ ฉันให้หยุด — ตารางนี้จำกัดที่ 2 แถวต่อบทสนทนาตลอดกาล เสมอ 2 ตารางเพิ่มบวก join ไม่คุ้ม เราเอาสี่คอลัมน์ไปไว้ใน 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

อ่าน-เขียน dispatch ตามฝั่งของ viewer:

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

โค้ดมี viewer dispatch เพิ่มนิดหน่อย แต่ schema ลดได้ตาราง join หนึ่งและชุด index หนึ่ง ความสัมพันธ์ที่จำกัดจำนวนถาวรที่ N ไม่ควรมีตารางของตัวเอง — นี่คือการตัดสินใจออกแบบ DB: Claude เสนอ คุณตัดสิน

การต้านการคุกคามทำหนาตั้งแต่วันแรก

ฉันเขียนชัดใน prompt ว่า "DM ปล่อยได้เฉพาะถ้าระบบป้องกันการใช้ในทางที่ผิดเปิดเป็น default ทุก toggle ชี้ไปทางปลอดภัย" Claude คืนการป้องกันสามชั้นซึ่งฉันไม่ได้แก้แม้แต่ตัวอักษรเดียว:

ชั้น กลไก ที่อยู่
ผู้ใช้บล็อกเอง blocked_users.exists?(id: other.id) ลัดวงจร can_be_dmed_by?
สามโหมดความเป็นส่วนตัว enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } User concern
Rate limit ตามระดับคะแนน <50pts → 10/ชม, <500pts → 60/ชม, ที่เหลือ 300/ชม dm_hourly_limit

ชั้นที่สามฉันเป็นคนขอ สแปมจากบัญชีใช้แล้วทิ้งเป็นหายนะที่การันตีในวันแรกของ DM บนผลิตภัณฑ์โซเชียลใด ๆ เวอร์ชันแรกของ Claude เป็น "ทุกคน 60/ชม" ตรง ๆ — ฉันให้แบ่งระดับตามคะแนนชื่อเสียง: เงียบเกือบไปสำหรับบัญชีใหม่ต้นทุนศูนย์ ใจกว้างสำหรับผู้ใช้ที่ active จริง ตรรกะ "อย่าบังคับความเที่ยงธรรมเชิงคณิตในโค้ด ออกแบบความเที่ยงธรรมระหว่างระดับผู้ใช้" คือการตัดสินใจของผลิตภัณฑ์ ไม่ใช่วิศวกรรม

ประตูทั้งหมดอยู่ใน Pundit policy ไม่มีทางอ้อม:

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

return false แต่ละบรรทัดตรงกับสถานการณ์การใช้ในทางที่ผิดเฉพาะอย่าง โค้ดอ่านเหมือนเช็คลิสต์ — policy ควรเป็นแบบนั้น

ถอนข้อความ 2 นาที, soft delete, ร่องรอยตรวจสอบ

การถอนข้อความเป็นเรื่องบังคับ (พิมพ์ผิด ภาพผิด คนผิด) แต่รายละเอียดสะดุดง่าย:

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

ชิ้นส่วน:

  • Soft delete — แสตมป์ deleted_at เคลียร์เนื้อหา แถวอยู่ต่อ
  • รูปลบจริงpurge_later ส่งล้าง attachment ไป background ประหยัด storage
  • หน้าต่างแข็ง 2 นาที — ห้ามขุดข้อความ 6 เดือนที่แล้วมาถอน
  • แถวเก็บไว้ — เผื่อ compliance/audit; Turbo broadcast เปลี่ยน render เป็น placeholder "ถอนแล้ว"

"ลบเนื้อหา เก็บเรกคอร์ด" เป็น pattern มาตรฐานของ messaging ส่วนตัว Claude เขียนตรรกะถูกตั้งแต่ครั้งแรก แต่ image.purge_later ฉันแก้เอง — ต้นฉบับเขียน image.purge ซึ่ง block response

Turbo broadcast ตาม viewer ไม่ใช่ตามบทสนทนา

real-time ฉันบอก Claude ให้ใช้ Hotwire/Turbo เพราะทั้งแอพอยู่บน stack นี้ จุดน่าสนใจคือจะตัดช่อง 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

ชื่อช่องมี viewer.id — ข้อความเดียวกัน broadcast สองครั้งไปคนละ stream render ด้วย viewer ที่ต่างกันแต่ละครั้ง

ทำไม? เพราะ partial ของข้อความแยกตาม "ฉันส่งหรือเปล่า?" และ "ฉันถอนได้ไหม?" — ผลลัพธ์ render ต่างจริงตาม viewer ถ้า broadcast payload เดียวให้ client ตัดสิน เราต้องเผย sender_id ไป frontend หรือเขียน CSS เงื่อนไขกองโต — ไม่สะอาดเท่า render สองครั้งบน server

Badge inbox ใช้โมเดลเดียวกัน:

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

หนึ่ง stream ต่อผู้ใช้หนึ่งคน แต่ละคนรีเฟรชตัวนับยังไม่อ่านของตัวเอง

สิ่งที่ฉันต้องอุดหลัง PR ของ Claude merge

หลัง PR เข้า ฉันนั่งใช้ผลิตภัณฑ์จริงประมาณสองชั่วโมงและเจอสามอย่างที่ Claude ไม่ได้คาดไว้ รวมไว้ใน commit polish:

1. บทสนทนาว่างเปล่าไม่ควรปรากฏทั้งสองฝั่ง

ฉันกดปุ่ม "ส่ง DM" ของคนอื่น แถวบทสนทนาถูกสร้าง แต่ฉันยังไม่ได้เขียนอะไร — และอีกฝ่ายเห็นบทสนทนาว่างใน inbox ของเขาแล้ว แปลกในเชิงสังคม: "ถ้าไม่จะพูดอะไร อย่ามากวน"

เพิ่ม scope:

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

บทสนทนาที่มี last_message_at IS NULL ไม่ปรากฏทั้งสองฝั่ง ข้อความจริงแรกเรียก bump_last_message! แสตมป์ timestamp และบทสนทนาโผล่ขึ้นมา Claude คิดไม่ถึง — เขาสนใจ "ทำงานไหม?" ไม่ใช่ "ถ้าผู้ใช้เปลี่ยนใจกลางทางล่ะ?"

2. ปุ่ม "ส่ง DM" บนโปรไฟล์ซ้อนใต้ Follow คับแคบ

Layout ของ Claude วางปุ่มซ้อนแนวตั้ง บวกชื่อ tooltip ของ heroicon "Chat-bubble-left-right" รั่วออกมา ฉันเปลี่ยนเป็นข้าง Follow ใช้ไอคอนอย่างเดียว ใส่ title แบบชัดเจนเพื่อทับ tooltip ที่รั่ว

3. อวตาร์ขนาดแตกใน inbox และ header ของ thread

.avatar-wrapper มี width lock ตีกันกับ utilities w-X h-X ของ Tailwind ปรับให้ตรงกับ convention ของ partial user_card: ถอด wrapper ใช้ w-X h-X rounded-full object-cover ตรง ๆ

ทั้งสามข้อเดี่ยว ๆ เล็ก รวมกันคือระยะห่างระหว่าง "ทำงาน" กับ "เข้ากันจริง" Claude เขียนโค้ดเร็ว แต่ การ polish แบบที่จะเห็นต่อเมื่อใช้ผลิตภัณฑ์เองมอบหมายไม่ได้ — เขาไม่มีตา ไม่มีนิ้ว ไม่มีความทรงจำของกล้ามเนื้อจากหน้าอื่นในแอพเดียวกัน

สิ่งที่การสร้างฟีเจอร์นี้สอนฉัน

1. การกำหนดขอบเขตเป็นงานของมนุษย์ ไม่ใช่ของ AI

"ไม่มีกลุ่ม ไม่มี read receipts ไม่มี reactions" มีค่ามากกว่าทุกบรรทัดของโค้ดที่ Claude เขียน คลายอย่างใดอย่างหนึ่งและตารางเวลาก็เพิ่มเป็นสองเท่า Claude จะไม่เสนอตัดขอบเขตเอง เพราะไม่รู้กำหนด release ของคุณ

2. ดันค่าคงที่ไปยัง database

[a.id, b.id].sort บวก unique index, validation distinct_participants, FK cascade — ทั้งหมดเป็นข้อเสนอของ Claude แต่หลังจากฉันบอก "ห้ามบทสนทนาสะท้อน" "ห้าม DM ตัวเอง" "delete cascade สะอาด" เท่านั้น ยิ่ง constraint เข้ม bug ยิ่งรอดน้อย

3. ต้านการใช้ในทางที่ผิดแสดงใน spec

ใน 41 spec ราว ~60% คือทดสอบ policy: บล็อกทำงาน, โหมดความเป็นส่วนตัวทำงาน, rate limit ทำงาน spec เหล่านี้ไม่ทดสอบ "ฟีเจอร์รันไหม?" — ทดสอบ "การใช้ในทางที่ผิดถูกบล็อกไหม?" หนึ่ง spec ต่อหนึ่งสถานการณ์ Claude แจกแจงรายการได้ แต่หลังจากคุณบอกเขาว่ากำลังป้องกันสถานการณ์ไหนเท่านั้น

4. UX polish ต้องมือลงผลิตภัณฑ์จริง

สองชั่วโมงของการใช้งานจริงพบสามอย่างที่ code review หรือ test suite เขียวไม่มีทางจับได้ ไม่มีสิ่งทดแทน


จาก "อยากได้ฟีเจอร์นี้" ถึง "ผู้ใช้ใช้ได้": PR หลักลงเวลา 21:53 polish 23:36 — คืนเดียว แต่ทรัพยากรที่หายากไม่ใช่เวลาเขียนโค้ด แต่เป็นเวลาคิดให้ทะลุเรื่องขอบเขต การให้สิทธิ์ และการป้องกันการใช้ในทางที่ผิด เขียนโค้ดเสร็จเป็นแค่จุดเริ่มต้น ใช้ผลิตภัณฑ์ด้วยตัวเองและขัดเสี้ยนออกต่างหากที่ทำให้พร้อมส่ง