feat(rating): multi-dimensional ratings, rich comments, eligibility guard

Rebuild the doctor rating/review system to power the public site's rich
review UI, and restrict who may submit.

Ratings:
- Rate entity holds five 0–100 dimensions (waiting time, diagnosis
  accuracy, behaviour, cleanliness, expertise) instead of a single score.
- GET /rate/{uuid} returns aggregate {point, satisfaction, averages[]}.
- POST /rate upserts all five dimensions and returns the new aggregate.

Comments:
- Comment gains parent/replies (threaded) and a rich toArray with author,
  like_status (like/dislike counts + current user's vote) and nested
  approved replies. POST /comment accepts {comment, parent}.
- Likes are directional (value 1=like, -1=dislike) with toggle/replace;
  POST /like/{uuid} returns like_count/dislike_count/current_user_like.

Eligibility:
- Only a user with a confirmed appointment in the last 30 days may rate or
  comment (AppointmentRepository::hasRecentConfirmed); otherwise
  403 ERR_RATING_NOT_ELIGIBLE. New GET /rate/{uuid}/eligibility for the UI.
- security.yaml: narrow the public rate pattern so /eligibility stays auth'd.

Also updates admin rates listing to the new dimensions and the rating/admin
API docs. Includes migration for the new columns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-16 00:25:59 +03:30
co-authored by Claude Opus 4.8
parent a5fca5d1ba
commit 45242a3128
16 changed files with 846 additions and 108 deletions
+2
View File
@@ -665,6 +665,8 @@ List all ratings.
| `limit` | integer | ❌ | Default: 20 |
| `search` | string | ❌ | Search by doctor/patient |
> Ratings are multi-dimensional (five 0100 dimensions). Each row's `overall` is the mean of the five dimensions (`0100`) and `score` is that mean on a 05 scale (`overall / 20`).
---
### GET `/api/v1/admin/comments`
+137 -49
View File
@@ -6,48 +6,69 @@
## POST `/api/v1/rate`
Submit a rating for a doctor.
Submit a multi-dimensional rating for a doctor. Upsert — re-submitting overwrites the user's previous rating.
**Permission:** `AUTH` — any authenticated user (typically after a completed appointment)
**Permission:** `AUTH`
> **Eligibility rule:** The user must have had a **confirmed** appointment (`status = confirmed`) with this doctor whose `slot_start` falls within the **last 30 days**. Otherwise the request is rejected with `403 ERR_RATING_NOT_ELIGIBLE`. Use [`GET /api/v1/rate/{doctorUuid}/eligibility`](#get-apiv1ratedoctoruuideligibility) to check before showing the rating UI.
### Request Body (`application/json`)
Five dimensions, each an integer percentage `0100`:
```json
{
"doctor_uuid": "550e8400-...",
"score": 5
"waiting_time_at_clinic": 80,
"accuracy_of_diagnosis": 100,
"doctor_behavior": 100,
"clinic_cleanliness": 60,
"doctor_expertise": 100
}
```
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `doctor_uuid` | string (UUID) | ✅ | Must exist |
| `score` | integer | ✅ | 15 |
| `waiting_time_at_clinic` | integer | ✅ | 0100 |
| `accuracy_of_diagnosis` | integer | ✅ | 0100 |
| `doctor_behavior` | integer | ✅ | 0100 |
| `clinic_cleanliness` | integer | ✅ | 0100 |
| `doctor_expertise` | integer | ✅ | 0100 |
### Response `201`
### Response `201` / `200`
Returns the **updated aggregate** for the doctor (same shape as `GET /api/v1/rate/{doctorUuid}`):
```json
{
"success": true,
"data": {
"uuid": "rate-uuid-...",
"doctor_uuid": "...",
"score": 5,
"created_at": 1717000000
"data": {
"point": 4.4,
"satisfaction": 88,
"averages": [
{ "name": "waiting_time_at_clinic", "label": "زمان انتظار در مطب", "progress": 80 },
{ "name": "accuracy_of_diagnosis", "label": "تشخیص درست", "progress": 100 },
{ "name": "doctor_behavior", "label": "برخورد مناسب پزشک", "progress": 100 },
{ "name": "clinic_cleanliness", "label": "نظافت مطب", "progress": 60 },
{ "name": "doctor_expertise", "label": "مهارت پزشک", "progress": 100 }
]
}
}
}
```
> Note: response is double-nested (`data.data`) — `success(['data' => $aggregate])`.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_RATING_NOT_ELIGIBLE` | 403 | No confirmed appointment with this doctor in the last 30 days |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
| `ERR_VALIDATION_001` | 422 | Score out of range |
| `ERR_VALIDATION_001` | 422 | A dimension is out of the 0100 range |
---
## GET `/api/v1/rate/{doctorUuid}`
Get average rating for a doctor.
Get the aggregate (multi-dimensional) rating for a doctor: overall star point, satisfaction percent, and per-dimension averages.
**Permission:** `PUBLIC`
@@ -61,8 +82,50 @@ Get average rating for a doctor.
{
"success": true,
"data": {
"average": 4.3,
"total": 47
"data": {
"point": 4.4,
"satisfaction": 88,
"averages": [
{ "name": "waiting_time_at_clinic", "label": "زمان انتظار در مطب", "progress": 80 },
{ "name": "accuracy_of_diagnosis", "label": "تشخیص درست", "progress": 100 },
{ "name": "doctor_behavior", "label": "برخورد مناسب پزشک", "progress": 100 },
{ "name": "clinic_cleanliness", "label": "نظافت مطب", "progress": 60 },
{ "name": "doctor_expertise", "label": "مهارت پزشک", "progress": 100 }
]
}
}
}
```
- `point`: overall rating on a 05 scale (`satisfaction / 20`).
- `satisfaction`: mean of all dimensions, percent `0100`.
- `averages[].progress`: per-dimension mean, percent `0100`.
- If the doctor has no ratings: `point=0`, `satisfaction=0`, every `progress=0`.
- Response is double-nested (`data.data`).
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
---
## GET `/api/v1/rate/{doctorUuid}/eligibility`
Whether the **current authenticated user** is allowed to rate/comment on this doctor — i.e. had a confirmed appointment with them in the last 30 days. Intended for the public site to conditionally show the "submit review" UI.
**Permission:** `AUTH` (`IS_AUTHENTICATED_FULLY`)
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `doctorUuid` | string (UUID) | Doctor UUID |
### Response `200`
```json
{
"success": true,
"data": {
"eligible": true
}
}
```
@@ -70,6 +133,7 @@ Get average rating for a doctor.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
---
@@ -81,52 +145,42 @@ Submit a comment/review for a doctor.
**Permission:** `AUTH`
> Comments require admin approval before appearing publicly.
>
> **Eligibility rule:** Same as `POST /api/v1/rate` — the user must have had a **confirmed** appointment with this doctor within the **last 30 days**, otherwise `403 ERR_RATING_NOT_ELIGIBLE`.
### Request Body (`application/json`)
```json
{
"doctor_uuid": "550e8400-...",
"body": "پزشک بسیار مؤدب و متخصص بودند"
"comment": "پزشک بسیار مؤدب و متخصص بودند",
"parent": null
}
```
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `doctor_uuid` | string (UUID) | ✅ | Must exist |
| `body` | string | ✅ | Min 10 chars |
| `comment` | string | ✅ | Non-empty |
| `parent` | string (UUID) \| null | ❌ | If set, this comment is a reply to the parent comment |
### Response `201`
```json
{
"success": true,
"data": {
"uuid": "comment-uuid-...",
"body": "پزشک بسیار مؤدب و متخصص بودند",
"status": "pending",
"created_at": 1717000000
}
}
```
Returns the created comment in the **rich shape** (see `GET /comments` below). New comments are `pending` until an admin approves them, so they will not appear in the public list yet.
**Comment Status Values:**
| Value | Description |
|-------|-------------|
| `pending` | Awaiting admin review |
| `approved` | Visible to public |
| `rejected` | Not visible |
**Comment Status Values:** `pending` (awaiting review) · `approved` (public) · `rejected`.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
| `ERR_VALIDATION_001` | 422 | Body too short |
| `ERR_RATING_NOT_ELIGIBLE` | 403 | No confirmed appointment with this doctor in the last 30 days |
| `ERR_NOT_FOUND_001` | 404 | Doctor (or parent comment) not found |
| `ERR_VALIDATION_002` | 422 | Comment text empty |
---
## GET `/api/v1/comments/{doctorUuid}`
Get approved comments for a doctor.
Get approved **root** comments for a doctor (replies are nested under each root via `replies`).
**Permission:** `PUBLIC`
@@ -136,21 +190,43 @@ Get approved comments for a doctor.
| `doctorUuid` | string (UUID) | Doctor UUID |
### Response `200`
Response is double-nested (`data.data`). Each item:
```json
{
"success": true,
"data": [
{
"uuid": "...",
"body": "پزشک بسیار مؤدب...",
"user": { "uuid": "...", "real_name": "علی" },
"likes": 3,
"status": "approved",
"created_at": 1717000000
}
]
"data": {
"data": [
{
"uuid": "...",
"comment": "پزشک بسیار مؤدب...",
"created": 1717000000,
"parent": null,
"author": { "real_name": "میثم امیری", "picture": [] },
"like_status": {
"like_count": 6,
"dislike_count": 1,
"current_user_like": { "like": false, "dislike": false }
},
"replies": [
{
"uuid": "...",
"comment": "پاسخ ...",
"created": 1717000500,
"parent": "<root-uuid>",
"author": { "real_name": "امیر حبیبی", "picture": [] },
"like_status": { "like_count": 0, "dislike_count": 0, "current_user_like": { "like": false, "dislike": false } },
"replies": []
}
]
}
]
}
}
```
- `comment` (not `body`); `created` (not `created_at`); both Unix seconds.
- `author.real_name` from the user (falls back to «کاربر نوبت‌۷۲۴» if unset). `author.picture` is always `[]` (no user avatar field) — frontend uses a default image.
- `current_user_like` is always `{false,false}` on this public endpoint (no token is processed); the real per-user state comes from the `POST /like` response — keep the UI optimistic.
- Only `approved` comments/replies are returned.
### Errors
| Code | HTTP | Description |
@@ -248,7 +324,10 @@ Updated comment object with `status: "rejected"`.
## POST `/api/v1/like/{commentUuid}`
Toggle like on a comment (like if not liked, unlike if already liked).
Cast a like or dislike on a comment. Toggling logic:
- Same vote sent again → vote is **removed**.
- Opposite vote sent → vote is **replaced** (e.g. like → dislike).
- No existing vote → vote is **added**.
**Permission:** `AUTH`
@@ -257,13 +336,22 @@ Toggle like on a comment (like if not liked, unlike if already liked).
|-------|------|-------------|
| `commentUuid` | string (UUID) | Comment UUID |
### Response `200` (unlike) or `201` (new like)
### Request Body (`application/json`)
```json
{ "value": 1 }
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `value` | integer | ❌ (default 1) | `1` = like, `-1` = dislike |
### Response `200`
```json
{
"success": true,
"data": {
"liked": true,
"likes": 4
"like_count": 6,
"dislike_count": 1,
"current_user_like": { "like": true, "dislike": false }
}
}
```