P1: User API голосования за отзывы (like/dislike) #47

Closed
opened 2026-07-19 20:24:19 +03:00 by cursor-ai · 3 comments
Owner

Проблема

В модели review уже есть счётчики likes / dislikes, клиент (EventHubFront) показывает их в UI, а в EventHubBackSpec §2.4 указано «реализованы лайки/дизлайки».

Фактически нет user HTTP API для голосования:

  • маршрутов POST/DELETE …/reviews/:id/… под голос нет (eventhub_app.erl: только /v1/reviews и /v1/reviews/:id);
  • нет хранения голоса пользователя (только агрегаты на review);
  • в ответах списка/карточки отзыва нет my_vote текущего пользователя;
  • клиент не может безопасно привязать кнопки лайк/дизлайк (см. EventHubFrontSpec.md §7).

Нужен полноценный контракт голосования + реализация + swagger + тесты + обновление спеки.

Влияние

  • Клиентский UI отзывов остаётся «только чтение» счётчиков.
  • Спека бэка и UI расходятся с реальностью API.
  • Без per-user vote нельзя корректно переключать/снимать голос и защититься от накрутки одним пользователем.

Ожидаемый результат

Пользователь (auth JWT) может поставить/сменить/снять лайк или дизлайк на чужой видимый отзыв. Счётчики на review обновляются атомарно. В ответах отзывов видно текущий голос вызывающего (my_vote). Контракт зафиксирован в client-swagger.json и EventHubBackSpec.md.


Контракт API (целевой)

База: user API :8080, auth: Authorization: Bearer <access_token> (как остальные /v1/*).

Content-Type: application/json.

1. Поставить / сменить голос

PUT /v1/reviews/:id/vote

Path

Параметр Тип Описание
id string (UUID) ID отзыва

Body

{
  "value": "like"
}
Поле Тип Обязательно Значения
value string да like \| dislike

Поведение

  • Нет голоса → создать голос, увеличить соответствующий счётчик (likes или dislikes) на 1.
  • Уже тот же valueидемпотентно 200, счётчики не менять.
  • Уже другой value → сменить: уменьшить старый счётчик на 1, увеличить новый на 1 (не уходить ниже 0).
  • Голос на свой отзыв → 403.
  • Отзыв не найден / status=deleted404.
  • Отзыв hidden (и вызывающий не админ/мод) → 404 или 403 (выбрать один вариант и зафиксировать в swagger; предпочтительно 404).
  • Неавторизован → 401.
  • Невалидный body → 400.

Response 200

{
  "review_id": "550e8400-e29b-41d4-a716-446655440000",
  "my_vote": "like",
  "likes": 12,
  "dislikes": 3
}
Поле Тип Описание
review_id string ID отзыва
my_vote string \| null like \| dislike \| null
likes integer ≥ 0 Актуальный счётчик
dislikes integer ≥ 0 Актуальный счётчик

2. Снять голос

DELETE /v1/reviews/:id/vote

Поведение

  • Голос есть → удалить, уменьшить соответствующий счётчик на 1 (не ниже 0).
  • Голоса нет → идемпотентно 200, my_vote: null.
  • Свой отзыв / не найден / hidden — те же правила, что у PUT.

Response 200 — тот же объект, что у PUT, с my_vote: null.

3. Расширение существующих ответов отзывов

Во всех user-ответах, где отдаётся review (как минимум):

  • GET /v1/reviews?target_type=&target_id=
  • GET /v1/reviews/:id
  • GET /v1/user/reviews (для чужих — не применимо; для своих my_vote обычно null)

добавить поле:

Поле Тип Описание
my_vote string \| null Голос текущего пользователя; без auth / нет голоса → null

Пример элемента списка:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "user_id": "...",
  "target_type": "calendar",
  "target_id": "...",
  "rating": 5,
  "comment": "Отлично",
  "status": "visible",
  "likes": 12,
  "dislikes": 3,
  "my_vote": "dislike",
  "created_at": "2026-07-01T12:00:00Z",
  "updated_at": "2026-07-01T12:00:00Z"
}

edited_at — если уже отдаётся, сохранить как есть.

Admin API менять не обязательно в рамках этой задачи (счётчики уже видны); при желании можно позже добавить админский просмотр голосов.


Модель данных (обязательно)

Счётчиков на review недостаточно: нужен per-user голос.

Рекомендуемая сущность review_vote (имя на усмотрение, смысл фиксирован):

Поле Тип Ограничения
id UUID / binary PK
review_id UUID FK → review, index
user_id UUID FK → user, index
value like \| dislike
created_at datetime
updated_at datetime

Уникальность: (review_id, user_id) — один голос на пользователя на отзыв.

Обновление review.likes / review.dislikesв одной транзакции с upsert/delete голоса (чтобы не было рассинхрона).

Опционально (не блокер MVP): периодическая сверка счётчиков с COUNT(*) по review_vote.


Ошибки (единый формат проекта)

Как в остальных handlers (кратко зафиксировать в swagger):

HTTP Когда
400 нет/невалидный value
401 нет/протухший JWT
403 голос за свой отзыв (если не маскируем в 404)
404 отзыв не найден / недоступен
409 опционально при гонках; предпочтительно транзакция без 409

Вне scope этой задачи

  • Жалоба на отзыв (POST /v1/reports target_type=review) — уже есть.
  • Голосование за календарь/событие (не отзыв).
  • Push/WS-событие о смене счётчиков (можно добавить позже; для MVP достаточно refetch на клиенте).
  • Модерация/скрытие по порогу дизлайков.

Критерии приёмки

  • Маршруты PUT /v1/reviews/:id/vote и DELETE /v1/reviews/:id/vote зарегистрированы и работают с JWT.
  • Нельзя голосовать за свой отзыв.
  • Повторный тот же голос идемпотентен; смена like↔dislike корректно двигает оба счётчика.
  • Снятие голоса уменьшает нужный счётчик; повторный DELETE идемпотентен.
  • В GET /v1/reviews и GET /v1/reviews/:id есть my_vote для текущего пользователя.
  • Счётчики не уходят в отрицательные значения.
  • Обновлены src/swagger/client-swagger.json и trails/handler schemas.
  • Unit/API-тесты: create / switch / delete / own-review forbidden / unauthorized.
  • Обновлён EventHubSpec/EventHubBackSpec.md §2.4: явно описан HTTP-контракт (не только поля counters).
  • После мержа клиент сможет убрать пункт «лайки/дизлайки» из Future в EventHubFrontSpec.md §7 (отдельная задача на Front).

Файлы (подсказка)

  • src/eventhub_app.erl — роут
  • src/handlers/ — новый handler или расширение handler_review_by_id
  • src/core/core_review.erl + новая core/logic для vote
  • include/records.hrl
  • src/swagger/client-swagger.json, src/swagger/eventhub_trails.erl
  • test/api/... / test/unit/...
  • EventHubSpec/EventHubBackSpec.md

Контекст клиента

  • EventHubFront: ReviewsList уже показывает likes/dislikes, кнопки голосования ждут API.
  • EventHubFrontSpec.md §7: «лайки/дизлайки отзывов» в Future до появления user HTTP API.

Приоритет

P1

## Проблема В модели `review` уже есть счётчики `likes` / `dislikes`, клиент (`EventHubFront`) показывает их в UI, а в `EventHubBackSpec` §2.4 указано «реализованы лайки/дизлайки». Фактически **нет user HTTP API для голосования**: - маршрутов `POST/DELETE …/reviews/:id/…` под голос нет (`eventhub_app.erl`: только `/v1/reviews` и `/v1/reviews/:id`); - нет хранения голоса пользователя (только агрегаты на `review`); - в ответах списка/карточки отзыва нет `my_vote` текущего пользователя; - клиент не может безопасно привязать кнопки лайк/дизлайк (см. `EventHubFrontSpec.md` §7). Нужен полноценный контракт голосования + реализация + swagger + тесты + обновление спеки. ## Влияние - Клиентский UI отзывов остаётся «только чтение» счётчиков. - Спека бэка и UI расходятся с реальностью API. - Без per-user vote нельзя корректно переключать/снимать голос и защититься от накрутки одним пользователем. ## Ожидаемый результат Пользователь (auth JWT) может поставить/сменить/снять лайк или дизлайк на **чужой видимый** отзыв. Счётчики на `review` обновляются атомарно. В ответах отзывов видно текущий голос вызывающего (`my_vote`). Контракт зафиксирован в `client-swagger.json` и `EventHubBackSpec.md`. --- ## Контракт API (целевой) База: user API `:8080`, auth: `Authorization: Bearer <access_token>` (как остальные `/v1/*`). Content-Type: `application/json`. ### 1. Поставить / сменить голос ```http PUT /v1/reviews/:id/vote ``` **Path** | Параметр | Тип | Описание | |----------|-----|----------| | `id` | string (UUID) | ID отзыва | **Body** ```json { "value": "like" } ``` | Поле | Тип | Обязательно | Значения | |------|-----|-------------|----------| | `value` | string | да | `like` \\| `dislike` | **Поведение** - Нет голоса → создать голос, увеличить соответствующий счётчик (`likes` или `dislikes`) на 1. - Уже тот же `value` → **идемпотентно** `200`, счётчики не менять. - Уже другой `value` → сменить: уменьшить старый счётчик на 1, увеличить новый на 1 (не уходить ниже 0). - Голос на **свой** отзыв → `403`. - Отзыв не найден / `status=deleted` → `404`. - Отзыв `hidden` (и вызывающий не админ/мод) → `404` или `403` (выбрать один вариант и зафиксировать в swagger; предпочтительно `404`). - Неавторизован → `401`. - Невалидный body → `400`. **Response `200`** ```json { "review_id": "550e8400-e29b-41d4-a716-446655440000", "my_vote": "like", "likes": 12, "dislikes": 3 } ``` | Поле | Тип | Описание | |------|-----|----------| | `review_id` | string | ID отзыва | | `my_vote` | string \\| null | `like` \\| `dislike` \\| `null` | | `likes` | integer ≥ 0 | Актуальный счётчик | | `dislikes` | integer ≥ 0 | Актуальный счётчик | ### 2. Снять голос ```http DELETE /v1/reviews/:id/vote ``` **Поведение** - Голос есть → удалить, уменьшить соответствующий счётчик на 1 (не ниже 0). - Голоса нет → идемпотентно `200`, `my_vote: null`. - Свой отзыв / не найден / hidden — те же правила, что у `PUT`. **Response `200`** — тот же объект, что у `PUT`, с `my_vote: null`. ### 3. Расширение существующих ответов отзывов Во всех user-ответах, где отдаётся review (как минимум): - `GET /v1/reviews?target_type=&target_id=` - `GET /v1/reviews/:id` - `GET /v1/user/reviews` (для чужих — не применимо; для своих `my_vote` обычно `null`) добавить поле: | Поле | Тип | Описание | |------|-----|----------| | `my_vote` | string \\| null | Голос **текущего** пользователя; без auth / нет голоса → `null` | Пример элемента списка: ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "user_id": "...", "target_type": "calendar", "target_id": "...", "rating": 5, "comment": "Отлично", "status": "visible", "likes": 12, "dislikes": 3, "my_vote": "dislike", "created_at": "2026-07-01T12:00:00Z", "updated_at": "2026-07-01T12:00:00Z" } ``` `edited_at` — если уже отдаётся, сохранить как есть. Admin API менять не обязательно в рамках этой задачи (счётчики уже видны); при желании можно позже добавить админский просмотр голосов. --- ## Модель данных (обязательно) Счётчиков на `review` недостаточно: нужен **per-user** голос. Рекомендуемая сущность `review_vote` (имя на усмотрение, смысл фиксирован): | Поле | Тип | Ограничения | |------|-----|-------------| | `id` | UUID / binary | PK | | `review_id` | UUID | FK → review, index | | `user_id` | UUID | FK → user, index | | `value` | `like` \\| `dislike` | | | `created_at` | datetime | | | `updated_at` | datetime | | **Уникальность:** `(review_id, user_id)` — один голос на пользователя на отзыв. Обновление `review.likes` / `review.dislikes` — **в одной транзакции** с upsert/delete голоса (чтобы не было рассинхрона). Опционально (не блокер MVP): периодическая сверка счётчиков с `COUNT(*)` по `review_vote`. --- ## Ошибки (единый формат проекта) Как в остальных handlers (кратко зафиксировать в swagger): | HTTP | Когда | |------|--------| | 400 | нет/невалидный `value` | | 401 | нет/протухший JWT | | 403 | голос за свой отзыв (если не маскируем в 404) | | 404 | отзыв не найден / недоступен | | 409 | опционально при гонках; предпочтительно транзакция без 409 | --- ## Вне scope этой задачи - Жалоба на отзыв (`POST /v1/reports` `target_type=review`) — уже есть. - Голосование за календарь/событие (не отзыв). - Push/WS-событие о смене счётчиков (можно добавить позже; для MVP достаточно refetch на клиенте). - Модерация/скрытие по порогу дизлайков. --- ## Критерии приёмки - [ ] Маршруты `PUT /v1/reviews/:id/vote` и `DELETE /v1/reviews/:id/vote` зарегистрированы и работают с JWT. - [ ] Нельзя голосовать за свой отзыв. - [ ] Повторный тот же голос идемпотентен; смена like↔dislike корректно двигает оба счётчика. - [ ] Снятие голоса уменьшает нужный счётчик; повторный DELETE идемпотентен. - [ ] В `GET /v1/reviews` и `GET /v1/reviews/:id` есть `my_vote` для текущего пользователя. - [ ] Счётчики не уходят в отрицательные значения. - [ ] Обновлены `src/swagger/client-swagger.json` и trails/handler schemas. - [ ] Unit/API-тесты: create / switch / delete / own-review forbidden / unauthorized. - [ ] Обновлён `EventHubSpec/EventHubBackSpec.md` §2.4: явно описан HTTP-контракт (не только поля counters). - [ ] После мержа клиент сможет убрать пункт «лайки/дизлайки» из Future в `EventHubFrontSpec.md` §7 (отдельная задача на Front). ## Файлы (подсказка) - `src/eventhub_app.erl` — роут - `src/handlers/` — новый handler или расширение `handler_review_by_id` - `src/core/core_review.erl` + новая core/logic для vote - `include/records.hrl` - `src/swagger/client-swagger.json`, `src/swagger/eventhub_trails.erl` - `test/api/...` / `test/unit/...` - `EventHubSpec/EventHubBackSpec.md` ## Контекст клиента - `EventHubFront`: `ReviewsList` уже показывает `likes`/`dislikes`, кнопки голосования ждут API. - `EventHubFrontSpec.md` §7: «лайки/дизлайки отзывов» в Future до появления user HTTP API. ## Приоритет P1
cursor-ai added the Story label 2026-07-19 20:24:19 +03:00
cursor-ai self-assigned this 2026-07-19 20:40:21 +03:00
Author
Owner

Беру в работу. Сначала предложу варианты реализации, затем жду подтверждения.

Беру в работу. Сначала предложу варианты реализации, затем жду подтверждения.
Author
Owner

Реализован вариант 1 (таблица review_vote + PUT/DELETE /v1/reviews/:id/vote).

Что сделано

  • Модель review_vote, миграция 20260719210000_review_vote, индексы review_id/user_id
  • core_review_vote: upsert/delete в одной транзакции со счётчиками likes/dislikes
  • Handler PUT/DELETE /v1/reviews/:id/vote; свой отзыв → 403, hidden/deleted → 404
  • Поле my_vote в GET /v1/reviews и GET /v1/reviews/:id
  • client-swagger + trails; спека EventHubBackSpec §2.4 обновлена

Тесты

  • OTP 28: rebar3 ct --suite=test/api_users_SUITE --case=user_test_review_vote — passed
Реализован вариант 1 (таблица review_vote + PUT/DELETE /v1/reviews/:id/vote). ## Что сделано - Модель `review_vote`, миграция `20260719210000_review_vote`, индексы review_id/user_id - `core_review_vote`: upsert/delete в одной транзакции со счётчиками likes/dislikes - Handler `PUT/DELETE /v1/reviews/:id/vote`; свой отзыв → 403, hidden/deleted → 404 - Поле `my_vote` в GET /v1/reviews и GET /v1/reviews/:id - client-swagger + trails; спека EventHubBackSpec §2.4 обновлена ## Тесты - OTP 28: `rebar3 ct --suite=test/api_users_SUITE --case=user_test_review_vote` — passed
Author
Owner

Сделано и в master (5b638de): PUT/DELETE /v1/reviews/:id/vote, my_vote, тесты, спека. Закрываю.

Сделано и в master (`5b638de`): PUT/DELETE /v1/reviews/:id/vote, my_vote, тесты, спека. Закрываю.
Sign in to join this conversation.