# MVP Spec 14 — Reviews System

## Паспорт документа

- Статус документа: working spec
- Актуально на: 28 марта 2026 года
- Владелец: backend/platform-команда
- Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
- Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
- Связанные документы:
  - [Product roadmap и delivery checklist](../product-roadmap.md)
  - [Roadmap](../../project/roadmap.md)
  - [Карта API-маршрутов](../../architecture/api-routes.md)

> Version: MVP · Priority: P0 · Phase: B (Demand)
> Status: Draft v1

---

## 1. Контекст и цель

Система отзывов — ключевой trust-сигнал платформы Qadam. Отзывы от реальных покупателей помогают принимать решения о выборе курса и формируют репутацию образовательных провайдеров.

**Цель модуля:** дать покупателям возможность оценивать курсы, а продавцам — отвечать на отзывы и защищать репутацию через механизм жалоб. Обеспечить модерацию для защиты от недобросовестных отзывов.

**Что не входит в этот модуль:**
- Интерфейс администратора (просмотр очереди модерации, решения) → Spec 04 (Admin)
- Отображение отзывов на публичной странице продавца → Spec 12
- Отображение отзывов покупателя в личном кабинете → Spec 13
- Уведомления о новых отзывах (Telegram) → Spec 10

---

## 2. Роли пользователей

| Роль | Действия в этом модуле |
|------|----------------------|
| **Покупатель (Buyer)** | Написать отзыв, просмотреть статус своих отзывов |
| **Продавец (Seller)** | Ответить на опубликованный отзыв, подать жалобу на отзыв |
| **Администратор** | Утвердить / отклонить отзыв (обрабатывается в Spec 04, здесь только контракты) |

---

## 3. Use Cases

---

### UC-01: Покупатель пишет отзыв на странице курса

**Актор:** Покупатель (авторизованный)
**Предусловие:**
- Пользователь авторизован как BUYER
- У покупателя есть Lead на этот Item с lead_status IN (enrolled, attended, purchased)
- Покупатель ещё не написал отзыв на этот Item (Review отсутствует)

**Триггер:** Прокручивает страницу курса `/item/[slug]` до секции отзывов

**Полный поток:**

```
[Точка входа]
→ Покупатель находится на /item/[slug]
→ Прокручивает до секции "Отзывы"

─────────────────────────────────────────────────────────
СОСТОЯНИЕ А — Покупатель имеет право оставить отзыв
─────────────────────────────────────────────────────────
→ Форма отзыва (review form) отображается вверху секции отзывов:

ФОРМА ОТЗЫВА:
─────────────────────────────────────────────────────────
    Заголовок: "Оставьте отзыв"

    Блок 1 — Оценка *:
        5 кликабельных звёзд (по умолчанию не выбраны)
        При наведении: звёзды подсвечиваются
        После клика: заливаются до выбранной
        Подписи при наведении:
            1 ★ — "Очень плохо"
            2 ★★ — "Плохо"
            3 ★★★ — "Нормально"
            4 ★★★★ — "Хорошо"
            5 ★★★★★ — "Отлично"

    Блок 2 — Текст отзыва *:
        Textarea
        Placeholder: "Расскажите о своём опыте обучения..."
        Минимум: 10 символов
        Максимум: 2000 символов
        Счётчик символов: "0 / 2000" (обновляется при вводе)

    Кнопка "Опубликовать отзыв" (активна только если рейтинг выбран и текст ≥ 10 символов)

→ Покупатель выбирает рейтинг
→ Покупатель вводит текст
→ Нажимает "Опубликовать отзыв"

─────────────────────────────────────────────────────────
ОБРАБОТКА ПОСЛЕ ОТПРАВКИ
─────────────────────────────────────────────────────────
→ POST /api/reviews
→ Система создаёт Review {
    status: pending,
    rating: <выбранное>,
    text: <введённое>,
    buyer_id: <текущий>,
    item_id: <текущий>,
    seller_id: <из Item>
  }
→ SAL пересчёт НЕ происходит (pending не учитывается в рейтинге)

→ Форма скрывается
→ На её месте появляется информационный блок:
    Иконка часов
    "Ваш отзыв отправлен на проверку!"
    "После проверки администратором он появится в общем списке.
     Обычно это занимает до 24 часов."
→ Кнопка "Опубликовать отзыв" больше не отображается
  (нельзя написать второй отзыв на тот же айтем)
```

---

### UC-01 — Альтернативные потоки и обработка ошибок

**1a. Покупатель не авторизован — пытается написать отзыв:**
```
UI-реакция:
→ Форма отзыва НЕ отображается
→ Вместо неё — блок:
    "Войдите, чтобы оставить отзыв."
    Кнопка "Войти" → /login?return=/item/[slug]#review-form
```

**1b. Покупатель авторизован, но нет лида на этот курс:**
```
UI-реакция:
→ Форма отзыва НЕ отображается
→ Вместо неё — блок:
    "Отзыв можно оставить только после записи на курс."
    Кнопка "Записаться" → форма лида на той же странице
```

**1c. Покупатель уже написал отзыв (статус любой):**
```
UI-реакция:
→ Форма отзыва НЕ отображается
→ Вместо неё — его существующий отзыв с бейджем статуса:
    pending           → [На проверке]
    published         → [Опубликован]
    rejected          → [Удалён]
    pending_moderation→ [На повторной проверке]
```

**1d. Рейтинг не выбран при попытке отправки:**
```
UI-реакция:
→ Блок звёзд: красная обводка + ⚠
→ Под блоком: "Пожалуйста, выберите оценку."
→ Форма не отправляется
```

**1e. Текст меньше 10 символов:**
```
UI-реакция:
→ Textarea: красная обводка + ⚠
→ Под полем: "Отзыв должен содержать минимум 10 символов."
→ Форма не отправляется
```

**1f. Текст превышает 2000 символов:**
```
UI-реакция (превентивная — при вводе):
→ Счётчик символов становится красным: "2005 / 2000"
→ Кнопка "Опубликовать" блокируется
→ Под полем: ⚠ "Максимум 2000 символов."
```

**1g. Технический сбой при отправке:**
```
API-реакция:
→ 500 или сетевая ошибка
UI-реакция:
→ Toast (красный): "Не удалось отправить отзыв. Попробуйте ещё раз."
→ Форма не закрывается, текст сохраняется
```

**1h. Гонка: покупатель открыл две вкладки и дважды отправил форму:**
```
API-реакция:
→ Unique constraint (buyer_id, item_id) срабатывает на второй запрос
→ 409: { error: 'REVIEW_ALREADY_EXISTS' }
UI-реакция:
→ Toast (жёлтый): "Вы уже оставили отзыв на этот курс."
→ Форма заменяется на информационный блок
```

---

### UC-02: Отзыв проходит модерацию → опубликован

**Актор:** Администратор (в интерфейсе Spec 04), система
**Предусловие:** Review.status = pending
**Триггер:** Администратор просматривает очередь модерации в /admin

**Полный поток:**

```
→ Администратор видит отзыв в очереди pending (Spec 04)
→ Читает текст, оценивает
→ Нажимает "Утвердить"

→ Система:
    Review.status = published
    Асинхронный пересчёт SAL:
        current_item_review_stats.rating_avg пересчитывается
        current_item_review_stats.reviews_count += 1
        current_seller_review_stats.rating_avg пересчитывается
        current_seller_review_stats.reviews_count += 1

→ Отзыв появляется:
    - В секции отзывов на /item/[slug]
    - На публичной странице продавца /sellers/[id]
    - В /me/reviews покупателя (статус меняется на [Опубликован])
```

---

### UC-03: Продавец отвечает на опубликованный отзыв

**Актор:** Продавец (Owner / Admin CRM)
**Предусловие:**
- Review.status = published
- Review.seller_reply IS NULL (ещё нет ответа) OR в течение 48 часов после создания ответа
**Триггер:** Продавец в своём кабинете видит раздел "Отзывы" → нажимает "Ответить"

**Полный поток:**

```
[Точка входа]
→ Продавец в кабинете открывает раздел отзывов (/seller/reviews)
→ Видит список отзывов с фильтром [Все] [Без ответа] [С ответом]
→ На карточке отзыва без ответа — кнопка "Ответить"

─────────────────────────────────────────────────────────
ФОРМА ОТВЕТА
─────────────────────────────────────────────────────────
→ При нажатии "Ответить" — разворачивается inline-форма:
    Textarea: "Ваш ответ покупателю..."
    Максимум: 1000 символов
    Счётчик: "0 / 1000"
    Кнопки: [Отмена] [Опубликовать ответ]

→ Продавец вводит ответ
→ Нажимает "Опубликовать ответ"
→ PATCH /api/reviews/:review_id/reply
→ Система:
    Review.seller_reply = <текст>
    Review.seller_reply_at = NOW()

→ Inline-форма закрывается
→ Ответ немедленно отображается под отзывом на:
    - /item/[slug]
    - /sellers/[id]
    - /me/reviews покупателя
→ Toast: "Ответ опубликован."

─────────────────────────────────────────────────────────
РЕДАКТИРОВАНИЕ ОТВЕТА (в течение 48 часов)
─────────────────────────────────────────────────────────
→ Если seller_reply_at + 48h > NOW():
    Под ответом: кнопка "Редактировать"
    Та же inline-форма с предзаполненным текстом
→ Если срок истёк: кнопка "Редактировать" не отображается
```

---

### UC-03 — Альтернативные потоки и обработка ошибок

**3a. Текст ответа пустой:**
```
UI-реакция:
→ Textarea: красная обводка + ⚠
→ "Введите текст ответа."
→ Форма не отправляется
```

**3b. Текст ответа > 1000 символов:**
```
UI-реакция (при вводе):
→ Счётчик красный: "1005 / 1000"
→ Кнопка блокируется
→ ⚠ "Максимум 1000 символов."
```

**3c. Продавец пытается ответить на чужой отзыв (другого продавца):**
```
API-реакция:
→ 403 FORBIDDEN
```

**3d. Продавец пытается ответить на отзыв не в статусе published:**
```
API-реакция:
→ 400: { error: 'REVIEW_NOT_PUBLISHED', message: 'Ответ можно оставить только на опубликованный отзыв.' }
```

**3e. Продавец пытается редактировать ответ старше 48 часов:**
```
API-реакция:
→ 400: { error: 'REPLY_EDIT_WINDOW_EXPIRED', message: 'Срок редактирования ответа (48 часов) истёк.' }
```

---

### UC-04: Продавец подаёт жалобу на отзыв

**Актор:** Продавец (Owner / Admin CRM)
**Предусловие:**
- Review.status = published
- Продавец не подавал жалобу на этот отзыв ранее (отсутствует активная жалоба)
**Триггер:** Продавец на карточке отзыва нажимает "Пожаловаться"

**Полный поток:**

```
→ Нажимает "Пожаловаться" на карточке отзыва
→ Открывается модальное окно:

─────────────────────────────────────────────────────────
МОДАЛ — ЖАЛОБА НА ОТЗЫВ
─────────────────────────────────────────────────────────
    Заголовок: "Жалоба на отзыв"

    Причина жалобы * (radio-кнопки):
        ○ Недостоверная информация
        ○ Оскорбительный / грубый язык
        ○ Отзыв не от покупателя этого курса
        ○ Другое

    Комментарий (только если выбрано "Другое"):
        Textarea, до 500 символов, placeholder:
        "Опишите причину подробнее..."

    [Отмена]  [Отправить жалобу]

→ Продавец выбирает причину → нажимает "Отправить жалобу"
→ POST /api/reviews/:review_id/complaint
→ Система:
    Review.status = pending_moderation
    Создаётся запись ReviewComplaint {
        review_id, seller_id, reason, comment, created_at
    }
    Отзыв скрывается с публичных страниц (/item/[slug], /sellers/[id])
    В /me/reviews покупателя: статус меняется на [На повторной проверке]

→ Модал закрывается
→ На карточке отзыва: бейдж [На рассмотрении]
→ Toast: "Жалоба отправлена. Отзыв временно скрыт до решения администратора."
```

---

### UC-04 — Альтернативные потоки и обработка ошибок

**4a. Причина жалобы не выбрана:**
```
UI-реакция:
→ Блок radio: красная обводка + ⚠
→ "Выберите причину жалобы."
→ Форма не отправляется
```

**4b. Выбрано "Другое", но комментарий пустой:**
```
UI-реакция:
→ Textarea: красная обводка + ⚠
→ "Опишите причину жалобы."
→ Форма не отправляется
```

**4c. Жалоба на этот отзыв уже подана (активная):**
```
API-реакция:
→ 409: { error: 'COMPLAINT_ALREADY_EXISTS' }
UI-реакция:
→ Кнопка "Пожаловаться" не отображается для этого отзыва
  (скрыта если уже есть активная жалоба)
```

**4d. Отзыв не в статусе published (уже снят, уже на модерации):**
```
API-реакция:
→ 400: { error: 'REVIEW_NOT_PUBLISHED' }
```

---

### UC-05: Администратор рассматривает жалобу (Spec 04)

**Актор:** Администратор
**Предусловие:** Review.status = pending_moderation
**Триггер:** Администратор в /admin видит отзыв в очереди pending_moderation

**Ссылка на Spec 04 (Admin Panel) — здесь только исходы:**

```
─────────────────────────────────────────────────────────
ИСХОД A — Администратор ВОССТАНАВЛИВАЕТ отзыв
─────────────────────────────────────────────────────────
→ PATCH /api/admin/reviews/:review_id/decision { action: 'restore' }
→ Система:
    Review.status = published
    Отзыв снова отображается на /item/[slug] и /sellers/[id]
    ReviewComplaint.resolution = rejected
    В /me/reviews покупателя: статус [Опубликован]
→ Жалоба отклонена — репутация защищена

─────────────────────────────────────────────────────────
ИСХОД B — Администратор УДАЛЯЕТ отзыв
─────────────────────────────────────────────────────────
→ PATCH /api/admin/reviews/:review_id/decision { action: 'reject', admin_internal_note: '...' }
→ Система:
    Review.status = rejected
    Отзыв не отображается нигде публично
    ReviewComplaint.resolution = accepted
    SAL пересчёт:
        Если до этого отзыв был published:
            current_item_review_stats.reviews_count -= 1
            rating_avg пересчитывается
            current_seller_review_stats аналогично
    В /me/reviews покупателя: статус [Удалён]
    admin_internal_note сохраняется (только для Admin)
```

---

### UC-06: Отзыв отклонён → покупатель видит "Удалён"

**Актор:** Покупатель
**Предусловие:** Review.status = rejected
**Триггер:** Покупатель открывает /me/reviews

**Полный поток:**

```
→ В /me/reviews отзыв отображается с бейджем [Удалён]:
    Бейдж красный: "Удалён"
    Под карточкой серый блок:
    "Ваш отзыв был удалён администратором."

→ Форма написания нового отзыва на этот курс НЕ появляется
  (один отзыв на айтем — финальный, нельзя написать повторно)

→ На /item/[slug]:
    - Информационный блок для покупателя:
      "Ваш отзыв на этот курс был удалён."
    - Форма отзыва НЕ отображается
```

---

### UC-07: Агрегация рейтинга (rating_avg)

**Актор:** Система (фоновый процесс / триггер)
**Предусловие:** Изменился статус Review
**Триггер:** Review.status становится published или меняется с published на rejected

**Полный поток:**

```
─────────────────────────────────────────────────────────
ТРИГГЕР: Review.status → published (approve)
─────────────────────────────────────────────────────────
→ Пересчёт current_item_review_stats для item_id:
    rating_avg = AVG(rating) WHERE item_id = X AND status = published
    reviews_count = COUNT(*) WHERE item_id = X AND status = published

→ Пересчёт current_seller_review_stats для seller_id:
    rating_avg = AVG(rating) WHERE seller_id = X AND status = published
    reviews_count = COUNT(*) WHERE seller_id = X AND status = published

→ Оба обновления атомарны (в одной транзакции)

─────────────────────────────────────────────────────────
ТРИГГЕР: Review.status → rejected (remove from published)
─────────────────────────────────────────────────────────
→ Те же формулы, тот же пересчёт

─────────────────────────────────────────────────────────
ТРИГГЕР: Review.status → pending_moderation
─────────────────────────────────────────────────────────
→ Отзыв был published → теперь скрыт
→ Пересчёт аналогичен: убирается из AVG и COUNT
→ Если жалоба отклонена и статус вернулся → published:
    Отзыв снова включается в AVG и COUNT

─────────────────────────────────────────────────────────
ПРАВИЛО ОКРУГЛЕНИЯ
─────────────────────────────────────────────────────────
→ rating_avg округляется до 1 знака после запятой (ROUND(avg, 1))
→ Если нет отзывов: rating_avg = NULL, reviews_count = 0

─────────────────────────────────────────────────────────
РЕАЛИЗАЦИЯ (SAL Layer)
─────────────────────────────────────────────────────────
→ current_item_review_stats — materializovanная view или
  отдельная таблица, обновляемая через DB trigger / event handler
→ Не пересчитывается in-request (async)
→ Допустимая задержка: до 5 секунд
```

---

## 4. Бизнес-правила и валидации

| Правило | Описание | Ошибка / Поведение |
|---------|----------|--------------------|
| Один отзыв на айтем | Уникальный индекс (buyer_id, item_id) | 409 REVIEW_ALREADY_EXISTS |
| Право на отзыв | lead_status IN (enrolled, attended, purchased) | Форма не отображается без права |
| Рейтинг | Целое число 1–5 | 422 если вне диапазона |
| Текст: минимум | 10 символов | ⚠ "Минимум 10 символов." |
| Текст: максимум | 2000 символов | ⚠ "Максимум 2000 символов." |
| Начальный статус | pending (всегда) | Нельзя создать сразу published |
| Rating avg учитывает | Только status = published | pending / rejected / pending_moderation исключены |
| После жалобы | Review.status = pending_moderation, скрыт с публичных страниц | Немедленно |
| Ответ продавца: один | Одна запись seller_reply на отзыв | Повторный PATCH перезаписывает (если в окне 48h) |
| Ответ продавца: окно редактирования | 48 часов с seller_reply_at | 400 REPLY_EDIT_WINDOW_EXPIRED |
| Ответ только на published | seller_reply недоступен если status != published | 400 REVIEW_NOT_PUBLISHED |
| Жалоба только на published | Нельзя жаловаться на pending/rejected/pending_moderation | 400 REVIEW_NOT_PUBLISHED |
| Повторный отзыв после rejected | Нельзя написать новый отзыв на тот же айтем | Форма не появляется |
| Модерация | Все новые отзывы → pending перед публикацией | Нет автоматической публикации |

---

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

### Review

| Атрибут | Тип | Описание |
|---------|-----|---------|
| review_id | UUID | PK |
| item_id | UUID FK | → Item |
| seller_id | UUID FK | → Seller |
| buyer_id | UUID FK | → Account (BUYER) |
| rating | Int | 1–5 |
| text | text | до 2000 символов |
| status | ReviewStatus | pending / published / rejected / pending_moderation |
| seller_reply | text? | nullable, до 1000 символов |
| seller_reply_at | DateTime? | nullable |
| admin_internal_note | text? | nullable, только для Admin |
| created_at | DateTime | |
| updated_at | DateTime | |

Уникальный индекс: `(buyer_id, item_id)`

### ReviewComplaint (жалоба продавца)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| complaint_id | UUID | PK |
| review_id | UUID FK | → Review |
| seller_id | UUID FK | → Seller |
| reason | ComplaintReason | false_info / offensive / not_a_buyer / other |
| comment | text? | nullable, до 500 символов (для reason = other) |
| resolution | ComplaintResolution? | null / accepted / rejected |
| resolved_by | UUID FK? | → Admin Account, nullable |
| resolved_at | DateTime? | nullable |
| created_at | DateTime | |

### current_item_review_stats (SAL)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| item_id | UUID PK/FK | → Item, unique |
| rating_avg | Decimal(3,1)? | ROUND(avg, 1), null если нет отзывов |
| reviews_count | Int | default: 0 |
| updated_at | DateTime | |

### current_seller_review_stats (SAL)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| seller_id | UUID PK/FK | → Seller, unique |
| rating_avg | Decimal(3,1)? | ROUND(avg, 1), null если нет отзывов |
| reviews_count | Int | default: 0 |
| updated_at | DateTime | |

---

## 6. Технические контракты

### 6.1 Prisma Schema

```prisma
enum ReviewStatus {
  pending
  published
  rejected
  pending_moderation
}

enum ComplaintReason {
  false_info
  offensive
  not_a_buyer
  other
}

enum ComplaintResolution {
  accepted
  rejected
}

model Review {
  review_id           String        @id @default(uuid())
  item_id             String
  seller_id           String
  buyer_id            String
  rating              Int           // 1-5
  text                String        @db.Text
  status              ReviewStatus  @default(pending)
  seller_reply        String?       @db.Text
  seller_reply_at     DateTime?
  admin_internal_note String?       @db.Text
  created_at          DateTime      @default(now())
  updated_at          DateTime      @updatedAt

  item       Item            @relation(fields: [item_id], references: [item_id])
  seller     Seller          @relation(fields: [seller_id], references: [seller_id])
  buyer      Account         @relation(fields: [buyer_id], references: [account_id])
  complaints ReviewComplaint[]

  @@unique([buyer_id, item_id])
  @@index([seller_id, status])
  @@index([item_id, status])
}

model ReviewComplaint {
  complaint_id  String              @id @default(uuid())
  review_id     String
  seller_id     String
  reason        ComplaintReason
  comment       String?             @db.Text
  resolution    ComplaintResolution?
  resolved_by   String?
  resolved_at   DateTime?
  created_at    DateTime            @default(now())

  review  Review  @relation(fields: [review_id], references: [review_id])
  seller  Seller  @relation(fields: [seller_id], references: [seller_id])
}

model CurrentItemReviewStats {
  item_id      String   @id
  rating_avg   Decimal? @db.Decimal(3, 1)
  reviews_count Int     @default(0)
  updated_at   DateTime @updatedAt

  item Item @relation(fields: [item_id], references: [item_id])
}

model CurrentSellerReviewStats {
  seller_id     String   @id
  rating_avg    Decimal? @db.Decimal(3, 1)
  reviews_count Int      @default(0)
  updated_at    DateTime @updatedAt

  seller Seller @relation(fields: [seller_id], references: [seller_id])
}
```

### 6.2 TypeScript DTOs

```typescript
// ─── Создание отзыва ──────────────────────────────────────────────────────

export class CreateReviewDto {
  @IsUUID()
  item_id: string

  @IsInt() @Min(1) @Max(5)
  rating: number

  @IsString() @MinLength(10) @MaxLength(2000)
  text: string
}

// ─── Ответ продавца ───────────────────────────────────────────────────────

export class CreateSellerReplyDto {
  @IsString() @MinLength(1) @MaxLength(1000)
  reply: string
}

// ─── Жалоба продавца ─────────────────────────────────────────────────────

export class CreateComplaintDto {
  @IsEnum(ComplaintReason)
  reason: ComplaintReason

  @IsOptional() @IsString() @MaxLength(500)
  comment?: string  // обязателен если reason = other
}

// ─── Решение администратора (Spec 04) ────────────────────────────────────

export class AdminReviewDecisionDto {
  @IsEnum(['restore', 'reject'])
  action: 'restore' | 'reject'

  @IsOptional() @IsString() @MaxLength(2000)
  admin_internal_note?: string
}

// ─── Публичный отзыв (для страниц курса и профиля продавца) ──────────────

export interface ReviewPublicDto {
  review_id: string
  buyer_display_name: string  // first_name + initial last_name ("Алина М.")
  rating: number
  text: string
  created_at: string
  item_id: string
  item_title: string
  item_slug: string
  seller_reply: string | null
  seller_reply_at: string | null
}

// ─── Список отзывов (публичный) ───────────────────────────────────────────

export interface ReviewsListResponse {
  reviews: ReviewPublicDto[]
  total: number
  has_more: boolean
  next_cursor: string | null
}

// ─── Отзыв продавца (с доп. полями для кабинета) ─────────────────────────

export interface SellerReviewItemDto extends ReviewPublicDto {
  status: ReviewStatus
  can_reply: boolean         // true если status = published
  can_edit_reply: boolean    // true если reply существует AND в окне 48h
  has_active_complaint: boolean
}

// ─── Stats ────────────────────────────────────────────────────────────────

export interface ReviewStatsDto {
  rating_avg: number | null
  reviews_count: number
}

// ─── Проверка права на отзыв ──────────────────────────────────────────────

export interface ReviewEligibilityDto {
  can_review: boolean
  reason: 'no_lead' | 'lead_status_ineligible' | 'already_reviewed' | 'eligible' | 'not_authenticated'
  existing_review_id: string | null  // если already_reviewed
}
```

### 6.3 API Endpoints

```
────────────────────────────────────────────────────────────────
BUYER: НАПИСАНИЕ ОТЗЫВА
────────────────────────────────────────────────────────────────

GET /api/items/:item_id/review-eligibility
Auth: Bearer (buyer)
→ 200: ReviewEligibilityDto
→ 401: { error: 'UNAUTHORIZED' }
(используется для показа/скрытия формы отзыва на странице курса)

POST /api/reviews
Auth: Bearer (buyer)
Body: CreateReviewDto
→ 201: { review_id: string, status: 'pending' }
→ 400: { error: 'NOT_ELIGIBLE', reason: string }
   (нет лида с нужным статусом)
→ 409: { error: 'REVIEW_ALREADY_EXISTS' }
→ 422: { errors: ValidationError[] }

────────────────────────────────────────────────────────────────
PUBLIC: ОТЗЫВЫ НА СТРАНИЦЕ КУРСА
────────────────────────────────────────────────────────────────

GET /api/items/:item_id/reviews
Auth: Public
Query: ?cursor=<review_id>&limit=10
→ 200: ReviewsListResponse
  (только status = published, порядок created_at DESC)

────────────────────────────────────────────────────────────────
PUBLIC: ОТЗЫВЫ НА СТРАНИЦЕ ПРОДАВЦА
────────────────────────────────────────────────────────────────

GET /api/sellers/:seller_id/reviews
Auth: Public
Query: ?cursor=<review_id>&limit=10
→ 200: ReviewsListResponse
  (только status = published, все айтемы продавца)

────────────────────────────────────────────────────────────────
SELLER: УПРАВЛЕНИЕ ОТЗЫВАМИ
────────────────────────────────────────────────────────────────

GET /api/seller/reviews
Auth: Bearer (seller: owner | admin_crm)
Query: ?status=published&page=1&limit=20
→ 200: { reviews: SellerReviewItemDto[], total: number }

PATCH /api/reviews/:review_id/reply
Auth: Bearer (seller: owner | admin_crm)
Body: CreateSellerReplyDto
→ 200: { seller_reply: string, seller_reply_at: string }
→ 400: { error: 'REVIEW_NOT_PUBLISHED' | 'REPLY_EDIT_WINDOW_EXPIRED' }
→ 403: { error: 'FORBIDDEN' }  // отзыв не принадлежит этому продавцу

POST /api/reviews/:review_id/complaint
Auth: Bearer (seller: owner | admin_crm)
Body: CreateComplaintDto
→ 201: { complaint_id: string }
→ 400: { error: 'REVIEW_NOT_PUBLISHED' | 'COMPLAINT_REASON_REQUIRED' }
→ 403: { error: 'FORBIDDEN' }
→ 409: { error: 'COMPLAINT_ALREADY_EXISTS' }

────────────────────────────────────────────────────────────────
ADMIN: РЕШЕНИЯ ПО ОТЗЫВАМ (Spec 04 — здесь только контракт)
────────────────────────────────────────────────────────────────

GET /api/admin/reviews
Auth: Bearer (admin)
Query: ?status=pending&page=1&limit=50
→ 200: { reviews: AdminReviewItemDto[], total: number }

PATCH /api/admin/reviews/:review_id/decision
Auth: Bearer (admin)
Body: AdminReviewDecisionDto
→ 200: { review_id: string, status: ReviewStatus }
→ 400: { error: 'INVALID_STATUS_TRANSITION' }
```

---

## 7. Edge Cases

| Сценарий | Поведение |
|----------|----------|
| Покупатель пишет отзыв, имея лид в статусе pending | Форма не показывается; GET /api/items/:id/review-eligibility → can_review: false, reason: 'lead_status_ineligible' |
| Покупатель имеет несколько лидов на один айтем (разные статусы) | Достаточно одного лида с подходящим статусом → can_review: true |
| Отзыв в pending_moderation → seller снова подаёт жалобу | 409 COMPLAINT_ALREADY_EXISTS |
| Продавец удалён (account_status = blocked) — его отзывы | Отзывы остаются (принадлежат покупателям), но seller_reply скрывается |
| Admin пересчитывает stats вручную | Нет эндпоинта — SAL обновляется только через event handler |
| Два admin одновременно принимают решение по одному отзыву | Optimistic lock / status check перед UPDATE; второй получит 409 CONFLICT |
| review_id не существует в PATCH /reviews/:id/reply | 404 REVIEW_NOT_FOUND |
| Seller пытается ответить на отзыв, который в pending_moderation | 400 REVIEW_NOT_PUBLISHED (ответ только на published) |
| Покупатель смотрит /me/reviews — отзыв в pending_moderation | Отображается с бейджем "На повторной проверке" + пояснение |
| AVG при единственном отзыве | rating_avg = rating этого отзыва (не null), reviews_count = 1 |
| Текст отзыва содержит HTML-теги | Санитизация на сервере (strip HTML), хранится plain text |
| Срок редактирования ответа: 48h точно | seller_reply_at + 48*60*60 seconds > NOW() → can_edit_reply: true |
| Покупатель, аккаунт которого заблокирован | Его published отзывы остаются видимыми; новые создать нельзя (401) |
| Рейтинг = 0 или 6 в запросе | 422 Validation error |

---

## 8. TBD / Сознательно опущено

| Тема | Статус | Примечание |
|------|--------|-----------|
| Редактирование отзыва покупателем | Исключено из MVP | После отправки — только на модерации; редактирование — v1.0 |
| Удаление отзыва покупателем | Исключено из MVP | Только через поддержку |
| Лайки на отзывы ("Полезно") | Исключено из MVP | v1.0 |
| Фото/видео в отзывах | Исключено из MVP | Media attachments — v1.5 |
| Автоматическая модерация (NLP / spam filter) | Исключено из MVP | Auto-moderation — v1.5 |
| Уведомление продавца о новом отзыве | TBD | Spec 10 (Telegram) — уточнить нужен ли триггер |
| Уведомление покупателя о публикации/отклонении | TBD | Email уведомление — уточнить приоритет |
| Анонимные отзывы | Исключено из MVP | Только авторизованные покупатели |
| Сортировка и фильтрация публичных отзывов | Исключено из MVP | Только by date DESC; фильтры — v1.0 |
| Верификация "реальный покупатель" (доп. проверка) | TBD | Lead-статус как единственная верификация на MVP |
| История жалоб в кабинете продавца | TBD | Список поданных жалоб и их решений — v1.0 |
| Пересчёт рейтинга: механизм | TBD | DB trigger vs application-level event handler vs Saga — не определено |
| Поддержка языков в тексте отзыва | TBD | Uzbek / Russian / English без ограничений на MVP |
| Срок хранения admin_internal_note | TBD | Чувствительные данные — нужна ли политика retention? |

---

## 9. Зависимости

| Модуль | Связь |
|--------|-------|
| **Spec 02** (Item Management) | Item.item_id, Item.seller_id; current_item_review_stats |
| **Spec 01** (Seller Profile) | Seller.seller_id; current_seller_review_stats |
| **Spec 05** (Lead Management) | Lead.lead_status — проверка права на отзыв |
| **Spec 07** (Auth) | Account (buyer_id) — авторизация покупателя |
| **Spec 04** (Admin Panel) | AdminReviewDecisionDto — интерфейс модерации |
| **Spec 12** (Public Seller Profile) | GET /api/sellers/:id/reviews используется на странице профиля |
| **Spec 13** (Buyer Cabinet) | GET /api/me/reviews — отзывы в кабинете покупателя |
| **Spec 10** (Notifications) | TBD: уведомление о новом отзыве / публикации |
