Группы — участники, роли, приглашения
Перед началом: P2P-диалоги (Модуль 4). Инструменты: PostgreSQL, Go, криптостойкий рандом. Трудоёмкость: ~6 ч.
🔥 Инцидент: «Группа осталась без владельца»
Два инцидента из одной недели. Первый: владелец группы удалил себя — и группа стала неуправляемой: добавить участника некому, прав нет ни у кого. Второй злее: обычный участник обнаружил, что может выгнать администратора — проверка прав в хендлере удаления смотрела только на то, что «удаляющий — участник». Оба — про отсутствие явной модели прав. В этом модуле: роли, матрица прав, системные сообщения и приглашения по ссылке.
📖 Теория
5.1 Роли и матрица прав
Роли уже лежат в conversation_participants.role с Модуля 2:
owner | admin | member. Матрица — единственный источник правды для
проверок в юзкейсах и тестов:
| Действие | member | admin | owner |
|---|---|---|---|
| Читать, писать | ✅ | ✅ | ✅ |
| Приглашать по ссылке | ✅ | ✅ | ✅ |
| Добавлять/удалять member | ❌ | ✅ | ✅ |
| Удалять admin | ❌ | ❌ | ✅ |
| Переименовать группу | ❌ | ✅ | ✅ |
| Передать владение | ❌ | ❌ | ✅ |
| Удалить группу | ❌ | ❌ | ✅ |
Инварианты, которые обязаны держаться всегда:
- Владелец ровно один; владелец не может выйти, не передав владение.
- Себя удалить нельзя (для выхода — отдельная операция
leave). - Direct-диалоги из группы-механик исключены (роль всегда
member, удалений нет).
Проверки ролей — в юзкейсе, но инвариант «владелец ровно один» стоит подкрепить схемой: частичный уникальный индекс.
sqlCREATE 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}), а текст собирает клиент — наш выбор: локализация и актуальные имена бесплатно.
sqlALTER 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 Приглашения по ссылке
Токен — криптостойкий, использования считаются атомарно:
sqlCREATE 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, никаких «прочитал-проверил-записал»:
sqlUPDATE 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.
💻 Практика
-
Миграция
000006_groups.{up,down}.sql:kind/payloadу сообщений,one_owner_per_conversation, таблицаconversation_invites. Туда же — contract-шаг из Модуля 2:ALTER TABLE messages DROP COLUMN room_id. -
Расширьте создание группы из Модуля 2: участники добавляются с ролями, создатель —
owner, каждому добавленному (member_ids) — системное сообщениеmember.added. -
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, всё в одной транзакции.
- Приглашения:
POST /conversations/{id}/invites { expires_in?: "24h", max_uses?: 10 }
→ { token, url: "https://…/join/AbC…" }
POST /invites/{token}/join
- Тесты по матрице — сгенерируйте кейсы из таблицы §5.1:
go// табличный тест: (роль действующего, целевая роль, операция) → ожидание // отдельно: владелец не может выйти без передачи; передача владения // атомарна (в группе всегда ровно один owner)
- Обновите openapi и прогоните
make validate-api. Транзишн-проверку: Боб выгнан из группы → API истории для него отвечает 403, его WS-подписка на диалог снимается (проверим в Модуле 6, пока — REST).
Чек-лист модуля: матрица прав покрыта тестами; владелец ровно один (индекс держит); системные сообщения в ленте; ссылка-приглашение работает, истекает и исчерпывается; выгнанный участник теряет доступ.
🧩 Quiz
WHERE role = 'owner'?✅ Итоги модуля
- Роли и матрица прав — явный контракт, покрытый табличными тестами.
- Инварианты («владелец ровно один», «владелец не выходит молча») держат схема и юзкейс вместе.
- Системные сообщения — структурные события в общей ленте, в одной транзакции с изменением состава.
- Приглашения: крипто-токен, TTL, лимит использований, атомарный вход.
- Выгнанный участник теряет и данные, и подписки.
🔗 Мостик к Модулю 6
Бэкенд умеет всё: диалоги, порядок, группы, права. Но доставка всё ещё
устроена по-детски: WS-соединение сидит на одной комнате из query-строки,
а после разрыва связи лента показывает дыру. В следующем модуле —
сердце мессенджера: user-centric соединения, синхронизация по seq и
read-receipts.