Як Claude зібрав 1:1 DM до бойової готовності за один вечір: scope, три шари анти-абʼюзу, Turbo за viewer та polish, який довелося додати мені.
Pickful сьогодні випустив особисті повідомлення. Чат один-на-один, текст плюс зображення, доставка в реальному часі, вікно відкликання 2 хвилини, rate limit за рівнями репутації, три режими приватності, врахування блокувань — 38 файлів, +1376 рядків, 41 spec, один PR.
Ця стаття не про "Claude чудовий". Вона про те, що реально відбувається, коли ви просите Claude додати функцію в соціальний продукт, який працює понад рік, де користувачі точно намагатимуться знущатися одне з одного: які рішення мусите приймати самі, що Claude пропускає на першому проході, що мені довелося латати потім.
DM — не коментарі. Коментарі — публічна площа, що кинеш — побачать усі. DM — приватна кімната рівно з двома людьми. Тобто кожна діра в моделі даних, у policy авторизації чи в каналі push безпосередньо перетворюється на "незнайомець може приліпити рекламу комусь на обличчя". Тож я не казав "зроби чат". Спершу зафіксував scope з Claude:
"Спочатку наточи лезо, потім вирішуй яке дерево валити" Claude зрозумів одразу. Цей принцип я використовував ще десяток разів: AI пише код швидко, але межі продукту — людське рішення.
(A,B) та (B,A)Класична пастка моделювання DM: A пише B і створює (A→B); B відповідає і створює (B→A); тепер два рядки для однієї бесіди і нічого не синхронізовано.
Розв'язок 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 плюс унікальний індекс на (user_one_id, user_two_id) — хто б не заговорив першим, обидва приземляються в один рядок. Просто, без міграцій для примирення близнюків, без склеювання дзеркальних записів на рівні застосунку.
Лукап peer(viewer) виходить чисто:
def peer(viewer)
viewer.id == user_one_id ? user_two : user_one
end
"Хто інший" — завжди тернарний оператор в один рядок. Запхати інваріант у шар БД значно надійніше, ніж форсувати його в коді застосунку.
Два шматки стану бесіди по суті per-user:
У першому чернетковому варіанті 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
Читання і запис диспатчаться за тим, на якому боці viewer:
def column_name(viewer, suffix)
viewer.id == user_one_id ? :"user_one_#{suffix}" : :"user_two_#{suffix}"
end
Трохи viewer dispatch у коді, мінус одна таблиця join і комплект індексів у схемі. Відношення з назавжди обмеженою кардинальністю N не заслуговує власної таблиці — це рішення з дизайну БД: Claude пропонує, ви вирішуєте.
Я прямо написав у prompt: "DM виходять тільки якщо захист від зловживань увімкнено за замовчуванням, всі перемикачі в безпечну сторону." 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/год для всіх" плоско — я попросив зробити ступінчасто за очками репутації: майже німі для безкоштовних нових акаунтів, щедро для дійсно активних користувачів. Ця логіка "не вбивайте арифметичну справедливість у код, проектуйте справедливість між рівнями користувачів" — це рішення продукту, не інженерне.
Повний шлюз у 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.
Відкликання обов'язкове (опечатки, не та картинка, не та людина), але в деталях легко спіткнутися:
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
Шматки:
deleted_at, контент очищаємо, рядок залишаємоpurge_later ставить очистку вкладення у фон, економить storage"Сотри контент, збережи запис" — стандартний шаблон особистого листування. Claude правильно написав логіку з першого разу, але image.purge_later — це вже моя правка: оригінал був image.purge, який блокує response.
Для real-time я сказав Claude використати Hotwire/Turbo, бо вся апка на цьому стеку. Цікаве — як нарізати 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 — одне й те саме повідомлення транслюється двічі, на два різні stream, кожного разу рендериться з іншим viewer.
Чому? Тому що partial повідомлення розгалужується на "я це відправив?" і "чи можу відкликати?" — результат рендера по суті різний для кожного viewer. Якщо broadcast одне payload і нехай клієнт вирішує — або виставляємо sender_id у фронт, або пишемо купу умовного CSS — жоден варіант не настільки чистий, як два рендери на сервері.
Бейдж 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 не передбачив. Залатав їх у 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 про це не подумав — він був зосереджений на "чи працює?", а не на "що, якщо користувач передумає посеред flow?".
2. Кнопка "Надіслати DM" у профілі була вертикально під Follow і тісно
Layout Claude складав кнопки вертикально, плюс ім'я tooltip heroicon "Chat-bubble-left-right" протікало. Я переробив на поряд з Follow, тільки іконка, з явним title, що перекриває протікання.
3. Аватари ламалися за розміром в inbox і заголовку треду
.avatar-wrapper мав width lock у конфлікті з утилітами w-X h-X Tailwind. Привів до конвенції partial user_card: прибрати wrapper, використовувати w-X h-X rounded-full object-cover напряму.
Кожне з трьох окремо — дрібниця. Разом — це відстань між "працює" і "реально сідає". Claude пише код швидко, але той сорт полірування, який помічаєш лише при використанні продукту, не можна делегувати — у нього немає очей, пальців, м'язової пам'яті інших сторінок тієї ж програми.
1. Scope — робота людини, не AI.
"Без груп, без read receipts, без reactions" коштувало більше за кожен рядок коду, що написав Claude. Послаб одне з них — графік подвоюється. Claude сам не запропонує різати scope, бо не знає вашого календаря релізів.
2. Штовхайте інваріанти в БД.
[a.id, b.id].sort плюс унікальний індекс, валідація distinct_participants, FK cascade — все запропонував Claude, але тільки після того як я сказав "жодних дзеркальних бесід", "жодних self-DM", "delete cascade чисто". Чим суворіше constraint, тим менше багів виживає.
3. Анти-абʼюз виражається в spec.
З 41 spec ~60% — тести policy: блок працює, режим приватності працює, rate limit працює. Ці spec не перевіряють "фіча працює?" — вони перевіряють "блокується чи зловживання?". Один spec на сценарій. Claude може перерахувати список, але тільки після того, як ви скажете які сценарії ви захищаєте.
4. UX polish вимагає рук на реальному продукті.
Дві години реальної взаємодії виловили три речі, які ні code review, ні зелена test suite ніколи б не зловили. Заміни немає.
Від "я хочу це" до "користувачі це використовують": основний PR о 21:53, polish о 23:36 — один вечір. Але дефіцитний ресурс був не час на написання коду, а час до кінця продумати scope, авторизацію і анти-абʼюз захист. Закінчити код — це лише початок; користуватися продуктом самому і шліфувати задирки — те, що робить його придатним до випуску.