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.
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:
"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.
(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.
Dwa kawałki stanu konwersacji są z natury per-user:
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.
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 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:
deleted_at, treść opróżniona, wiersz zachowanypurge_later wysyła czyszczenie załącznika w tło, oszczędza storage"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.
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.
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.
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.