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>
10 KiB
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 whoseslot_startfalls within the last 30 days. Otherwise the request is rejected with403 ERR_RATING_NOT_ELIGIBLE. UseGET /api/v1/rate/{doctorUuid}/eligibilityto check before showing the rating UI.
Request Body (application/json)
Five dimensions, each an integer percentage 0–100:
{
"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}):
{
"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
{
"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, percent0–100.averages[].progress: per-dimension mean, percent0–100.- If the doctor has no ratings:
point=0,satisfaction=0, everyprogress=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
{
"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, otherwise403 ERR_RATING_NOT_ELIGIBLE.
Request Body (application/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:
{
"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(notbody);created(notcreated_at); both Unix seconds.author.real_namefrom the user (falls back to «کاربر نوبت۷۲۴» if unset).author.pictureis always[](no user avatar field) — frontend uses a default image.current_user_likeis always{false,false}on this public endpoint (no token is processed); the real per-user state comes from thePOST /likeresponse — keep the UI optimistic.- Only
approvedcomments/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
{ "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
{
"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)
{ "value": 1 }
| Field | Type | Required | Description |
|---|---|---|---|
value |
integer | ❌ (default 1) | 1 = like, -1 = dislike |
Response 200
{
"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 |