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

MVP Spec 14 — Reviews System

Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев

MVP Spec 14 — Reviews System

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

  • Статус документа: working spec
  • Актуально на: 28 марта 2026 года
  • Владелец: backend/platform-команда
  • Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
  • Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
  • Связанные документы:

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–5422 если вне диапазона
Текст: минимум10 символов⚠ "Минимум 10 символов."
Текст: максимум2000 символов⚠ "Максимум 2000 символов."
Начальный статусpending (всегда)Нельзя создать сразу published
Rating avg учитываетТолько status = publishedpending / rejected / pending_moderation исключены
После жалобыReview.status = pending_moderation, скрыт с публичных страницНемедленно
Ответ продавца: одинОдна запись seller_reply на отзывПовторный PATCH перезаписывает (если в окне 48h)
Ответ продавца: окно редактирования48 часов с seller_reply_at400 REPLY_EDIT_WINDOW_EXPIRED
Ответ только на publishedseller_reply недоступен если status != published400 REVIEW_NOT_PUBLISHED
Жалоба только на publishedНельзя жаловаться на pending/rejected/pending_moderation400 REVIEW_NOT_PUBLISHED
Повторный отзыв после rejectedНельзя написать новый отзыв на тот же айтемФорма не появляется
МодерацияВсе новые отзывы → pending перед публикациейНет автоматической публикации

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

Review

АтрибутТипОписание
review_idUUIDPK
item_idUUID FK→ Item
seller_idUUID FK→ Seller
buyer_idUUID FK→ Account (BUYER)
ratingInt1–5
texttextдо 2000 символов
statusReviewStatuspending / published / rejected / pending_moderation
seller_replytext?nullable, до 1000 символов
seller_reply_atDateTime?nullable
admin_internal_notetext?nullable, только для Admin
created_atDateTime
updated_atDateTime

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

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

АтрибутТипОписание
complaint_idUUIDPK
review_idUUID FK→ Review
seller_idUUID FK→ Seller
reasonComplaintReasonfalse_info / offensive / not_a_buyer / other
commenttext?nullable, до 500 символов (для reason = other)
resolutionComplaintResolution?null / accepted / rejected
resolved_byUUID FK?→ Admin Account, nullable
resolved_atDateTime?nullable
created_atDateTime

current_item_review_stats (SAL)

АтрибутТипОписание
item_idUUID PK/FK→ Item, unique
rating_avgDecimal(3,1)?ROUND(avg, 1), null если нет отзывов
reviews_countIntdefault: 0
updated_atDateTime

current_seller_review_stats (SAL)

АтрибутТипОписание
seller_idUUID PK/FK→ Seller, unique
rating_avgDecimal(3,1)?ROUND(avg, 1), null если нет отзывов
reviews_countIntdefault: 0
updated_atDateTime

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

6.1 Prisma Schema

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

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

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/reply404 REVIEW_NOT_FOUND
Seller пытается ответить на отзыв, который в pending_moderation400 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 + 486060 seconds > NOW() → can_edit_reply: true
Покупатель, аккаунт которого заблокированЕго published отзывы остаются видимыми; новые создать нельзя (401)
Рейтинг = 0 или 6 в запросе422 Validation error

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

ТемаСтатусПримечание
Редактирование отзыва покупателемИсключено из MVPПосле отправки — только на модерации; редактирование — v1.0
Удаление отзыва покупателемИсключено из MVPТолько через поддержку
Лайки на отзывы ("Полезно")Исключено из MVPv1.0
Фото/видео в отзывахИсключено из MVPMedia attachments — v1.5
Автоматическая модерация (NLP / spam filter)Исключено из MVPAuto-moderation — v1.5
Уведомление продавца о новом отзывеTBDSpec 10 (Telegram) — уточнить нужен ли триггер
Уведомление покупателя о публикации/отклоненииTBDEmail уведомление — уточнить приоритет
Анонимные отзывыИсключено из MVPТолько авторизованные покупатели
Сортировка и фильтрация публичных отзывовИсключено из MVPТолько by date DESC; фильтры — v1.0
Верификация "реальный покупатель" (доп. проверка)TBDLead-статус как единственная верификация на MVP
История жалоб в кабинете продавцаTBDСписок поданных жалоб и их решений — v1.0
Пересчёт рейтинга: механизмTBDDB trigger vs application-level event handler vs Saga — не определено
Поддержка языков в тексте отзываTBDUzbek / Russian / English без ограничений на MVP
Срок хранения admin_internal_noteTBDЧувствительные данные — нужна ли политика 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: уведомление о новом отзыве / публикации