MVP Spec 04 — Admin Roles & Item/Review Moderation
Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев
MVP Spec 04 — Admin Roles & Item/Review Moderation
Паспорт документа
- Статус документа: working spec
- Актуально на: 28 марта 2026 года
- Владелец: backend/platform-команда
- Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
- Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
- Связанные документы:
Version: MVP · Priority: P0 · Phase: A (Supply) Status: Draft v1
1. Контекст и цель
Административная панель — инструмент внутренней команды Qadam для обеспечения качества контента, соблюдения правил платформы и управления пользователями.
Три ключевые проблемы, которые решает этот модуль:
- Критичный баг с видимостью: айтемы в статусах
pending,draft,rejected,revision_required,archivedпоявляются в публичном каталоге. После реализации этого модуля в каталоге отображаются ТОЛЬКОactiveайтемы сitem_isvisible = true. - Нет ролевой модели: единственная роль
ADMINбез разграничения обязанностей. Этот модуль вводит четыре роли с разными уровнями доступа. - Нет модерации отзывов: продавец не может оспорить отзыв, который нарушает правила.
Цель модуля: дать команде Qadam инструменты для:
- Создания и управления аккаунтами администраторов с разными ролями
- Проверки и принятия решений по айтемам продавцов (approve/reject/revision)
- Рассмотрения жалоб продавцов на отзывы
- Управления аккаунтами пользователей (block/unblock/send to review)
Что не входит в этот модуль:
- Создание айтемов продавцами → Spec 02
- Публичный каталог и поиск → Spec 05
- Управление отзывами со стороны покупателя → Spec 14
- Жалоба продавца на отзыв (триггер для UC-06) → Spec 14
- Аналитика и дашборд для Analyst → Spec 11 (seller), отдельная задача
- Reference data (subjects, locations) — Spec 16
2. Роли пользователей
Роли администраторов платформы
| Роль | Кодовое имя | Доступы |
|---|---|---|
| Root | root | Полный доступ ко всему. Создаёт других администраторов. Единственный, кто может назначать/менять роли. |
| Верификатор | verifier | Модерация айтемов + модерация отзывов |
| Аналитик | analyst | Дашборд + все лиды + просмотр пользователей (без блокировки) |
| Маркетолог | marketer | Управление reference data (subjects, locations) + просмотр пользователей (без блокировки) |
Матрица доступов
| Действие | Root | Верификатор | Аналитик | Маркетолог |
|---|---|---|---|---|
| Создать/удалить admin | ✅ | ❌ | ❌ | ❌ |
| Изменить роль admin | ✅ | ❌ | ❌ | ❌ |
| Модерация айтемов | ✅ | ✅ | ❌ | ❌ |
| Модерация отзывов | ✅ | ✅ | ❌ | ❌ |
| Блокировка пользователей | ✅ | ❌ | ❌ | ❌ |
| Просмотр пользователей | ✅ | ❌ | ✅ | ✅ |
| Дашборд + все лиды | ✅ | ❌ | ✅ | ❌ |
| Reference data (CRUD) | ✅ | ❌ | ❌ | ✅ |
3. Use Cases
UC-01: Root создаёт аккаунт администратора
Актор: Root (авторизованный admin с ролью root)
Предусловие: Root авторизован, находится в /admin
Триггер: Root нажимает "+ Добавить администратора" в /admin/team
Полный поток:
[Точка входа]
→ Root в навигации /admin нажимает раздел "Команда" → /admin/team
→ Видит список всех администраторов
→ Нажимает "+ Добавить администратора"
→ Открывается модал "Новый администратор"
─────────────────────────────────────────────────────────
ФОРМА СОЗДАНИЯ АДМИНИСТРАТОРА
─────────────────────────────────────────────────────────
→ Поля:
- Имя * (2–50 символов)
- Фамилия * (2–50 символов)
- Email * (будет логином)
- Роль * (radio):
◉ Верификатор — модерация айтемов и отзывов
○ Аналитик — дашборд, лиды, просмотр пользователей
○ Маркетолог — справочные данные, просмотр пользователей
→ Root нажимает "Создать"
→ Система:
1. Создаёт Account { account_type: ADMIN, account_status: active }
2. Создаёт AdminProfile { admin_role: выбранная роль }
3. Генерирует временный пароль
4. Отправляет письмо на email:
"Вы добавлены в команду Qadam.
Ваша роль: {роль}.
Войдите: https://qadam.uz/login
Логин: {email}
Временный пароль: {temp_password}"
→ Toast (зелёный): "Администратор создан. Приглашение отправлено на {email}."
→ Новый admin появляется в списке /admin/team
UC-01 — Альтернативные потоки и обработка ошибок
1a. Email уже зарегистрирован в системе:
Триггер: POST возвращает 409 EMAIL_TAKEN
UI-реакция:
→ Поле email: красная обводка + ⚠
→ Под полем: "Этот email уже используется другим аккаунтом на платформе."
→ Форма не закрывается
1b. Обязательное поле не заполнено:
UI-реакция:
→ Пустые поля: красная обводка + ⚠
→ Под каждым: "Это поле обязательно для заполнения"
→ Форма не отправляется
1c. Роль не выбрана:
UI-реакция:
→ Блок с radio-кнопками: красная обводка
→ Под блоком: "Выберите роль для нового администратора"
1d. Ошибка отправки email (SMTP недоступен):
Поведение:
→ Аккаунт СОЗДАЁТСЯ (запись в БД сохранена)
→ Toast (жёлтый): "Администратор создан, но письмо-приглашение не отправлено.
Передайте пароль вручную или повторите отправку."
→ В карточке admin: кнопка "Повторно отправить приглашение"
1e. Root пытается создать другого Root:
UI-реакция:
→ Роль "Root" не присутствует в списке radio-кнопок
→ Через API: 403 { error: 'CANNOT_CREATE_ROOT', message: 'Роль Root нельзя создать через интерфейс.' }
UC-02: Верификатор просматривает очередь модерации айтемов
Актор: Верификатор или Root Предусловие: Admin авторизован Триггер: Admin переходит на /admin/moderation/items
Полный поток:
[Точка входа]
→ Admin в навигации нажимает "Модерация" → открывается /admin/moderation/items
→ Страница показывает очередь: список айтемов со статусом `pending`
Отсортировано по дате отправки: старые сначала (FIFO)
─────────────────────────────────────────────────────────
СПИСОК ОЧЕРЕДИ МОДЕРАЦИИ
─────────────────────────────────────────────────────────
→ Каждая строка таблицы содержит:
- Название айтема
- Продавец (org_name)
- Дата отправки на модерацию
- Это повторная модерация? (бейдж "Повторно" если статус менялся ранее)
- Кнопка "Рассмотреть"
→ Фильтры вверху:
- Показывать: Все pending / Первичные / Повторные
- Сортировка: Старые сначала / Новые сначала
→ Счётчик: "В очереди: 47 айтемов"
→ Пустое состояние (если очередь пуста):
Иллюстрация + "Очередь пуста. Все айтемы проверены ✓"
UC-02 — Альтернативные потоки
2a. Нет доступа (роль Аналитик или Маркетолог):
UI-реакция:
→ Раздел "Модерация" не отображается в навигации
→ При прямом переходе на /admin/moderation/items:
Страница 403: "У вас нет доступа к этому разделу."
UC-03: Верификатор одобряет айтем
Актор: Верификатор или Root
Предусловие: Айтем существует, moderation_status = pending
Триггер: Admin нажимает "Рассмотреть" на строке айтема
Полный поток:
[Точка входа]
→ В /admin/moderation/items нажимает "Рассмотреть"
→ Открывается /admin/moderation/items/[item_id]
→ Страница детального просмотра айтема:
─────────────────────────────────────────────────────────
ПРОСМОТР АЙТЕМА НА МОДЕРАЦИИ
─────────────────────────────────────────────────────────
Левая колонка — полная карточка айтема:
- Название, описание, все параметры
- Цены и варианты оплаты
- Фото и видео
- Преподаватели (если привязаны)
- Профиль продавца (org_name, статус, история нарушений)
Правая колонка — панель решения:
[✅ Одобрить]
[✏ Отправить на доработку]
[❌ Отклонить]
─────────────────────────────────────────────────────────
ДЕЙСТВИЕ: ОДОБРИТЬ
─────────────────────────────────────────────────────────
→ Admin нажимает "✅ Одобрить"
→ Диалог подтверждения:
"Одобрить айтем «{название}»?
Он появится в каталоге немедленно."
[Отмена] [Одобрить]
→ При подтверждении:
- Item.moderation_status = active
- Item.item_isvisible = true (если не был скрыт продавцом вручную)
- Item.moderation_comment = null (сбрасываем старый комментарий)
- Item.moderated_by = admin_id
- Item.moderated_at = now()
→ Айтем исчезает из очереди
→ Toast (зелёный): "Айтем одобрен и опубликован в каталоге."
→ Возврат к списку очереди /admin/moderation/items
→ (Фоново) Продавец получает уведомление: "Ваш курс «{название}» прошёл проверку и опубликован."
UC-03 — Альтернативные потоки
3a. Технический сбой при сохранении решения:
UI-реакция:
→ Toast (красный): "Не удалось сохранить решение. Попробуйте ещё раз."
→ Статус айтема не изменяется
→ Кнопки решения снова активны
3b. Айтем уже промодерирован другим верификатором (race condition):
Триггер: PATCH возвращает 409 ITEM_ALREADY_MODERATED
UI-реакция:
→ Toast (жёлтый): "Этот айтем уже промодерирован другим верификатором. Статус: {active/rejected}."
→ Страница обновляет статус айтема
→ Кнопки решения блокируются
UC-04: Верификатор отклоняет айтем с комментарием
Актор: Верификатор или Root
Предусловие: Айтем существует, moderation_status = pending
Триггер: Admin нажимает "❌ Отклонить" в панели решения
Полный поток:
[Точка входа]
→ Admin находится на /admin/moderation/items/[item_id]
→ Нажимает "❌ Отклонить"
→ Панель разворачивается (или появляется модал):
─────────────────────────────────────────────────────────
ФОРМА ОТКЛОНЕНИЯ
─────────────────────────────────────────────────────────
→ Причина отклонения * (обязательно):
- Выбор из шаблонов (dropdown или radio):
○ Неполное описание курса
○ Нарушение правил платформы
○ Недостоверная информация (ложные обещания)
○ Запрещённый контент
○ Дублирующий айтем
○ Другое (требует текста)
- Текстовое поле "Комментарий для продавца" * (обязательно):
placeholder: "Опишите, что именно не соответствует требованиям..."
max 1000 символов
→ Чекбокс: "Отправить продавцу уведомление" (default: включён)
→ Кнопка "Подтвердить отклонение"
─────────────────────────────────────────────────────────
ПОСЛЕ ПОДТВЕРЖДЕНИЯ
─────────────────────────────────────────────────────────
→ Item.moderation_status = rejected
→ Item.moderation_comment = {текст комментария}
→ Item.item_isvisible = false (принудительно)
→ Item.moderated_by = admin_id
→ Item.moderated_at = now()
→ Продавец видит в /seller/items: бейдж "Отклонён" + комментарий
→ Toast (зелёный): "Айтем отклонён. Продавец уведомлён."
→ Возврат к /admin/moderation/items
Что видит продавец после отклонения:
В /seller/items на карточке айтема:
Бейдж: [Отклонён]
Блок с причиной (раскрывающийся):
"Ваш курс отклонён.
Причина: {shablон}
Комментарий: {текст от верификатора}
Исправьте указанные недостатки и отправьте курс на повторную проверку."
Кнопка: "Редактировать и отправить повторно"
UC-04 — Альтернативные потоки
4a. Комментарий не заполнен (нажали "Подтвердить" без текста):
UI-реакция:
→ Поле комментария: красная обводка + ⚠
→ Под полем: "Укажите причину отклонения для продавца"
→ Форма не отправляется
4b. Комментарий превышает 1000 символов:
UI-реакция:
→ Счётчик под полем: "987/1000" → при превышении: красный "1023/1000"
→ Поле: красная обводка
→ Под полем: "Максимум 1000 символов"
UC-05: Верификатор отправляет айтем на доработку
Актор: Верификатор или Root
Предусловие: Айтем существует, moderation_status = pending
Триггер: Admin нажимает "✏ Отправить на доработку"
Полный поток:
[Точка входа]
→ Admin находится на /admin/moderation/items/[item_id]
→ Нажимает "✏ Отправить на доработку"
→ Панель разворачивается:
─────────────────────────────────────────────────────────
ФОРМА "НА ДОРАБОТКУ"
─────────────────────────────────────────────────────────
→ Что нужно исправить * (обязательно):
- Чекбоксы с типичными недочётами (мультивыбор):
☐ Добавьте фотографии курса
☐ Уточните описание и программу
☐ Укажите точную цену
☐ Добавьте информацию о преподавателях
☐ Уточните формат занятий (онлайн/офлайн)
☐ Другое
- Текстовое поле "Что именно нужно исправить" * (обязательно):
placeholder: "Опишите конкретные правки..."
max 1000 символов
→ Кнопка "Отправить на доработку"
─────────────────────────────────────────────────────────
ПОСЛЕ ПОДТВЕРЖДЕНИЯ
─────────────────────────────────────────────────────────
→ Item.moderation_status = revision_required
→ Item.moderation_comment = {текст с перечислением выбранных пунктов + комментарий}
→ Item.item_isvisible = false
→ Item.moderated_by = admin_id
→ Item.moderated_at = now()
→ Toast: "Айтем отправлен на доработку. Продавец уведомлён."
→ Возврат к /admin/moderation/items
Продавец видит в /seller/items:
Бейдж: [Нужна доработка]
Текст: "Исправьте следующее и повторно отправьте на проверку:"
{список пунктов}
{комментарий верификатора}
Кнопка: "Исправить и отправить повторно"
UC-05 — Альтернативные потоки
5a. Ни один чекбокс не выбран и текст не заполнен:
UI-реакция:
→ Блок чекбоксов: красная обводка
→ Под блоком: "Выберите хотя бы один пункт или опишите, что нужно исправить"
UC-06: Модерация отзывов (жалоба от продавца)
Актор: Верификатор или Root
Предусловие: Продавец оспорил отзыв через свой кабинет (Spec 14), отзыв переведён в статус pending_moderation
Триггер: Admin переходит на /admin/moderation/reviews
Контекст: Отзывы публикуются немедленно (статус active по умолчанию). Если продавец считает отзыв нарушающим правила — он подаёт жалобу. После жалобы отзыв не скрывается автоматически — он остаётся видимым, но попадает в очередь модерации.
Полный поток:
[Точка входа]
→ Admin в навигации нажимает "Модерация" → вкладка "Отзывы"
→ Открывается /admin/moderation/reviews
→ Список отзывов на рассмотрении:
─────────────────────────────────────────────────────────
СПИСОК ОЧЕРЕДИ: ОТЗЫВЫ
─────────────────────────────────────────────────────────
Каждая строка:
- Айтем, к которому относится отзыв
- Имя покупателя (или "Аноним")
- Рейтинг (1–5 звёзд)
- Дата отзыва / дата жалобы
- Начало текста отзыва
- Кнопка "Рассмотреть"
─────────────────────────────────────────────────────────
ДЕТАЛЬНАЯ СТРАНИЦА: /admin/moderation/reviews/[review_id]
─────────────────────────────────────────────────────────
→ Блок "Отзыв":
- Полный текст отзыва
- Рейтинг
- Дата публикации
- Покупатель (имя/аноним, кол-во отзывов)
→ Блок "Жалоба продавца":
- Название школы
- Причина жалобы (что выбрал продавец): {шаблон}
- Комментарий продавца к жалобе
→ Блок "Правила платформы" (краткая справка: что является нарушением)
→ Внутренний комментарий (admin only) — опциональное поле:
placeholder: "Заметки для внутреннего использования (не видны продавцу и покупателю)"
max 500 символов
→ Панель решения:
[✅ Вернуть в опубликованные] ← отзыв остаётся видимым
[❌ Удалить отзыв] ← нарушение подтверждено
─────────────────────────────────────────────────────────
ДЕЙСТВИЕ А: ВЕРНУТЬ В ОПУБЛИКОВАННЫЕ
─────────────────────────────────────────────────────────
→ Admin нажимает "✅ Вернуть в опубликованные"
→ Диалог: "Оставить отзыв? Он останется опубликованным. Жалоба будет закрыта."
→ [Отмена] [Подтвердить]
→ Review.moderation_status = active
→ Review.moderation_note = {внутренний комментарий, если заполнен}
→ Toast: "Отзыв оставлен. Жалоба закрыта."
→ (Фоново) Продавцу: "Ваша жалоба на отзыв рассмотрена. Отзыв остаётся на платформе."
─────────────────────────────────────────────────────────
ДЕЙСТВИЕ Б: УДАЛИТЬ ОТЗЫВ
─────────────────────────────────────────────────────────
→ Admin нажимает "❌ Удалить отзыв"
→ Модал:
"Удалить отзыв?
Укажите причину удаления (видна только продавцу и покупателю):
[текстовое поле, обязательное, max 500 символов]"
[Отмена] [Удалить]
→ Review.moderation_status = rejected
→ Review.moderation_note = {внутренний комментарий}
→ Review.rejection_reason = {причина для участников}
→ Review скрывается из публичного каталога
→ Toast: "Отзыв удалён. Стороны уведомлены."
→ (Фоново) Продавцу: "Ваша жалоба подтверждена. Отзыв удалён."
→ (Фоново) Покупателю: "Ваш отзыв был удалён администрацией. Причина: {rejection_reason}"
UC-06 — Альтернативные потоки
6a. Причина удаления не заполнена:
UI-реакция:
→ Поле: красная обводка + ⚠
→ Под полем: "Укажите причину удаления для покупателя и продавца"
6b. Отзыв уже промодерирован другим верификатором (race condition):
Триггер: PATCH возвращает 409 REVIEW_ALREADY_MODERATED
UI-реакция:
→ Toast (жёлтый): "Этот отзыв уже рассмотрен."
→ Кнопки блокируются
UC-07: Управление аккаунтами пользователей
Актор: Root Предусловие: Root авторизован Триггер: Root переходит в /admin/users или /admin/users/sellers
Полный поток — просмотр и поиск:
[Точка входа]
→ Root в навигации нажимает "Пользователи" → /admin/users/sellers
→ Таблица продавцов:
- Название организации
- Тип (school_offline / online_school / individual_contributor)
- Email / Телефон
- Статус аккаунта (бейдж: [Активен] / [На проверке] / [Заблокирован])
- Дата регистрации
- Кол-во активных айтемов
- Действия: "Подробнее" / "Заблокировать" / "Разблокировать"
→ Поиск: по email, телефону, названию организации
→ Фильтр: по статусу (active / under_review / blocked)
Поток — заблокировать продавца:
→ Root нажимает "Заблокировать" у нужного продавца
→ Модал:
"Заблокировать аккаунт {org_name}?
Причина блокировки (видна продавцу):
[textarea, опционально, max 500 символов]
Продавец получит уведомление об блокировке."
[Отмена] [Заблокировать]
→ Account.account_status = blocked
→ Все айтемы продавца скрываются из каталога (принудительно)
→ Продавец при следующей попытке войти видит:
Страница /seller/blocked:
"Ваш аккаунт заблокирован.
Причина: {причина если указана}
Если вы считаете это ошибкой, напишите нам: support@qadam.uz"
→ Toast: "Аккаунт заблокирован."
Поток — разблокировать продавца:
→ Root нажимает "Разблокировать"
→ Диалог: "Разблокировать аккаунт {org_name}? Все его айтемы (в статусе active) вернутся в каталог."
→ [Отмена] [Разблокировать]
→ Account.account_status = active
→ Айтемы восстанавливаются в каталоге если были active до блокировки
→ Toast: "Аккаунт разблокирован."
Поток — отправить на проверку:
→ Root нажимает "Отправить на проверку"
→ Account.account_status = under_review
→ Продавец видит в /seller баннер:
"Ваш аккаунт находится на проверке. Создание и редактирование курсов временно недоступно.
Обратитесь в поддержку: support@qadam.uz"
→ Айтемы продавца скрываются из каталога на время проверки
UC-07 — Альтернативные потоки
7a. Роль Аналитик или Маркетолог пытается заблокировать пользователя:
UI-реакция:
→ Кнопки "Заблокировать" / "Разблокировать" не отображаются (скрыты по роли)
→ Через API: 403 { error: 'INSUFFICIENT_ROLE', message: 'Управление блокировками доступно только Root.' }
7b. Верификатор переходит в /admin/users:
UI-реакция:
→ Раздел "Пользователи" не отображается в навигации для роли verifier
→ 403 при прямом переходе
4. Бизнес-правила и валидации
Правила модерации айтемов
| Правило | Описание |
|---|---|
| Очередь только pending | В очереди модерации отображаются ТОЛЬКО айтемы со статусом pending. Черновики, архивные — не попадают. |
| FIFO-сортировка | По умолчанию: старые айтемы (по дате отправки) — первые. |
| Комментарий обязателен | При rejected и revision_required — текстовый комментарий обязателен. При approve — комментарий сбрасывается. |
| Повторная модерация | После rejected/revision_required: продавец правит → снова pending → возвращается в очередь с пометкой "Повторно". |
| Защита от race condition | Первый ответивший верификатор "захватывает" айтем. Второй получает 409. |
| Критичный инвариант каталога | В публичный каталог попадают ТОЛЬКО: moderation_status = active AND item_isvisible = true AND seller.account_status = active. |
Правила модерации отзывов
| Правило | Описание |
|---|---|
| Отзывы видны сразу | По умолчанию review_status = active. Жалоба продавца НЕ скрывает отзыв автоматически — он остаётся видимым. |
| Одна активная жалоба | На один отзыв — только одна активная жалоба. Повторная жалоба до рассмотрения первой — заблокирована. |
| Внутренний комментарий | Заметки верификатора видны ТОЛЬКО администраторам. Не попадают в публичный API. |
| Срок рассмотрения | TBD (не определён на MVP). |
Правила ролевой модели
| Правило | Описание |
|---|---|
| Root уникален | Роль root не создаётся через UI. Только seed при деплое или прямая правка БД. |
| Root неудаляем через UI | Root-аккаунт нельзя заблокировать или удалить через интерфейс. |
| Смена роли — только Root | Только Root может изменить роль другого admin. |
| Блокировка продавца — только Root | Верификатор, Аналитик, Маркетолог не могут блокировать пользователей. |
Таблица валидаций полей
| Поле | Правило | Ошибка пользователю |
|---|---|---|
| Email нового admin | RFC 5322, уникальный | "Этот email уже используется другим аккаунтом." |
| Имя / Фамилия admin | 2–50 символов | "Имя: от 2 до 50 символов" |
| Комментарий модерации (обязательный) | 1–1000 символов | "Укажите причину отклонения/доработки (до 1000 символов)" |
| Внутренняя заметка верификатора | 0–500 символов | "Максимум 500 символов" |
| Причина удаления отзыва | 1–500 символов | "Укажите причину удаления (до 500 символов)" |
| Причина блокировки пользователя | 0–500 символов (опционально) | — |
5. Модель данных
Используются существующие сущности: Account, Seller, Item, Review. Новые: AdminProfile, ReviewComplaint.
AdminProfile (профиль администратора)
| Атрибут | Тип | Описание |
|---|---|---|
| admin_id | UUID | PK |
| account_id | UUID FK | → Account (account_type = ADMIN), unique |
| admin_role | AdminRole | root / verifier / analyst / marketer |
| created_by | UUID FK | → Account (кто создал, nullable для seed root) |
| created_at | DateTime | |
| updated_at | DateTime |
Item (расширение существующей сущности)
| Атрибут | Тип | Описание |
|---|---|---|
| item_id | UUID | PK (существующий) |
| seller_id | UUID FK | → Seller (существующий) |
| item_name | string | (существующий) |
| item_slug | string | (существующий) |
| moderation_status | ItemStatus | draft/pending/active/rejected/revision_required/archived |
| item_isvisible | boolean | default: true. Управляется продавцом. |
| moderation_comment | text? | Комментарий верификатора — виден продавцу |
| moderated_by | UUID FK? | → Account (admin), nullable |
| moderated_at | DateTime? | Дата последнего решения по модерации |
Review (расширение существующей сущности, детали в Spec 14)
| Атрибут | Тип | Описание |
|---|---|---|
| review_id | UUID | PK |
| review_status | ReviewStatus | active / pending_moderation / rejected |
| moderation_note | text? | Внутренняя заметка верификатора (admin only) |
| rejection_reason | text? | Причина удаления (видна продавцу и покупателю) |
| moderated_by | UUID FK? | → Account (admin) |
| moderated_at | DateTime? |
ReviewComplaint (жалоба продавца на отзыв)
| Атрибут | Тип | Описание |
|---|---|---|
| complaint_id | UUID | PK |
| review_id | UUID FK | → Review |
| seller_id | UUID FK | → Seller (жалующийся) |
| complaint_reason | ComplaintReason | spam / fake / offensive / other |
| complaint_comment | text? | Пояснение от продавца (max 500 символов) |
| complaint_status | ComplaintStatus | open / resolved |
| created_at | DateTime | |
| resolved_at | DateTime? |
6. Технические контракты
6.1 Prisma Schema (добавления)
enum AdminRole {
root
verifier
analyst
marketer
}
enum ItemStatus {
draft
pending
active
rejected
revision_required
archived
}
enum ReviewStatus {
active
pending_moderation
rejected
}
enum ComplaintReason {
spam
fake
offensive
other
}
enum ComplaintStatus {
open
resolved
}
model AdminProfile {
admin_id String @id @default(uuid())
account_id String @unique
admin_role AdminRole
created_by String? // nullable для seed root
created_at DateTime @default(now())
updated_at DateTime @updatedAt
account Account @relation(fields: [account_id], references: [account_id])
}
model Item {
item_id String @id @default(uuid())
seller_id String
item_name String @db.VarChar(200)
item_slug String @unique
moderation_status ItemStatus @default(draft)
item_isvisible Boolean @default(true)
moderation_comment String? @db.Text
moderated_by String?
moderated_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
seller Seller @relation(fields: [seller_id], references: [seller_id])
moderator Account? @relation("ItemModerator", fields: [moderated_by], references: [account_id])
reviews Review[]
leads Lead[]
}
// Расширения Review (подробная схема — Spec 14):
// review_status, moderation_note, rejection_reason, moderated_by, moderated_at
model ReviewComplaint {
complaint_id String @id @default(uuid())
review_id String
seller_id String
complaint_reason ComplaintReason
complaint_comment String? @db.VarChar(500)
complaint_status ComplaintStatus @default(open)
created_at DateTime @default(now())
resolved_at DateTime?
seller Seller @relation(fields: [seller_id], references: [seller_id])
@@unique([review_id, seller_id]) // одна жалоба от продавца на один отзыв
}
6.2 TypeScript DTO
// ─── Создание администратора ───────────────────────────────────────────────
export class CreateAdminDto {
@IsString() @MinLength(2) @MaxLength(50)
first_name: string
@IsString() @MinLength(2) @MaxLength(50)
last_name: string
@IsEmail()
email: string
@IsEnum(['verifier', 'analyst', 'marketer'])
// root нельзя создать через UI
admin_role: 'verifier' | 'analyst' | 'marketer'
}
// ─── Решение по модерации айтема ──────────────────────────────────────────
export class ModerateItemDto {
@IsEnum(['active', 'rejected', 'revision_required'])
decision: 'active' | 'rejected' | 'revision_required'
@IsOptional() @IsString() @MinLength(1) @MaxLength(1000)
// обязателен при rejected и revision_required
moderation_comment?: string
}
// ─── Решение по модерации отзыва ──────────────────────────────────────────
export class ModerateReviewDto {
@IsEnum(['active', 'rejected'])
decision: 'active' | 'rejected'
@IsOptional() @IsString() @MaxLength(500)
moderation_note?: string // внутренняя заметка
@IsOptional() @IsString() @MinLength(1) @MaxLength(500)
// обязателен при decision = rejected
rejection_reason?: string
}
// ─── Управление статусом аккаунта пользователя ───────────────────────────
export class UpdateAccountStatusDto {
@IsEnum(['active', 'under_review', 'blocked'])
status: 'active' | 'under_review' | 'blocked'
@IsOptional() @IsString() @MaxLength(500)
reason?: string
}
// ─── Response ─────────────────────────────────────────────────────────────
export interface ModerationItemResponse {
item_id: string
item_name: string
item_slug: string
seller: {
seller_id: string
org_name: string
account_status: AccountStatus
}
moderation_status: ItemStatus
moderation_comment: string | null
submitted_at: string // дата отправки на текущую модерацию
is_resubmission: boolean
// полные данные айтема для просмотра:
description: string
short_desc: string
prices: ItemPriceDto[]
media: ItemMediaDto[]
performers: PerformerPublicDto[]
}
export interface ModerationQueueResponse {
items: ModerationItemResponse[]
total: number
pending_count: number
}
export interface AdminProfileResponse {
admin_id: string
account_id: string
first_name: string
last_name: string
email: string
admin_role: AdminRole
created_at: string
}
export interface ReviewModerationResponse {
review_id: string
review_text: string
rating: number
buyer_name: string | null
item_name: string
seller_name: string
review_status: ReviewStatus
complaint: {
complaint_id: string
complaint_reason: ComplaintReason
complaint_comment: string | null
created_at: string
}
moderation_note: string | null
}
6.3 API Endpoints
────────────────────────────────────────────────────────────────
ADMIN: УПРАВЛЕНИЕ КОМАНДОЙ (только Root)
────────────────────────────────────────────────────────────────
GET /api/admin/team
Auth: Bearer (admin: root)
→ 200: { admins: AdminProfileResponse[], total: number }
→ 403: { error: 'INSUFFICIENT_ROLE' }
POST /api/admin/team
Auth: Bearer (admin: root)
Body: CreateAdminDto
→ 201: AdminProfileResponse
→ 409: { error: 'EMAIL_TAKEN', message: 'Этот email уже используется другим аккаунтом.' }
→ 400: { error: 'CANNOT_CREATE_ROOT', message: 'Роль Root нельзя создать через интерфейс.' }
→ 422: { errors: ValidationError[] }
PATCH /api/admin/team/:admin_id/role
Auth: Bearer (admin: root)
Body: { admin_role: AdminRole }
→ 200: AdminProfileResponse
→ 400: { error: 'CANNOT_CHANGE_ROOT_ROLE', message: 'Роль Root нельзя изменить.' }
→ 403: { error: 'INSUFFICIENT_ROLE' }
→ 404: { error: 'ADMIN_NOT_FOUND' }
DELETE /api/admin/team/:admin_id
Auth: Bearer (admin: root)
→ 204
→ 400: { error: 'CANNOT_DELETE_ROOT', message: 'Root-аккаунт нельзя удалить через интерфейс.' }
→ 403: { error: 'INSUFFICIENT_ROLE' }
POST /api/admin/team/:admin_id/resend-invite
Auth: Bearer (admin: root)
→ 200: { message: 'Приглашение отправлено.' }
────────────────────────────────────────────────────────────────
ADMIN: МОДЕРАЦИЯ АЙТЕМОВ (Root + Верификатор)
────────────────────────────────────────────────────────────────
GET /api/admin/moderation/items
Auth: Bearer (admin: root | verifier)
Query: ?status=pending&sort=asc&type=all|primary|resubmission&page=1&limit=20
→ 200: ModerationQueueResponse
→ 403: { error: 'INSUFFICIENT_ROLE' }
GET /api/admin/moderation/items/:item_id
Auth: Bearer (admin: root | verifier)
→ 200: ModerationItemResponse
→ 403: { error: 'INSUFFICIENT_ROLE' }
→ 404: { error: 'ITEM_NOT_FOUND' }
PATCH /api/admin/moderation/items/:item_id
Auth: Bearer (admin: root | verifier)
Body: ModerateItemDto
→ 200: { item_id: string, moderation_status: ItemStatus, moderated_at: string }
→ 400: { error: 'COMMENT_REQUIRED', message: 'Укажите причину отклонения для продавца.' }
→ 403: { error: 'INSUFFICIENT_ROLE' }
→ 404: { error: 'ITEM_NOT_FOUND' }
→ 409: { error: 'ITEM_ALREADY_MODERATED', message: 'Этот айтем уже промодерирован.', current_status: ItemStatus }
────────────────────────────────────────────────────────────────
ADMIN: МОДЕРАЦИЯ ОТЗЫВОВ (Root + Верификатор)
────────────────────────────────────────────────────────────────
GET /api/admin/moderation/reviews
Auth: Bearer (admin: root | verifier)
Query: ?page=1&limit=20
→ 200: { reviews: ReviewModerationResponse[], total: number }
→ 403: { error: 'INSUFFICIENT_ROLE' }
GET /api/admin/moderation/reviews/:review_id
Auth: Bearer (admin: root | verifier)
→ 200: ReviewModerationResponse
→ 403: { error: 'INSUFFICIENT_ROLE' }
→ 404: { error: 'REVIEW_NOT_FOUND' }
PATCH /api/admin/moderation/reviews/:review_id
Auth: Bearer (admin: root | verifier)
Body: ModerateReviewDto
→ 200: { review_id: string, review_status: ReviewStatus }
→ 400: { error: 'REJECTION_REASON_REQUIRED', message: 'Укажите причину удаления отзыва.' }
→ 403: { error: 'INSUFFICIENT_ROLE' }
→ 409: { error: 'REVIEW_ALREADY_MODERATED' }
────────────────────────────────────────────────────────────────
ADMIN: УПРАВЛЕНИЕ ПОЛЬЗОВАТЕЛЯМИ (Root: блокировка; Analyst/Marketer: просмотр)
────────────────────────────────────────────────────────────────
GET /api/admin/users/sellers
Auth: Bearer (admin: root | analyst | marketer)
Query: ?status=active|under_review|blocked&search=term&page=1&limit=50
→ 200: { sellers: SellerAdminView[], total: number }
→ 403: { error: 'INSUFFICIENT_ROLE' }
GET /api/admin/users/sellers/:seller_id
Auth: Bearer (admin: root | analyst | marketer)
→ 200: SellerAdminView (полный профиль + история статусов + список айтемов)
→ 403: { error: 'INSUFFICIENT_ROLE' }
→ 404: { error: 'SELLER_NOT_FOUND' }
PATCH /api/admin/users/sellers/:seller_id/status
Auth: Bearer (admin: root only)
Body: UpdateAccountStatusDto
→ 200: { seller_id: string, account_status: AccountStatus }
→ 400: { error: 'INVALID_STATUS_TRANSITION', message: string }
→ 403: { error: 'INSUFFICIENT_ROLE', message: 'Управление блокировками доступно только Root.' }
→ 404: { error: 'SELLER_NOT_FOUND' }
GET /api/admin/users/buyers
Auth: Bearer (admin: root | analyst | marketer)
Query: ?page=1&limit=50&search=term
→ 200: { buyers: BuyerAdminView[], total: number }
────────────────────────────────────────────────────────────────
ПУБЛИЧНЫЙ КАТАЛОГ (критичный инвариант — проверка на бэкенде)
────────────────────────────────────────────────────────────────
// Любой GET /api/items или /api/items?... ОБЯЗАН включать фильтр:
// WHERE moderation_status = 'active'
// AND item_isvisible = true
// AND seller.account_status = 'active'
// Это не опция — это жёсткий инвариант, нарушение = критичный баг.
7. Edge Cases и обработка ошибок
| Сценарий | Поведение |
|---|---|
| Верификатор одновременно открыл один айтем с коллегой | Первый сохранивший решение получает 200. Второй — 409 ITEM_ALREADY_MODERATED. |
| Продавец отозвал айтем с модерации пока верификатор его смотрит | PATCH от верификатора возвращает 409 с сообщением: "Продавец отозвал айтем с проверки." |
| Продавец заблокирован: что с его pending айтемами? | Pending айтемы остаются в очереди. После разблокировки — продавец сам решает что делать. |
| Root удаляет сам себя | 400: CANNOT_DELETE_ROOT. Защита на уровне бэкенда. |
| Аналитик пытается открыть /admin/moderation через прямой URL | 403 INSUFFICIENT_ROLE. Навигация скрыта. |
| Отзыв удалён, но продавец повторно подаёт жалобу на него | 400: REVIEW_ALREADY_REJECTED. |
| Продавец заблокирован — его айтемы в статусе active | Айтемы НЕ меняют moderation_status, но не появляются в каталоге (фильтр по seller.account_status). При разблокировке — мгновенно возвращаются. |
| Верификатор пытается блокировать пользователя | 403 INSUFFICIENT_ROLE. Нет UI-кнопки для этой роли. |
| Item со статусом draft попал в каталог (защита от бага) | Backend: жёсткий WHERE-фильтр. Middleware для catalog-endpoints обязательно добавляет moderation_status = 'active'. |
| Admin входит через /login с обычными credentials | Стандартный /login работает для всех account_type. После логина: редирект на /admin вместо /seller. |
8. TBD / Сознательно опущено
| Тема | Статус | Примечание |
|---|---|---|
| Ролевая модель в MVP для Login | TBD | При входе через /login: если account_type = ADMIN → редирект /admin. Логика редиректа не специфицирована отдельно. |
| Уведомления продавцам о решениях по модерации | Частично | На MVP: уведомление через кабинет (бейдж + комментарий). Email/Telegram-уведомление — v1.0. |
| Аудит-лог действий администраторов | Исключено из MVP | Все модерационные действия должны логироваться. Реализация audit_log — v1.0. |
| SLA на модерацию | TBD | Время ответа по жалобе на отзыв не определено. |
| Пагинация очереди модерации | Реализовать | limit=20 по умолчанию. Важно для масштаба (800+ продавцов → тысячи айтемов). |
| Bulk-действия (одобрить/отклонить несколько сразу) | Исключено из MVP | Только единичные действия на MVP. Bulk — v1.0. |
| Автоматические правила модерации (ML) | Вне скоупа | Не планируется в MVP. |
| Admin 2FA | Исключено из MVP | Критично для безопасности. Добавить в v1.0. |
| "Захват" айтема (assign to me) | TBD | Нужен ли механизм назначения айтема на конкретного верификатора? На MVP — нет. |
| История решений по конкретному айтему | TBD | Показывать ли верификатору историю предыдущих решений? Полезно при повторной модерации. |
| Причины блокировки продавца — шаблоны | TBD | Свободный текст на MVP. Шаблоны причин — v1.0. |
| Модерация Buyer-аккаунтов | Исключено из MVP | На MVP байеры не модерируются. |
Зависимости
| Модуль | Связь |
|---|---|
| Spec 01 (Seller Onboarding) | Account.account_status изменяется в UC-07 |
| Spec 02 (Item Management) | Item.moderation_status и Item.item_isvisible — основные сущности UC-02–UC-05 |
| Spec 03 (Staff Management) | PerformerProfile видима только при active seller (фильтр в публичном API) |
| Spec 05 (Catalog) | Каталог ОБЯЗАН фильтровать: active + isvisible=true + seller.active |
| Spec 14 (Reviews) | ReviewComplaint.review_id → Review.review_id. Жалоба продавца — триггер UC-06. |
| Spec 16 (Reference Data) | Marketer-роль управляет subjects/locations через /admin/reference/* |