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>
12 KiB
امتیاز چندبُعدی + نظرات غنی (نویسنده، لایک/دیسلایک، پاسخ) برای صفحه پزشک
پروژه
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} (عمومی) — تجمیع امتیاز
{
"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:
{
"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} (عمومی) — هر آیتم نظر
{
"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:
{ "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:
{ "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_idnullable +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).