Files
clinicpro/.claude/prompt/rating-multidimensional-and-rich-comments.md
T
hamedandClaude Opus 4.8 45242a3128 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>
2026-06-16 00:25:59 +03:30

173 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# امتیاز چندبُعدی + نظرات غنی (نویسنده، لایک/دیسلایک، پاسخ) برای صفحه پزشک
## پروژه
`clinicpro` (Backend — منبع حقیقت).
> **Cross-repo:** سایت عمومی `nobat724_front` این قرارداد را مصرف می‌کند تا UI غنیِ نظرات/امتیاز صفحه پزشک را پر کند (پرامپت همتا: `nobat724_front/.claude/prompt/rating-rich-ui-wiring.md`). این پرامپت **اول** اجرا شود.
>
> این کار قانونِ «نوبت تایید‌شده در ۳۰ روز گذشته» را که قبلاً اضافه شد **نگه می‌دارد**؛ فقط مدل داده‌ی امتیاز/نظر را غنی‌تر می‌کند.
## زمینه و چرایی
UI صفحه پزشک در `nobat724_front` (طراحی موجود و تأییدشده) یک نمای غنی دارد که باید حفظ شود:
- چارت امتیاز با **۵ بُعد** درصدی: زمان انتظار در مطب، تشخیص درست، برخورد مناسب پزشک، نظافت مطب، مهارت پزشک.
- یک **دایره‌ی «مجموع رضایت کاربران»** (درصد).
- امتیاز ستاره‌ای کلی (`point` از ۵).
- هر نظر با **نام و عکس نویسنده**، **لایک و دیسلایک** (با تعداد و وضعیت کاربر فعلی)، و **پاسخ‌ها (replies)**.
مدل فعلی بک‌اند خیلی ساده است (تک‌`score` ۱–۵، نظرِ بدون نویسنده/دیسلایک/پاسخ)، پس UI نمی‌تواند پر شود. هدف: بک‌اند این قرارداد غنی را تأمین کند.
## قرارداد هدف (دقیقاً آنچه UI انتظار دارد)
### الف) `GET /api/v1/rate/{doctorUuid}` (عمومی) — تجمیع امتیاز
```json
{
"success": true,
"data": {
"point": 4.4,
"satisfaction": 89,
"averages": [
{ "name": "waiting_time_at_clinic", "label": "زمان انتظار در مطب", "progress": 70 },
{ "name": "accuracy_of_diagnosis", "label": "تشخیص درست", "progress": 88 },
{ "name": "doctor_behavior", "label": "برخورد مناسب پزشک", "progress": 90 },
{ "name": "clinic_cleanliness", "label": "نظافت مطب", "progress": 72 },
{ "name": "doctor_expertise", "label": "مهارت پزشک", "progress": 90 }
]
}
}
```
- `progress` هر بُعد: میانگین آن بُعد روی همه‌ی امتیازها، به‌صورت درصد ۰–۱۰۰ (گرد).
- `point`: میانگین کلیِ همه‌ی ابعاد، روی مقیاس ۰–۵ (مثلاً `mean(progress)/20`).
- `satisfaction`: همان میانگین کلی به‌صورت درصد ۰–۱۰۰.
- اگر هیچ امتیازی نباشد: `point=0`, `satisfaction=0`, ابعاد با `progress=0`.
### ب) `POST /api/v1/rate` (AUTH + گارد واجد بودن موجود) — ثبت امتیاز چندبُعدی
Request body:
```json
{
"doctor_uuid": "…",
"waiting_time_at_clinic": 80,
"accuracy_of_diagnosis": 100,
"doctor_behavior": 100,
"clinic_cleanliness": 60,
"doctor_expertise": 100
}
```
- هر بُعد عددی ۰–۱۰۰ (UI با Rating ۵ ستاره مقدار `progress = stars*20` می‌فرستد). Validation: هر کدام بین ۰ و ۱۰۰.
- Upsert per (user, doctor): اگر امتیاز قبلی بود، به‌روزرسانی شود.
### ج) `GET /api/v1/comments/{doctorUuid}` (عمومی) — هر آیتم نظر
```json
{
"uuid": "…",
"comment": "متن نظر",
"created": 1700000000,
"parent": null,
"author": { "real_name": "میثم امیری", "picture": [{ "url": "/path.jpg" }] },
"like_status": {
"like_count": 6,
"dislike_count": 1,
"current_user_like": { "like": false, "dislike": false }
},
"replies": [ /* همان ساختار، به‌صورت تو در تو */ ]
}
```
- کلید متن `comment` است (نه `body`). تاریخ `created` (نه `created_at`).
- `author.real_name` از `User.realName`؛ `author.picture` آرایه‌ای از `{url}` (اگر کاربر عکس ندارد، آرایه‌ی خالی یا با url پیش‌فرض — هماهنگ با `imageUrl()` فرانت که fallback دارد).
- `current_user_like`: اگر درخواست با توکن باشد، وضعیت رأی کاربر فعلی؛ اگر بدون توکن، هر دو `false`.
- `replies`: نظرهایی که `parent` آن‌ها این نظر است؛ فقط نظرهای ریشه (parent=null) در سطح بالا برگردند و پاسخ‌ها داخل `replies` تو در تو بیایند.
### د) `POST /api/v1/comment` (AUTH + گارد واجد بودن موجود) — ثبت نظر/پاسخ
Request body:
```json
{ "doctor_uuid": "…", "comment": "متن", "parent": "<uuid نظر والد یا null>" }
```
- کلید `comment` (نه `body`). اگر `parent` داده شد، نظرِ پاسخ زیر آن والد ساخته شود.
- نکته‌ی سازگاری: فرانت در برخی نقاط `doctor_id`/`comment` می‌فرستد؛ ولی قرارداد رسمی ما `doctor_uuid` است — در پرامپت فرانت همتا، فرستادن `doctor_uuid` تضمین می‌شود. بک‌اند فقط `doctor_uuid` را بپذیرد.
### ه) `POST /api/v1/like/{commentUuid}` (AUTH) — رأی لایک/دیسلایک
Request body:
```json
{ "value": 1 } // 1 = like ، -1 = dislike
```
- toggle: اگر همان رأی دوباره زده شد حذف شود؛ اگر رأی مخالف بود جایگزین شود.
- Response: `{ success, data: { like_count, dislike_count, current_user_like: { like, dislike } } }`.
## فایل‌های مرتبط
| فایل | کار |
|---|---|
| `src/Rating/Entity/Rate.php` | جایگزینی تک‌`score` با ۵ ستون بُعدی + متد میانگین‌ها |
| `src/Rating/Entity/Comment.php` | افزودن `parent` (self ManyToOne)، `replies` (OneToMany)، `toArray` غنی با author/like_status/replies |
| `src/Rating/Entity/Like.php` | افزودن `value` (۱ یا ۱-) برای تفکیک like/dislike |
| `src/Rating/Repository/RateRepository.php` | `getAggregate(Doctor): array` (point/satisfaction/averages) |
| `src/Rating/Repository/CommentRepository.php` | `findApprovedRootsByDoctor` (parent IS NULL)، شمارش لایک/دیسلایک |
| `src/Rating/Repository/LikeRepository.php` | تطبیق با `value` |
| `src/Rating/Controller/RatingController.php` | به‌روزرسانی `rate`/`getAverage`/`createComment`/`listComments`/`toggleLike` طبق قرارداد بالا؛ گارد واجد بودن حفظ شود؛ `#[CurrentUser] ?User $user` برای خواندن وضعیت رأی کاربر در endpointهای عمومی |
| `src/Auth/Entity/User.php` | بررسی وجود `realName` و فیلد عکس (picture/avatar) — برای ساخت `author` |
| migrations | افزودن ستون‌های Rate، `parent_id` در comments، `value` در likes |
| `docs/api/rating.md` | بازنویسی کامل قراردادها |
## وضعیت فعلی (کد واقعی)
`Rate` فقط `score` دارد (۱–۵). `Comment::toArray()` فقط `{uuid, doctor_uuid, user_uuid, body, status, likes(count), created_at}` می‌دهد — بدون author/like_status/replies/parent. `Like` رأی دودویی بدون جهت دارد (toggleLike فقط وجود/عدم وجود). `getAverage` فقط `{average}` می‌دهد.
> ابعاد و labelها (منبع، از فرانت قدیمی `data/progress_detail.json`):
> ```
> waiting_time_at_clinic → زمان انتظار در مطب
> accuracy_of_diagnosis → تشخیص درست
> doctor_behavior → برخورد مناسب پزشک
> clinic_cleanliness → نظافت مطب
> doctor_expertise → مهارت پزشک
> ```
> labelها را به‌صورت ثابت (مثلاً یک const map در `Rate` یا یک enum/array در Controller) نگه‌دار و در `averages` همراه `name` برگردان.
## وظایف
### ۱. Entity `Rate` چندبُعدی
- پنج ستون `smallint` (۰–۱۰۰): `waitingTimeAtClinic`, `accuracyOfDiagnosis`, `doctorBehavior`, `clinicCleanliness`, `doctorExpertise`.
- constructor و setterها مقادیر را به ۰–۱۰۰ clamp کنند.
- متد کمکی `overallPercent(): float` = میانگین ۵ بُعد.
- `score` قدیمی را حذف کن (یا تبدیل کن). migration بساز.
### ۲. Entity `Comment` — parent/replies + toArray غنی
- `#[ORM\ManyToOne(targetEntity: Comment::class)] private ?Comment $parent` با ستون `parent_id` nullable + `onDelete: CASCADE`.
- `#[ORM\OneToMany(mappedBy: 'parent', targetEntity: Comment::class)] private Collection $replies`.
- constructor یک پارامتر اختیاری `?Comment $parent = null` بگیرد.
- `toArray(?User $currentUser = null): array` غنی طبق قرارداد (ج) — شامل `author`, `like_status`, `replies` (بازگشتی، فقط replies تأییدشده).
### ۳. Entity `Like` جهت‌دار
- ستون `value` (`smallint`: ۱ یا ۱-).
- constructor `value` بگیرد. Repository متدی برای شمارش `like_count`/`dislike_count` یک نظر و یافتن رأی کاربر فعلی.
### ۴. Repository ها
- `RateRepository::getAggregate(Doctor $d): array``['point'=>…, 'satisfaction'=>…, 'averages'=>[…]]` با DQL AVG روی هر ستون (اگر صفر رکورد، همه ۰).
- `CommentRepository::findApprovedRootsByDoctor(Doctor $d): Comment[]` (status=approved AND parent IS NULL).
- `LikeRepository`: `countByComment`, `findByUserAndComment` سازگار با `value`.
### ۵. Controller
- `rate()`: پنج بُعد را بخوان/validate (۰–۱۰۰)، گارد `hasRecentConfirmed` را حفظ کن، upsert.
- `getAverage()`: خروجی `getAggregate` را برگردان (point/satisfaction/averages).
- `createComment()`: `comment` + `parent` اختیاری؛ گارد حفظ شود.
- `listComments()`: ریشه‌ها را با `toArray($currentUser)` برگردان؛ کاربر فعلی را با `#[CurrentUser] ?User $user` بخوان (روی endpoint عمومی، nullable).
- `toggleLike()`: `value` بخوان (۱/۱-)؛ toggle/replace؛ شمارش‌ها را برگردان.
> چون `listComments` و `getAverage` عمومی‌اند ولی برای `current_user_like` به کاربر اختیاری نیاز دارند: `#[CurrentUser] ?User $user = null` کار می‌کند چون firewall عمومی توکن را پردازش نمی‌کند — در این صورت همیشه null خواهد بود. **اگر می‌خواهی وضعیت رأی کاربر در لیست عمومی نمایش داده شود، این endpointها باید توکن اختیاری را بپذیرند.** ساده‌ترین راه بدون پیچیدگی firewall: یک پارامتر کوئری یا هدر بررسی نشود؛ به‌جایش فرانت پس از لاگین، رأی‌ها را سمت کلاینت مدیریت کند. **تصمیم پیش‌فرض:** `current_user_like` را در لیست عمومی همیشه `{like:false,dislike:false}` برگردان و به‌روزرسانی واقعی را به پاسخِ `toggleLike` بسپار (UI optimistic). این از تغییر firewall جلوگیری می‌کند.
### ۶. Migration ها
بعد از تغییر Entityها: `doctrine:migrations:diff` و `migrate`. داده‌ی موجود `rates.score` در صورت وجود → می‌توان در migration به ابعاد map کرد (یا چون داده‌ی واقعی نیست، drop/recreate ساده‌تر است).
### ۷. مستندسازی `docs/api/rating.md`
همه‌ی قراردادهای الف–ه را با مثال JSON واقعی بازنویسی کن.
## نکات مهم
- **گارد واجد بودن (`hasRecentConfirmed`) روی `rate` و `comment` حفظ شود** — قبلاً اضافه و تست شده.
- `User.realName` و فیلد عکس را قبل از استفاده در `author` تأیید کن (فایل `src/Auth/Entity/User.php`). اگر عکس روی User نیست، `picture: []` برگردان (فرانت fallback دارد).
- تاریخ‌ها Unix ثانیه؛ کلید `created` (نه `created_at`) در نظرها — مطابق UI.
- پاسخ‌ها (`replies`) فقط تأییدشده‌ها؛ عمق بازگشت یک سطح کافی است (UI تو در توی عمیق ندارد).
- همه پاسخ‌ها از `BaseController`.
- بعد از تغییر route/Entity: `cache:clear` + `migrate` + تست با `debug:router`.
- `docs/api/rating.md` در همین session به‌روز شود (Standing Rule).