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–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
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/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 + 486060 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: уведомление о новом отзыве / публикации |