Личные диалоги (P2P)

Перед началом: seq и идемпотентная отправка (Модуль 3). Инструменты: PostgreSQL (LATERAL), Go. Трудоёмкость: ~5 ч.

🔥 Инцидент: «У вас новый диалог — с пустотой»

Пользователь случайно кликнул по чужому аватару — и у собеседника в списке появился диалог с пустой строкой и без имени: «диалог» без сообщений, с заголовком ''. Пара дней — и в списке живёт десяток пустых «переписок» из случайных кликов. Вторая жалоба из саппорта: «В списке написано direct, что это?» — оказалось, кто-то вывел conversation.title для личного диалога напрямую. В этом модуле собираем P2P-опыт: список диалогов, который не врёт, и создание переписки, которое не создаёт мусор.

📖 Теория

4.1 Жизненный цикл личного диалога

Решаем продуктовую механику и фиксируем её явно:

  • диалог создаётся осознанным действием («Написать») из контактов;
  • пустой диалог (без сообщений) скрыт из списка до первого сообщения;
  • заголовок direct не хранится: он собирается из имени собеседника на чтении. Имя изменилось — заголовки обновились у всех сразу. Хранить копию значило бы чинить её при каждом переименовании.

Правило «не хранить производное» — тот же принцип, что и last_seq vs count(messages): производное либо вычисляется, либо честно денормализуется с механикой поддержки.

4.2 Список диалогов одним запросом

Классическая задача «N+1 по последнему сообщению и собеседнику» решается одним SQL — через LATERAL:

sql
SELECT c.id, c.type, c.title, c.last_seq,
       p.last_read_seq,
       c.last_seq - p.last_read_seq AS unread,
       m.text AS last_text, m.created_at AS last_at,
       peer.user_id AS peer_id, u.display_name AS peer_name
FROM conversation_participants p
JOIN conversations c ON c.id = p.conversation_id
LEFT JOIN LATERAL (
    SELECT text, created_at, sender_id
    FROM messages WHERE conversation_id = c.id
    ORDER BY seq DESC LIMIT 1
) m ON true
LEFT JOIN conversation_participants peer
       ON peer.conversation_id = c.id AND peer.user_id <> $1
LEFT JOIN users u ON u.id = peer.user_id
WHERE p.user_id = $1
  AND EXISTS (SELECT 1 FROM messages WHERE conversation_id = c.id)
ORDER BY c.last_message_at DESC NULLS LAST;

Три вещи, за которые стоит зацепиться глазами:

  • LATERAL выполняет подзапрос «последнее сообщение» на каждый диалог, но это один поход в БД с индексом (conversation_id, seq DESC) — не N+1 из приложения;
  • EXISTS (messages) прячет пустые диалоги (§4.1);
  • ORDER BY last_message_at DESC NULLS LAST — активные наверху.

4.3 DTO для списка, а не «модель как есть»

Список диалогов — не Conversation из БД, а проекция под экран: последнее сообщение, непрочитанные, собеседник у direct. Это осознанный DTO: меняется интерфейс списка — меняется один SQL и один тип:

go
type ConversationListItem struct {
	ID       uuid.UUID
	Type     string
	Title    string    // group: из БД; direct: display_name собеседника
	Unread   int64
	LastText *string
	LastAt   *time.Time
	Peer     *UserCard
}

4.4 Контакты

Для «Написать» нужен список пользователей. Честный минимум для курса: пагинированный GET /api/v1/contacts?search= по display_name (ILIKE + индекс pg_trgm — врезка; на учебных объёмах достаточно LIKE). Полноценные «контакты с рукопожатием» — продуктовая тема за рамками курса.

💻 Практика

  1. Хендлер POST /api/v1/conversations с type=direct доводим до продуктового вида: тело {"type":"direct","peer_id":"…"}, ответ — DTO диалога (id + peer). Повторный вызов — тот же диалог (проверьте тестом идемпотентности из Модуля 2).

  2. GET /api/v1/conversations — по SQL из §4.2. Соберите ConversationListItem и отдайте:

jsonc
{
  "conversations": [{
    "id": "…", "type": "direct", "title": "Bob",
    "unread": 3, "last_text": "…", "last_at": "2026-10-29T…",
    "peer": { "id": "…", "display_name": "Bob", "email": "bob@example.dev" }
  }]
}
  1. GET /api/v1/contacts?search=bo — пользователи для старта переписки, исключая себя. Обновите openapi, make validate-api.

  2. Путь P2P целиком (можно curl-ом, до фронтенда из Модуля 8): Алиса вызывает direct с Бобом → отправляет сообщение → Боб видит диалог в списке с непрочитанным 1 → отмечает read → непрочитанное 0.

  3. Тесты:

    • чужой диалог не виден: список Боба не содержит диалог Алисы с Кэрол;
    • пустой диалог скрыт; после первого сообщения появляется;
    • unread на списке совпадает с last_seq - last_read_seq позиции.

Чек-лист модуля: заголовок direct собирается из имени собеседника; пустые диалоги не засоряют список; непрочитанные корректны; контакты есть; чужая переписка недоступна.

🧩 Quiz

1. Что делает LEFT JOIN LATERAL (... ORDER BY seq DESC LIMIT 1) в списке диалогов?
2. Почему непрочитанные считаются как last_seq - last_read_seq, а не подсчётом сообщений?
3. Список диалогов — это DTO или доменная модель?
4. Почему заголовок личного диалога не хранится в БД?
5. Повторный POST /conversations с тем же peer отвечает 201 и создаёт диалог заново?
6. Пустой диалог скрыт из списка. Какая часть запроса это обеспечивает?

✅ Итоги модуля

  • Жизненный цикл direct зафиксирован: создание действием, пустые скрыты, заголовок вычисляется.
  • Список диалогов — один SQL с LATERAL; непрочитанные — разность seq.
  • DTO списка отделён от доменной модели — экран меняется независимо от схемы.
  • Контакты дают точку входа в переписку; чужие диалоги недоступны by design.

🔗 Мостик к Модулю 5

Личная переписка работает. Следующий шаг — группы: несколько участников, роли с правами (кто может добавлять и удалять), системные сообщения («Алиса добавила Боба») и приглашения по ссылке. И заодно наведём порядок в правах: у группы, в отличие от личного диалога, «просто участник» — не единственная роль.