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>
364 lines
10 KiB
Markdown
364 lines
10 KiB
Markdown
# Rating & Comments API
|
||
|
||
> **Prefix:** `/api/v1/rate`, `/api/v1/comment`, `/api/v1/like`
|
||
|
||
---
|
||
|
||
## POST `/api/v1/rate`
|
||
|
||
Submit a multi-dimensional rating for a doctor. Upsert — re-submitting overwrites the user's previous rating.
|
||
|
||
**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-...",
|
||
"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 |
|
||
| `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` / `200`
|
||
Returns the **updated aggregate** for the doctor (same shape as `GET /api/v1/rate/{doctorUuid}`):
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"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 | A dimension is out of the 0–100 range |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/rate/{doctorUuid}`
|
||
|
||
Get the aggregate (multi-dimensional) rating for a doctor: overall star point, satisfaction percent, and per-dimension averages.
|
||
|
||
**Permission:** `PUBLIC`
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `doctorUuid` | string (UUID) | Doctor UUID |
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"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
|
||
}
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
|
||
|
||
---
|
||
|
||
## POST `/api/v1/comment`
|
||
|
||
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-...",
|
||
"comment": "پزشک بسیار مؤدب و متخصص بودند",
|
||
"parent": null
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required | Validation |
|
||
|-------|------|----------|------------|
|
||
| `doctor_uuid` | string (UUID) | ✅ | Must exist |
|
||
| `comment` | string | ✅ | Non-empty |
|
||
| `parent` | string (UUID) \| null | ❌ | If set, this comment is a reply to the parent comment |
|
||
|
||
### Response `201`
|
||
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:** `pending` (awaiting review) · `approved` (public) · `rejected`.
|
||
|
||
### 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 (or parent comment) not found |
|
||
| `ERR_VALIDATION_002` | 422 | Comment text empty |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/comments/{doctorUuid}`
|
||
|
||
Get approved **root** comments for a doctor (replies are nested under each root via `replies`).
|
||
|
||
**Permission:** `PUBLIC`
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `doctorUuid` | string (UUID) | Doctor UUID |
|
||
|
||
### Response `200`
|
||
Response is double-nested (`data.data`). Each item:
|
||
```json
|
||
{
|
||
"success": true,
|
||
"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 |
|
||
|------|------|-------------|
|
||
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
|
||
|
||
---
|
||
|
||
## DELETE `/api/v1/comment/{uuid}`
|
||
|
||
Delete a comment.
|
||
|
||
**Permission:** `AUTH` — must be the comment author or `ROLE_ADMIN`
|
||
|
||
### Response `200`
|
||
```json
|
||
{ "success": true, "data": { "message": "نظر حذف شد" } }
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not the author |
|
||
| `ERR_NOT_FOUND_001` | 404 | Comment not found |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/admin/comments/pending`
|
||
|
||
Get all pending comments waiting for review.
|
||
|
||
**Permission:** `ROLE_ADMIN`
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"uuid": "...",
|
||
"body": "...",
|
||
"doctor": { "uuid": "...", "title": "دکتر علی احمدی" },
|
||
"user": { "uuid": "...", "real_name": "..." },
|
||
"status": "pending",
|
||
"created_at": 1717000000
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Not admin |
|
||
|
||
---
|
||
|
||
## POST `/api/v1/admin/comment/{uuid}/approve`
|
||
|
||
Approve a pending comment (makes it public).
|
||
|
||
**Permission:** `ROLE_ADMIN`
|
||
|
||
### Response `200`
|
||
Updated comment object with `status: "approved"`.
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Not admin |
|
||
| `ERR_NOT_FOUND_001` | 404 | Comment not found |
|
||
|
||
---
|
||
|
||
## POST `/api/v1/admin/comment/{uuid}/reject`
|
||
|
||
Reject a pending comment.
|
||
|
||
**Permission:** `ROLE_ADMIN`
|
||
|
||
### Response `200`
|
||
Updated comment object with `status: "rejected"`.
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Not admin |
|
||
| `ERR_NOT_FOUND_001` | 404 | Comment not found |
|
||
|
||
---
|
||
|
||
## POST `/api/v1/like/{commentUuid}`
|
||
|
||
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`
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `commentUuid` | string (UUID) | Comment UUID |
|
||
|
||
### 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": {
|
||
"like_count": 6,
|
||
"dislike_count": 1,
|
||
"current_user_like": { "like": true, "dislike": false }
|
||
}
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_NOT_FOUND_001` | 404 | Comment not found |
|