Группы — участники, роли, приглашения

Перед началом: P2P-диалоги (Модуль 4). Инструменты: PostgreSQL, Go, криптостойкий рандом. Трудоёмкость: ~6 ч.

🔥 Инцидент: «Группа осталась без владельца»

Два инцидента из одной недели. Первый: владелец группы удалил себя — и группа стала неуправляемой: добавить участника некому, прав нет ни у кого. Второй злее: обычный участник обнаружил, что может выгнать администратора — проверка прав в хендлере удаления смотрела только на то, что «удаляющий — участник». Оба — про отсутствие явной модели прав. В этом модуле: роли, матрица прав, системные сообщения и приглашения по ссылке.

📖 Теория

5.1 Роли и матрица прав

Роли уже лежат в conversation_participants.role с Модуля 2: owner | admin | member. Матрица — единственный источник правды для проверок в юзкейсах и тестов:

Действиеmemberadminowner
Читать, писать✅✅✅
Приглашать по ссылке✅✅✅
Добавлять/удалять member❌✅✅
Удалять admin❌❌✅
Переименовать группу❌✅✅
Передать владение❌❌✅
Удалить группу❌❌✅

Инварианты, которые обязаны держаться всегда:

  1. Владелец ровно один; владелец не может выйти, не передав владение.
  2. Себя удалить нельзя (для выхода — отдельная операция leave).
  3. Direct-диалоги из группы-механик исключены (роль всегда member, удалений нет).

Проверки ролей — в юзкейсе, но инвариант «владелец ровно один» стоит подкрепить схемой: частичный уникальный индекс.

sql
CREATE UNIQUE INDEX one_owner_per_conversation
    ON conversation_participants (conversation_id)
    WHERE role = 'owner';

5.2 Системные сообщения

Изменения состава — часть ленты. Два способа:

  • Текстом, собранным сервером: «Алиса добавила Боба» — быстро, но текст не переведёшь и он врёт при переименовании пользователя;
  • Структурой (kind='system', payload в JSON: {action, actor_id, target_id}), а текст собирает клиент — наш выбор: локализация и актуальные имена бесплатно.
sql
ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'text';
ALTER TABLE messages ADD COLUMN payload JSONB;
-- инвариант: у system-сообщения payload есть, text пуст
ALTER TABLE messages ADD CONSTRAINT system_has_payload
    CHECK (kind <> 'system' OR payload IS NOT NULL);

Системное сообщение пишется в той же транзакции, что и изменение состава: лента и состояние участников не могут разъехаться.

5.3 Приглашения по ссылке

Токен — криптостойкий, использования считаются атомарно:

sql
CREATE TABLE conversation_invites (
    token           TEXT PRIMARY KEY,
    conversation_id UUID NOT NULL REFERENCES conversations (id) ON DELETE CASCADE,
    created_by      UUID NOT NULL REFERENCES users (id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at      TIMESTAMPTZ,
    max_uses        INT,
    uses            INT NOT NULL DEFAULT 0
);

Вход по ссылке — один атомарный UPDATE, никаких «прочитал-проверил-записал»:

sql
UPDATE conversation_invites
SET uses = uses + 1
WHERE token = $1
  AND (expires_at IS NULL OR expires_at > now())
  AND (max_uses IS NULL OR uses < max_uses)
RETURNING conversation_id;

Нет строки — ссылка мертва (истекла/исчерпана). Есть — вставляем участника (ON CONFLICT DO NOTHING — повторный вход не ошибка) и системное сообщение. Токен генерируйте через crypto/rand, 128+ бит; в URL его шлют как POST /invites/{token}/join.

💻 Практика

  1. Миграция 000006_groups.{up,down}.sql: kind/payload у сообщений, one_owner_per_conversation, таблица conversation_invites. Туда же — contract-шаг из Модуля 2: ALTER TABLE messages DROP COLUMN room_id.

  2. Расширьте создание группы из Модуля 2: участники добавляются с ролями, создатель — owner, каждому добавленному (member_ids) — системное сообщение member.added.

  3. API участников:

POST   /conversations/{id}/members        { user_id }        admin+
DELETE /conversations/{id}/members/{uid}                     по матрице
PATCH  /conversations/{id}/members/{uid}  { role }           owner — админы
POST   /conversations/{id}/leave                              с передачей владения

Каждая операция: проверка прав по матрице → изменение состава → системное сообщение → outbox, всё в одной транзакции.

  1. Приглашения:
POST /conversations/{id}/invites { expires_in?: "24h", max_uses?: 10 }
→ { token, url: "https://…/join/AbC…" }
POST /invites/{token}/join
  1. Тесты по матрице — сгенерируйте кейсы из таблицы §5.1:
go
// табличный тест: (роль действующего, целевая роль, операция) → ожидание
// отдельно: владелец не может выйти без передачи; передача владения
// атомарна (в группе всегда ровно один owner)
  1. Обновите openapi и прогоните make validate-api. Транзишн-проверку: Боб выгнан из группы → API истории для него отвечает 403, его WS-подписка на диалог снимается (проверим в Модуле 6, пока — REST).

Чек-лист модуля: матрица прав покрыта тестами; владелец ровно один (индекс держит); системные сообщения в ленте; ссылка-приглашение работает, истекает и исчерпывается; выгнанный участник теряет доступ.

🧩 Quiz

1. Владелец нажал «покинуть группу». Что обязан сделать API?
2. Почему системные сообщения — структура (kind+payload), а не готовый текст?
3. Зачем частичный уникальный индекс WHERE role = 'owner'?
4. Участник вышел из группы. Куда должно попасть событие об этом?
5. Почему вход по ссылке — один UPDATE с условиями, а не SELECT + INSERT?
6. Может ли admin удалить другого admin?

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

  • Роли и матрица прав — явный контракт, покрытый табличными тестами.
  • Инварианты («владелец ровно один», «владелец не выходит молча») держат схема и юзкейс вместе.
  • Системные сообщения — структурные события в общей ленте, в одной транзакции с изменением состава.
  • Приглашения: крипто-токен, TTL, лимит использований, атомарный вход.
  • Выгнанный участник теряет и данные, и подписки.

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

Бэкенд умеет всё: диалоги, порядок, группы, права. Но доставка всё ещё устроена по-детски: WS-соединение сидит на одной комнате из query-строки, а после разрыва связи лента показывает дыру. В следующем модуле — сердце мессенджера: user-centric соединения, синхронизация по seq и read-receipts.