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

12 KiB
Raw Blame History

امتیاز چندبُعدی + نظرات غنی (نویسنده، لایک/دیسلایک، پاسخ) برای صفحه پزشک

پروژه

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_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).