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:
@@ -665,6 +665,8 @@ List all ratings.
|
||||
| `limit` | integer | ❌ | Default: 20 |
|
||||
| `search` | string | ❌ | Search by doctor/patient |
|
||||
|
||||
> Ratings are multi-dimensional (five 0–100 dimensions). Each row's `overall` is the mean of the five dimensions (`0–100`) and `score` is that mean on a 0–5 scale (`overall / 20`).
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/v1/admin/comments`
|
||||
|
||||
+137
-49
@@ -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 `0–100`:
|
||||
```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 | ✅ | 1–5 |
|
||||
| `waiting_time_at_clinic` | integer | ✅ | 0–100 |
|
||||
| `accuracy_of_diagnosis` | integer | ✅ | 0–100 |
|
||||
| `doctor_behavior` | integer | ✅ | 0–100 |
|
||||
| `clinic_cleanliness` | integer | ✅ | 0–100 |
|
||||
| `doctor_expertise` | integer | ✅ | 0–100 |
|
||||
|
||||
### 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 0–100 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 0–5 scale (`satisfaction / 20`).
|
||||
- `satisfaction`: mean of all dimensions, percent `0–100`.
|
||||
- `averages[].progress`: per-dimension mean, percent `0–100`.
|
||||
- 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 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user