Free

Jak Claude zbudował funkcję wiadomości prywatnych 1:1 gotową do produkcji

Jak Claude zbudował gotowe do wdrożenia DM-y 1:1 w jeden wieczór: scope, trzy warstwy anti-nękania, Turbo per viewer i polish, który musiałem dodać.


Pickful wdrożył dziś wiadomości prywatne. Czat jeden-na-jeden, tekst plus obrazy, dostarczanie w czasie rzeczywistym, okno cofnięcia 2 minuty, rate limit według progów punktów, trzy tryby prywatności, respektowanie blokady — 38 plików, +1376 linii, 41 spec, jeden PR.

Ten artykuł to nie "Claude jest niesamowity". To o tym, co naprawdę się dzieje, kiedy prosisz Claude'a o dodanie funkcji do socjalnego produktu działającego od ponad roku, w którym użytkownicy z pewnością spróbują się nawzajem nękać: które decyzje musisz podjąć sam, czego Claude nie zauważa za pierwszym podejściem, co musiałem łatać potem.

Ustal zakres przed napisaniem linijki kodu

DM-y to nie komentarze. Komentarze to publiczny plac — co rzucisz, każdy zobaczy. DM-y to prywatny pokój z dokładnie dwiema osobami. Każda dziura w modelu danych, w policy autoryzacji albo w kanale push przekłada się bezpośrednio na "obcy może nakleić reklamę komuś na twarzy". Dlatego nie powiedziałem "zrób czat". Najpierw zablokowałem zakres z Claude'em:

  • Tylko 1:1 — grupy ciągną zarządzanie członkami, @mention, kto-widzi-co; złożoność co najmniej ×3
  • Bez potwierdzeń odczytu — wrażliwe na prywatność, odkładamy, ale zostawiamy miejsce na opt-in przełącznik później
  • Bez reakcji emoji — w pokoju 1:1 ludzie wysyłają naklejki, reactions są niskim priorytetem

"Najpierw zaostrz ostrze, potem zdecyduj które drzewo ścinasz" — Claude załapał od razu. Wracałem do tej zasady jeszcze tuzin razy: AI pisze kod szybko, ale granice produktu to decyzja człowieka.

Jedna para = jedna konwersacja: unikamy bliźniaczek (A,B) i (B,A)

Klasyczna pułapka modelowania DM: A pisze do B i tworzy (A→B); B odpowiada i tworzy (B→A); teraz są dwa wiersze dla tej samej konwersacji i nic nie jest zsynchronizowane.

Rozwiązanie Claude'a wziąłem w całości:

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 unikalny na (user_one_id, user_two_id) — kto by nie zaczął, oboje lądują w tym samym wierszu. Prosto, bez migracji godzącej bliźniaki, bez sklejania rekordów lustrzanych na warstwie aplikacji.

Lookup peer(viewer) wychodzi czysto:

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

"Kto jest drugą stroną" to zawsze trójargumentowy operator w jednej linii. Wepchnięcie niezmiennika do warstwy DB jest znacznie bardziej solidne niż wymuszanie go w kodzie aplikacji.

Stan podzielony na obie strony

Dwa kawałki stanu konwersacji są z natury per-user:

  • Przeczytane — A przeczytał nie znaczy, że B przeczytał
  • Ukryte — A kasujący konwersację nie może sprawić, że zniknie u B

W pierwszym szkicu Claude napisał tabelę conversation_states po jednym wierszu na użytkownika. Zatrzymałem — ta tabela jest na zawsze ograniczona do 2 wierszy na konwersację, zawsze 2. Dodatkowa tabela plus join nie warta. Cztery kolumny położyliśmy bezpośrednio w 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

Odczyt i zapis dispatchują się według strony viewer'a:

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

Trochę viewer dispatch w kodzie, mniej jedna tabela join i komplet indeksów w schemie. Relacja o trwale ograniczonej kardynalności N nie zasługuje na własną tabelę — to decyzja projektowa DB: Claude proponuje, ty decydujesz.

Anti-nękanie grube od pierwszego dnia

Napisałem to wprost w promptcie: "DM-y wychodzą tylko jeśli obrona przed nadużyciami jest włączona domyślnie, wszystkie przełączniki na bezpieczną stronę". Claude zwrócił trzy warstwy obrony, w których nie zmieniłem ani litery:

Warstwa Mechanizm Gdzie żyje
Blokada przez użytkownika blocked_users.exists?(id: other.id) krótkie spięcie can_be_dmed_by?
Trzy tryby prywatności enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } User concern
Rate limit według progów punktów <50pts → 10/h, <500pts → 60/h, reszta 300/h dm_hourly_limit

Trzecią warstwę poprosiłem ja. Spam z kont jednorazowych to gwarantowana katastrofa pierwszego dnia DM w każdym socjalnym produkcie. Pierwsza wersja Claude'a miała "60/h dla wszystkich" na sztywno — kazałem zrobić stopniowo według punktów reputacji: prawie niemo dla bezkosztowych nowych kont, hojnie dla naprawdę aktywnych użytkowników. Logika "nie wbijaj arytmetycznej sprawiedliwości w kod, projektuj sprawiedliwość między warstwami użytkowników" to decyzja produktu, nie inżynieria.

Pełna bramka żyje w Pundit policy, bez bypassu:

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

Każde return false odpowiada konkretnemu scenariuszowi nadużycia. Kod czyta się jak checklistę — tak powinien wyglądać policy.

Cofnięcie 2 minuty, soft delete, ślad audytu

Cofnięcie jest obowiązkowe (literówki, zła grafika, zła osoba), ale w detalach łatwo się potknąć:

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

Kawałki:

  • Soft delete — stempel deleted_at, treść opróżniona, wiersz zachowany
  • Obrazek faktycznie usuniętypurge_later wysyła czyszczenie załącznika w tło, oszczędza storage
  • Twarde okno 2 minut — nie ma odkopywania wiadomości sprzed pół roku do cofnięcia
  • Wiersz zostaje — do compliance/audytu; Turbo broadcast zamienia render na placeholder "cofnięto"

"Wyczyść treść, zachowaj rekord" to standardowy wzorzec prywatnego messagingu. Claude napisał logikę poprawnie za pierwszym razem, ale image.purge_later to już moja poprawka — oryginał miał image.purge, który blokuje response.

Turbo broadcast per viewer, nie per konwersacja

Do real-time powiedziałem Claude'owi użyć Hotwire/Turbo, bo cała apka jest na tym stacku. Ciekawe — jak pokroić kanały broadcastu:

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

Nazwa kanału zawiera viewer.id — ta sama wiadomość jest broadcastowana dwa razy, na dwa różne strumienie, renderowana z innym viewer za każdym razem.

Dlaczego? Bo partial wiadomości rozgałęzia się na "czy ja to wysłałem?" i "czy mogę cofnąć?" — wynik renderu jest faktycznie różny dla każdego viewer'a. Jeśli zbroadcastujemy jeden payload i pozwolimy zdecydować klientowi — albo wystawiamy sender_id na frontend, albo piszemy kupę CSS warunkowego — żaden wariant nie jest tak czysty jak dwa rendery na serwerze.

Badge inbox używa tego samego modelu:

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

Jeden stream na użytkownika, każdy odświeża swój licznik nieprzeczytanych.

Co musiałem załatać po merge'u PR Claude'a

Po wejściu PR usiadłem z prawdziwym produktem na jakieś dwie godziny i znalazłem trzy rzeczy, których Claude nie przewidział. Wsadziłem je w commit polish:

1. Puste konwersacje nie powinny pokazywać się po żadnej stronie

Kliknąłem przycisk "Wyślij DM" cudzego profilu. Wiersz konwersacji został utworzony, ale jeszcze nic nie napisałem — a druga osoba już widziała pustą konwersację w swojej skrzynce. Społecznie dziwne: "jak nic nie chcesz powiedzieć, to mi nie przeszkadzaj".

Dodałem scope:

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

Konwersacje z last_message_at IS NULL są niewidoczne z żadnej strony. Pierwsza prawdziwa wiadomość woła bump_last_message!, stempluje timestamp, i konwersacja wypływa. Claude o tym nie pomyślał — był skoncentrowany na "czy działa?", nie na "co jeśli użytkownik się rozmyśli w połowie flow?".

2. Przycisk "Wyślij DM" na profilu był pionowo pod Follow i ciasno

Layout Claude'a układał przyciski pionowo, plus nazwa tooltipa heroicon "Chat-bubble-left-right" wyciekała. Przerobiłem na obok Follow, sama ikona, z jawnym title przykrywającym wyciek.

3. Awatary łamały rozmiar w inbox i headerze threadu

.avatar-wrapper miał width lock kłócący się z utilities w-X h-X Tailwindzie. Dostosowałem do konwencji partiala user_card: precz wrapper, używaj w-X h-X rounded-full object-cover bezpośrednio.

Każde z trzech samo w sobie to drobiazg. Razem to dystans między "działa" a "naprawdę pasuje". Claude pisze kod szybko, ale ten rodzaj polishingu, który zauważasz dopiero używając produktu, nie da się oddelegować — nie ma oczu, palców, pamięci mięśniowej z innych stron tej samej apki.

Czego nauczyło mnie zbudowanie tego

1. Scope to ludzka robota, nie AI.

"Bez grup, bez potwierdzeń odczytu, bez reactions" było warte więcej niż każda linia kodu, którą napisał Claude. Poluzuj jedno z tych — harmonogram się podwaja. Claude sam nie zaproponuje cięcia scope'u, bo nie zna twojego kalendarza release'ów.

2. Wpychaj niezmienniki do bazy danych.

[a.id, b.id].sort plus indeks unikalny, walidacja distinct_participants, FK cascade — wszystko zaproponowane przez Claude'a, ale tylko po tym jak powiedziałem "żadnych konwersacji-luster", "żadnych self-DM", "delete cascade czysto". Im twardszy constraint, tym mniej bugów przeżywa.

3. Anti-abuse wyraża się w spec.

Z 41 spec ~60% to testy policy: blokada działa, tryb prywatności działa, rate limit działa. Te spec nie testują "czy fjcza działa?" — testują "czy nadużycie jest blokowane?". Jeden spec na scenariusz. Claude umie wymienić listę, ale tylko po tym jak powiesz mu jakie scenariusze bronisz.

4. UX polish wymaga rąk na prawdziwym produkcie.

Dwie godziny prawdziwej interakcji wyciągnęły trzy rzeczy, których ani code review, ani zielony test suite nigdy by nie złapały. Nie ma substytutu.


Od "chcę to" do "użytkownicy tego używają": główny PR o 21:53, polish o 23:36 — jeden wieczór. Ale rzadkim zasobem nie był czas na pisanie kodu, tylko czas na przemyślenie do końca scope'u, autoryzacji i obrony przed nadużyciami. Skończyć kod to dopiero początek; samemu używać produktu i ścierać zadziory — to co czyni go gotowym do wydania.