Qadam Roadmap
проектdocs/Agents/specs/2026-03-24-mvp-spec-04-admin-moderation.md

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 для обеспечения качества контента, соблюдения правил платформы и управления пользователями.

Три ключевые проблемы, которые решает этот модуль:

  1. Критичный баг с видимостью: айтемы в статусах pending, draft, rejected, revision_required, archived появляются в публичном каталоге. После реализации этого модуля в каталоге отображаются ТОЛЬКО active айтемы с item_isvisible = true.
  2. Нет ролевой модели: единственная роль ADMIN без разграничения обязанностей. Этот модуль вводит четыре роли с разными уровнями доступа.
  3. Нет модерации отзывов: продавец не может оспорить отзыв, который нарушает правила.

Цель модуля: дать команде 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. Роли пользователей

Роли администраторов платформы

РольКодовое имяДоступы
RootrootПолный доступ ко всему. Создаёт других администраторов. Единственный, кто может назначать/менять роли.
Верификатор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 неудаляем через UIRoot-аккаунт нельзя заблокировать или удалить через интерфейс.
Смена роли — только RootТолько Root может изменить роль другого admin.
Блокировка продавца — только RootВерификатор, Аналитик, Маркетолог не могут блокировать пользователей.

Таблица валидаций полей

ПолеПравилоОшибка пользователю
Email нового adminRFC 5322, уникальный"Этот email уже используется другим аккаунтом."
Имя / Фамилия admin2–50 символов"Имя: от 2 до 50 символов"
Комментарий модерации (обязательный)1–1000 символов"Укажите причину отклонения/доработки (до 1000 символов)"
Внутренняя заметка верификатора0–500 символов"Максимум 500 символов"
Причина удаления отзыва1–500 символов"Укажите причину удаления (до 500 символов)"
Причина блокировки пользователя0–500 символов (опционально)

5. Модель данных

Используются существующие сущности: Account, Seller, Item, Review. Новые: AdminProfile, ReviewComplaint.

AdminProfile (профиль администратора)

АтрибутТипОписание
admin_idUUIDPK
account_idUUID FK→ Account (account_type = ADMIN), unique
admin_roleAdminRoleroot / verifier / analyst / marketer
created_byUUID FK→ Account (кто создал, nullable для seed root)
created_atDateTime
updated_atDateTime

Item (расширение существующей сущности)

АтрибутТипОписание
item_idUUIDPK (существующий)
seller_idUUID FK→ Seller (существующий)
item_namestring(существующий)
item_slugstring(существующий)
moderation_statusItemStatusdraft/pending/active/rejected/revision_required/archived
item_isvisiblebooleandefault: true. Управляется продавцом.
moderation_commenttext?Комментарий верификатора — виден продавцу
moderated_byUUID FK?→ Account (admin), nullable
moderated_atDateTime?Дата последнего решения по модерации

Review (расширение существующей сущности, детали в Spec 14)

АтрибутТипОписание
review_idUUIDPK
review_statusReviewStatusactive / pending_moderation / rejected
moderation_notetext?Внутренняя заметка верификатора (admin only)
rejection_reasontext?Причина удаления (видна продавцу и покупателю)
moderated_byUUID FK?→ Account (admin)
moderated_atDateTime?

ReviewComplaint (жалоба продавца на отзыв)

АтрибутТипОписание
complaint_idUUIDPK
review_idUUID FK→ Review
seller_idUUID FK→ Seller (жалующийся)
complaint_reasonComplaintReasonspam / fake / offensive / other
complaint_commenttext?Пояснение от продавца (max 500 символов)
complaint_statusComplaintStatusopen / resolved
created_atDateTime
resolved_atDateTime?

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 через прямой URL403 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 для LoginTBDПри входе через /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/*