From 45242a3128239ce4c4dcb2ac028cc6d52e21fa4c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 16 Jun 2026 00:25:59 +0330 Subject: [PATCH] feat(rating): multi-dimensional ratings, rich comments, eligibility guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...ting-multidimensional-and-rich-comments.md | 172 ++++++++++++++++ ...ng-require-recent-confirmed-appointment.md | 168 ++++++++++++++++ config/packages/security.yaml | 6 +- docs/api/admin.md | 2 + docs/api/rating.md | 186 +++++++++++++----- migrations/Version20260615203529.php | 39 ++++ src/Admin/Controller/AdminApiController.php | 25 ++- .../Repository/AppointmentRepository.php | 24 +++ src/Rating/Controller/RatingController.php | 122 ++++++++++-- src/Rating/Entity/Comment.php | 63 +++++- src/Rating/Entity/Like.php | 11 +- src/Rating/Entity/Rate.php | 66 +++++-- src/Rating/Repository/CommentRepository.php | 9 + src/Rating/Repository/LikeRepository.php | 23 +++ src/Rating/Repository/RateRepository.php | 34 +++- src/Shared/Constant/ErrorCodes.php | 4 + 16 files changed, 846 insertions(+), 108 deletions(-) create mode 100644 .claude/prompt/rating-multidimensional-and-rich-comments.md create mode 100644 .claude/prompt/rating-require-recent-confirmed-appointment.md create mode 100644 migrations/Version20260615203529.php diff --git a/.claude/prompt/rating-multidimensional-and-rich-comments.md b/.claude/prompt/rating-multidimensional-and-rich-comments.md new file mode 100644 index 00000000..e07dcef2 --- /dev/null +++ b/.claude/prompt/rating-multidimensional-and-rich-comments.md @@ -0,0 +1,172 @@ +# امتیاز چندبُعدی + نظرات غنی (نویسنده، لایک/دیسلایک، پاسخ) برای صفحه پزشک + +## پروژه + +`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": "" } +``` +- کلید `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). diff --git a/.claude/prompt/rating-require-recent-confirmed-appointment.md b/.claude/prompt/rating-require-recent-confirmed-appointment.md new file mode 100644 index 00000000..b9724504 --- /dev/null +++ b/.claude/prompt/rating-require-recent-confirmed-appointment.md @@ -0,0 +1,168 @@ +# محدودسازی ثبت نظر/امتیاز به کاربرانِ دارای نوبت تایید‌شده در ۳۰ روز گذشته + +## پروژه + +`clinicpro` (Backend — منبع حقیقت). + +> **Cross-repo:** قرارداد این endpointها توسط سایت عمومی `nobat724_front` مصرف می‌شود (پرامپت همتا: `nobat724_front/.claude/prompt/rating-eligibility-ui.md`). این پرامپت **اول** اجرا شود؛ سپس فرانت. + +## زمینه + +در حال حاضر هر کاربر احرازهویت‌شده‌ای می‌تواند به هر پزشکی نظر و امتیاز بدهد. `RatingController::rate()` و `createComment()` فقط `score`/`body` و وجود پزشک را اعتبارسنجی می‌کنند و **هیچ بررسی‌ای** روی سابقه‌ی نوبت کاربر نزد آن پزشک ندارند. خواسته‌ی محصول: فقط کاربری که در **۳۰ روز گذشته** نزد آن پزشک نوبتِ **تایید‌شده (`confirmed`)** داشته، اجازه‌ی ثبت نظر و امتیاز دارد. + +چون این منبع حقیقت است، قانون باید سمت بک‌اند اجرا شود (UI به‌تنهایی کافی نیست — کاربر می‌تواند مستقیم به API بزند). + +## هدف + +۱. قانون «نوبت تایید‌شده در ۳۰ روز گذشته» روی `POST /api/v1/rate` و `POST /api/v1/comment` اعمال شود؛ در صورت نقض، خطای مجوز با کد و پیام فارسی برگردد. +۲. یک endpoint عمومیِ سبک برای فرانت که بگوید کاربر فعلی نسبت به این پزشک واجد شرایط هست یا نه (تا UI دکمه‌ی ثبت نظر را نشان/مخفی کند). + +## تعریف دقیق «واجد بودن» + +کاربر `U` نسبت به پزشک `D` واجد شرایط است اگر حداقل یک `Appointment` وجود داشته باشد که: +- `appointment.user = U` +- `appointment.doctor = D` +- `appointment.status = Appointment::STATUS_CONFIRMED` +- `appointment.slotStart` در بازه‌ی `[now - 30*86400, now]` باشد (Unix ثانیه؛ یعنی نوبت در ۳۰ روز گذشته بوده). + +> توجه: `slotStart` تایم‌استمپ ثانیه است. «۳۰ روز» = `30 * 86400` ثانیه. کف بازه شامل و سقف `now` است. + +## فایل‌های مرتبط + +| فایل | نقش | +|---|---| +| `src/Appointment/Repository/AppointmentRepository.php` | افزودن متد `hasRecentConfirmed(User, Doctor, int $sinceDays = 30): bool` | +| `src/Rating/Controller/RatingController.php` | اعمال گارد در `rate()` و `createComment()`؛ افزودن endpoint `eligibility` | +| `src/Shared/Constant/ErrorCodes.php` | افزودن کد خطای جدید (در صورت نبود کد مناسب) | +| `docs/api/rating.md` | مستندسازی گارد جدید + endpoint جدید | + +## وضعیت فعلی (کد واقعی) + +`RatingController::rate()` — هیچ گاردی ندارد: + +```php +$doctor = $this->doctorRepo->findByUuid($doctorUuid); +if ($doctor === null) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); } +$existing = $this->rateRepo->findByUserAndDoctor($user, $doctor); +// ... upsert +``` + +`createComment()` مشابه است. + +`AppointmentRepository` الگوی کوئری موجود (برای تقلید): + +```php +public function findExpiredPending(int $before): array +{ + return $this->createQueryBuilder('a') + ->where('a.status = :status') + ->andWhere('a.slotStart < :before') + ->setParameter('status', Appointment::STATUS_PENDING) + ->setParameter('before', $before) + ->getQuery()->getResult(); +} +``` + +ثابت‌های وضعیت: `Appointment::STATUS_CONFIRMED = 'confirmed'`. فیلد زمان: `slotStart` (Unix ثانیه). روابط: `a.user`, `a.doctor`. + +## وظایف + +### ۱. متد Repository — `hasRecentConfirmed` + +در `AppointmentRepository`: + +```php +/** آیا کاربر در sinceDays روز گذشته نزد این پزشک نوبت تایید‌شده داشته؟ */ +public function hasRecentConfirmed(User $user, Doctor $doctor, int $sinceDays = 30): bool +{ + $now = time(); + $since = $now - $sinceDays * 86400; + + $count = (int) $this->createQueryBuilder('a') + ->select('COUNT(a.id)') + ->where('a.user = :user') + ->andWhere('a.doctor = :doctor') + ->andWhere('a.status = :status') + ->andWhere('a.slotStart >= :since') + ->andWhere('a.slotStart <= :now') + ->setParameter('user', $user) + ->setParameter('doctor', $doctor) + ->setParameter('status', Appointment::STATUS_CONFIRMED) + ->setParameter('since', $since) + ->setParameter('now', $now) + ->getQuery()->getSingleScalarResult(); + + return $count > 0; +} +``` + +`use App\Auth\Entity\User;` و `use App\Doctor\Entity\Doctor;` را اگر نبود اضافه کن. + +### ۲. گارد در `rate()` و `createComment()` + +`AppointmentRepository` را به constructor `RatingController` تزریق کن (`private readonly AppointmentRepository $appointmentRepo`). بلافاصله **بعد از** پیداشدن `$doctor` (و قبل از upsert/ساخت)، در هر دو متد: + +```php +if (!$this->appointmentRepo->hasRecentConfirmed($user, $doctor)) { + return $this->error( + ErrorCodes::ERR_RATING_NOT_ELIGIBLE, + 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تایید‌شده نزد این پزشک داشته باشید', + 403 + ); +} +``` + +### ۳. کد خطا + +در `src/Shared/Constant/ErrorCodes.php` اگر کد مناسبی نیست، اضافه کن (الگوی نام‌گذاری موجود را رعایت کن، مثل `ERR_RATING_NOT_ELIGIBLE` با پیام فارسی متناظر). اول فایل را بخوان تا الگو و گروه‌بندی موجود را ببینی. + +### ۴. endpoint بررسی واجد بودن (برای UI) + +برای اینکه فرانت بتواند دکمه‌ی «ثبت نظر» را شرطی نشان دهد، یک GET سبک اضافه کن: + +```php +#[Route('/api/v1/rate/{doctorUuid}/eligibility', name: 'app_rating_rating_eligibility', methods: ['GET'])] +#[IsGranted('IS_AUTHENTICATED_FULLY')] +public function eligibility(string $doctorUuid, #[CurrentUser] User $user): JsonResponse +{ + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); + } + return $this->success(['eligible' => $this->appointmentRepo->hasRecentConfirmed($user, $doctor)]); +} +``` + +> این مسیر باید `IS_AUTHENTICATED_FULLY` باشد (نه عمومی) چون به کاربر فعلی وابسته است. فرانت فقط وقتی صدایش می‌زند که کاربر لاگین باشد. پاسخ: `{ success, data: { eligible: bool } }`. +> مراقب باش annotation مسیر با `GET /api/v1/rate/{doctorUuid}` (getAverage) تداخل نکند — مسیر جدید پسوند `/eligibility` دارد، پس مجزاست. + +### ۵. مستندسازی — `docs/api/rating.md` + +- زیر `POST /api/v1/rate` و `POST /api/v1/comment`: قانون جدید واجد بودن + پاسخ خطای ۴۰۳ با کد `ERR_RATING_NOT_ELIGIBLE` را مستند کن. +- بخش جدید برای `GET /api/v1/rate/{doctorUuid}/eligibility`: method/path/permission/response با مثال JSON واقعی. + +## تست + +```bash +ddev exec php -l src/Appointment/Repository/AppointmentRepository.php +ddev exec php -l src/Rating/Controller/RatingController.php +ddev exec php bin/console cache:clear +ddev exec php bin/console debug:router | grep -i "rating_eligibility" +``` + +تست رفتاری (با کاربر لاگین‌شده‌ای که نوبت confirmed اخیر دارد و یکی که ندارد): +- بدون نوبت اخیر → `POST /rate` و `POST /comment` باید **۴۰۳** با کد `ERR_RATING_NOT_ELIGIBLE` بدهند. +- با نوبت confirmed در ۳۰ روز گذشته → باید موفق شوند. +- `GET /rate/{uuid}/eligibility` برای هر دو حالت `eligible: true/false` درست برگرداند. +- یک نوبت confirmed با `slotStart` قدیمی‌تر از ۳۰ روز → نباید واجد شرایط محسوب شود. + +> برای ساخت داده‌ی تست می‌توانی مستقیم در DB یک `appointment` با `status='confirmed'` و `slot_start` در بازه‌ی اخیر برای کاربر/پزشک تست درج کنی (مشابه روشی که قبلاً برای تست expiry استفاده شد). از `TEST_USERS.md` برای شناسه‌ها کمک بگیر. + +## نکات مهم + +- **منبع حقیقت بک‌اند است**؛ گارد باید روی خود `rate`/`comment` باشد، نه فقط endpoint eligibility (که صرفاً برای UX است). +- `slotStart` ثانیه است؛ از `time()` و `* 86400` استفاده کن، نه DateTime. +- وضعیت معیار **فقط `confirmed`** است (نه `completed`/`pending`/سایر) — طبق خواسته. +- بازه: نوبت در ۳۰ روزِ **گذشته** (`since <= slotStart <= now`). نوبت آینده واجد شرایط نیست. +- همه‌ی پاسخ‌ها از `BaseController` (`$this->error/$this->success`). +- طبق Standing Rule، `docs/api/rating.md` در همین session به‌روز شود. diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 567d3d6a..fb8feb9b 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -33,7 +33,7 @@ security: provider: api_doc_provider public_endpoints: - pattern: ^/(api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/appointment-settings/month-availability/|api/v1/comments/|api/v1/rate/|api/v1/blogs$|api/v1/clinic-invitation/|api/v1/pre-registration$) + pattern: ^/(api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/appointment-settings/month-availability/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/blogs$|api/v1/clinic-invitation/|api/v1/pre-registration$) stateless: true security: false @@ -62,7 +62,9 @@ security: - { path: ^/api/v1/appointment-slots, roles: PUBLIC_ACCESS } - { path: ^/api/v1/appointment-settings/month-availability/, roles: PUBLIC_ACCESS } - { path: ^/api/v1/comments/, roles: PUBLIC_ACCESS } - - { path: ^/api/v1/rate/, roles: PUBLIC_ACCESS } + - path: '^/api/v1/rate/[^/]+$' + methods: [GET] + roles: PUBLIC_ACCESS - { path: ^/api/v1/blogs$, roles: PUBLIC_ACCESS } - path: '^/api/v1/blog/[^/]+$' methods: [GET] diff --git a/docs/api/admin.md b/docs/api/admin.md index ece318db..9aea6be2 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -665,6 +665,8 @@ List all ratings. | `limit` | integer | ❌ | Default: 20 | | `search` | string | ❌ | Search by doctor/patient | +> Ratings are multi-dimensional (five 0–100 dimensions). Each row's `overall` is the mean of the five dimensions (`0–100`) and `score` is that mean on a 0–5 scale (`overall / 20`). + --- ### GET `/api/v1/admin/comments` diff --git a/docs/api/rating.md b/docs/api/rating.md index 59fb1d57..f5446eac 100644 --- a/docs/api/rating.md +++ b/docs/api/rating.md @@ -6,48 +6,69 @@ ## POST `/api/v1/rate` -Submit a rating for a doctor. +Submit a multi-dimensional rating for a doctor. Upsert — re-submitting overwrites the user's previous rating. -**Permission:** `AUTH` — any authenticated user (typically after a completed appointment) +**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-...", - "score": 5 + "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 | -| `score` | integer | ✅ | 1–5 | +| `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` +### Response `201` / `200` +Returns the **updated aggregate** for the doctor (same shape as `GET /api/v1/rate/{doctorUuid}`): ```json { "success": true, "data": { - "uuid": "rate-uuid-...", - "doctor_uuid": "...", - "score": 5, - "created_at": 1717000000 + "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 | Score out of range | +| `ERR_VALIDATION_001` | 422 | A dimension is out of the 0–100 range | --- ## GET `/api/v1/rate/{doctorUuid}` -Get average rating for a doctor. +Get the aggregate (multi-dimensional) rating for a doctor: overall star point, satisfaction percent, and per-dimension averages. **Permission:** `PUBLIC` @@ -61,8 +82,50 @@ Get average rating for a doctor. { "success": true, "data": { - "average": 4.3, - "total": 47 + "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 } } ``` @@ -70,6 +133,7 @@ Get average rating for a doctor. ### Errors | Code | HTTP | Description | |------|------|-------------| +| `ERR_AUTH_001` | 401 | Missing token | | `ERR_NOT_FOUND_001` | 404 | Doctor not found | --- @@ -81,52 +145,42 @@ 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-...", - "body": "پزشک بسیار مؤدب و متخصص بودند" + "comment": "پزشک بسیار مؤدب و متخصص بودند", + "parent": null } ``` | Field | Type | Required | Validation | |-------|------|----------|------------| | `doctor_uuid` | string (UUID) | ✅ | Must exist | -| `body` | string | ✅ | Min 10 chars | +| `comment` | string | ✅ | Non-empty | +| `parent` | string (UUID) \| null | ❌ | If set, this comment is a reply to the parent comment | ### Response `201` -```json -{ - "success": true, - "data": { - "uuid": "comment-uuid-...", - "body": "پزشک بسیار مؤدب و متخصص بودند", - "status": "pending", - "created_at": 1717000000 - } -} -``` +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:** -| Value | Description | -|-------|-------------| -| `pending` | Awaiting admin review | -| `approved` | Visible to public | -| `rejected` | Not visible | +**Comment Status Values:** `pending` (awaiting review) · `approved` (public) · `rejected`. ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | -| `ERR_NOT_FOUND_001` | 404 | Doctor not found | -| `ERR_VALIDATION_001` | 422 | Body too short | +| `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 comments for a doctor. +Get approved **root** comments for a doctor (replies are nested under each root via `replies`). **Permission:** `PUBLIC` @@ -136,21 +190,43 @@ Get approved comments for a doctor. | `doctorUuid` | string (UUID) | Doctor UUID | ### Response `200` +Response is double-nested (`data.data`). Each item: ```json { "success": true, - "data": [ - { - "uuid": "...", - "body": "پزشک بسیار مؤدب...", - "user": { "uuid": "...", "real_name": "علی" }, - "likes": 3, - "status": "approved", - "created_at": 1717000000 - } - ] + "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": "", + "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 | @@ -248,7 +324,10 @@ Updated comment object with `status: "rejected"`. ## POST `/api/v1/like/{commentUuid}` -Toggle like on a comment (like if not liked, unlike if already liked). +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` @@ -257,13 +336,22 @@ Toggle like on a comment (like if not liked, unlike if already liked). |-------|------|-------------| | `commentUuid` | string (UUID) | Comment UUID | -### Response `200` (unlike) or `201` (new like) +### 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": { - "liked": true, - "likes": 4 + "like_count": 6, + "dislike_count": 1, + "current_user_like": { "like": true, "dislike": false } } } ``` diff --git a/migrations/Version20260615203529.php b/migrations/Version20260615203529.php new file mode 100644 index 00000000..3a2ad6e8 --- /dev/null +++ b/migrations/Version20260615203529.php @@ -0,0 +1,39 @@ +addSql('ALTER TABLE comments ADD parent_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE comments ADD CONSTRAINT FK_5F9E962A727ACA70 FOREIGN KEY (parent_id) REFERENCES comments (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX IDX_5F9E962A727ACA70 ON comments (parent_id)'); + $this->addSql('ALTER TABLE likes ADD value SMALLINT NOT NULL'); + $this->addSql('ALTER TABLE rates ADD accuracy_of_diagnosis SMALLINT NOT NULL, ADD doctor_behavior SMALLINT NOT NULL, ADD clinic_cleanliness SMALLINT NOT NULL, ADD doctor_expertise SMALLINT NOT NULL, CHANGE score waiting_time_at_clinic SMALLINT NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE comments DROP FOREIGN KEY FK_5F9E962A727ACA70'); + $this->addSql('DROP INDEX IDX_5F9E962A727ACA70 ON comments'); + $this->addSql('ALTER TABLE comments DROP parent_id'); + $this->addSql('ALTER TABLE likes DROP value'); + $this->addSql('ALTER TABLE rates ADD score SMALLINT NOT NULL, DROP waiting_time_at_clinic, DROP accuracy_of_diagnosis, DROP doctor_behavior, DROP clinic_cleanliness, DROP doctor_expertise'); + } +} diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index a36115ca..10d264d3 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -1091,7 +1091,8 @@ class AdminApiController extends BaseController $qb = $this->em->createQueryBuilder() ->select( - 'r.uuid, r.score, r.createdAt', + 'r.uuid, r.createdAt', + 'r.waitingTimeAtClinic, r.accuracyOfDiagnosis, r.doctorBehavior, r.clinicCleanliness, r.doctorExpertise', 'u.realName as patient_name, u.mobileNumber as patient_mobile', 'd.name as doctor_name', ) @@ -1110,14 +1111,20 @@ class AdminApiController extends BaseController $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) ->getQuery()->getArrayResult(); - $items = array_map(fn(array $r) => [ - 'uuid' => $r['uuid'], - 'patient_name' => $r['patient_name'] ?? $r['patient_mobile'], - 'doctor_name' => $r['doctor_name'], - 'overall' => (int) $r['score'], - 'score' => (int) $r['score'], - 'created_at' => date('c', (int) $r['createdAt']), - ], $rows); + $items = array_map(function (array $r) { + $overall = (int) round(( + $r['waitingTimeAtClinic'] + $r['accuracyOfDiagnosis'] + $r['doctorBehavior'] + + $r['clinicCleanliness'] + $r['doctorExpertise'] + ) / 5); + return [ + 'uuid' => $r['uuid'], + 'patient_name' => $r['patient_name'] ?? $r['patient_mobile'], + 'doctor_name' => $r['doctor_name'], + 'overall' => $overall, + 'score' => (int) round($overall / 20), + 'created_at' => date('c', (int) $r['createdAt']), + ]; + }, $rows); return $this->paginated($items, (int) $total, $page, $limit); } diff --git a/src/Appointment/Repository/AppointmentRepository.php b/src/Appointment/Repository/AppointmentRepository.php index f86aab47..90ba278b 100644 --- a/src/Appointment/Repository/AppointmentRepository.php +++ b/src/Appointment/Repository/AppointmentRepository.php @@ -80,6 +80,30 @@ class AppointmentRepository extends ServiceEntityRepository return $this->findBy($criteria, ['slotStart' => 'DESC']); } + /** Whether the user had a confirmed appointment with this doctor within the last $sinceDays days. */ + public function hasRecentConfirmed(User $user, Doctor $doctor, int $sinceDays = 30): bool + { + $now = time(); + $since = $now - $sinceDays * 86400; + + $count = (int) $this->createQueryBuilder('a') + ->select('COUNT(a.id)') + ->where('a.user = :user') + ->andWhere('a.doctor = :doctor') + ->andWhere('a.status = :status') + ->andWhere('a.slotStart >= :since') + ->andWhere('a.slotStart <= :now') + ->setParameter('user', $user) + ->setParameter('doctor', $doctor) + ->setParameter('status', Appointment::STATUS_CONFIRMED) + ->setParameter('since', $since) + ->setParameter('now', $now) + ->getQuery() + ->getSingleScalarResult(); + + return $count > 0; + } + /** @return Appointment[] pending bookings whose 15-minute payment window has lapsed */ public function findPaymentExpired(int $now): array { diff --git a/src/Rating/Controller/RatingController.php b/src/Rating/Controller/RatingController.php index 4cc48874..ee841d3f 100644 --- a/src/Rating/Controller/RatingController.php +++ b/src/Rating/Controller/RatingController.php @@ -2,6 +2,7 @@ namespace App\Rating\Controller; +use App\Appointment\Repository\AppointmentRepository; use App\Auth\Entity\User; use App\Doctor\Repository\DoctorRepository; use App\Rating\Entity\Comment; @@ -27,6 +28,7 @@ class RatingController extends BaseController private readonly CommentRepository $commentRepo, private readonly LikeRepository $likeRepo, private readonly DoctorRepository $doctorRepo, + private readonly AppointmentRepository $appointmentRepo, ) {} // ── Ratings ─────────────────────────────────────────────────────────────── @@ -67,10 +69,14 @@ class RatingController extends BaseController { $data = json_decode($request->getContent(), true) ?? []; $doctorUuid = trim($data['doctor_uuid'] ?? ''); - $score = (int) ($data['score'] ?? 0); - if ($score < 1 || $score > 5) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'امتیاز باید بین ۱ تا ۵ باشد', 422); + $dimensions = []; + foreach (array_keys(Rate::DIMENSIONS) as $name) { + $value = (int) ($data[$name] ?? -1); + if ($value < 0 || $value > 100) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'هر امتیاز باید بین ۰ تا ۱۰۰ باشد', 422); + } + $dimensions[$name] = $value; } $doctor = $this->doctorRepo->findByUuid($doctorUuid); @@ -78,17 +84,21 @@ class RatingController extends BaseController return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); } - $existing = $this->rateRepo->findByUserAndDoctor($user, $doctor); - if ($existing !== null) { - $existing->setScore($score); - $this->rateRepo->save($existing); - return $this->success(['data' => $existing->toArray()]); + if (!$this->appointmentRepo->hasRecentConfirmed($user, $doctor)) { + return $this->error(ErrorCodes::ERR_RATING_NOT_ELIGIBLE, ErrorCodes::message(ErrorCodes::ERR_RATING_NOT_ELIGIBLE), 403); } - $rate = new Rate($user, $doctor, $score); + $existing = $this->rateRepo->findByUserAndDoctor($user, $doctor); + if ($existing !== null) { + $existing->setDimensions($dimensions); + $this->rateRepo->save($existing); + return $this->success(['data' => $this->rateRepo->getAggregate($doctor)]); + } + + $rate = new Rate($user, $doctor, $dimensions); $this->rateRepo->save($rate); - return $this->success(['data' => $rate->toArray()], 201); + return $this->success(['data' => $this->rateRepo->getAggregate($doctor)], 201); } #[OA\Get( @@ -125,7 +135,47 @@ class RatingController extends BaseController return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); } - return $this->success(['average' => $this->rateRepo->getAverageScore($doctor)]); + return $this->success(['data' => $this->rateRepo->getAggregate($doctor)]); + } + + #[OA\Get( + path: '/api/v1/rate/{doctorUuid}/eligibility', + summary: 'Whether the current user may rate/comment on this doctor', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'doctorUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Eligibility status', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'eligible', type: 'boolean'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'Doctor not found'), + ] + )] + #[Route('/api/v1/rate/{doctorUuid}/eligibility', methods: ['GET'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function eligibility(string $doctorUuid, #[CurrentUser] User $user): JsonResponse + { + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); + } + + return $this->success(['eligible' => $this->appointmentRepo->hasRecentConfirmed($user, $doctor)]); } // ── Comments ────────────────────────────────────────────────────────────── @@ -166,7 +216,8 @@ class RatingController extends BaseController { $data = json_decode($request->getContent(), true) ?? []; $doctorUuid = trim($data['doctor_uuid'] ?? ''); - $body = trim($data['body'] ?? ''); + $body = trim($data['comment'] ?? ''); + $parentUuid = trim($data['parent'] ?? ''); if (empty($body)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'متن نظر الزامی است', 422); @@ -177,10 +228,22 @@ class RatingController extends BaseController return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); } - $comment = new Comment($user, $doctor, $body); + if (!$this->appointmentRepo->hasRecentConfirmed($user, $doctor)) { + return $this->error(ErrorCodes::ERR_RATING_NOT_ELIGIBLE, ErrorCodes::message(ErrorCodes::ERR_RATING_NOT_ELIGIBLE), 403); + } + + $parent = null; + if ($parentUuid !== '') { + $parent = $this->commentRepo->findByUuid($parentUuid); + if ($parent === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر والد یافت نشد', 404); + } + } + + $comment = new Comment($user, $doctor, $body, $parent); $this->commentRepo->save($comment); - return $this->success(['data' => $comment->toArray()], 201); + return $this->success(['data' => $comment->toArray($user)], 201); } #[OA\Get( @@ -213,7 +276,7 @@ class RatingController extends BaseController $comments = array_map( fn(Comment $c) => $c->toArray(), - $this->commentRepo->findApprovedByDoctor($doctor) + $this->commentRepo->findApprovedRootsByDoctor($doctor) ); return $this->success(['data' => $comments]); @@ -412,21 +475,38 @@ class RatingController extends BaseController )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/like/{commentUuid}', methods: ['POST'])] - public function toggleLike(string $commentUuid, #[CurrentUser] User $user): JsonResponse + public function toggleLike(string $commentUuid, Request $request, #[CurrentUser] User $user): JsonResponse { $comment = $this->commentRepo->findByUuid($commentUuid); if ($comment === null) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر یافت نشد', 404); } + $data = json_decode($request->getContent(), true) ?? []; + $value = ((int) ($data['value'] ?? 1)) >= 0 ? 1 : -1; + $existing = $this->likeRepo->findByUserAndComment($user, $comment); if ($existing !== null) { - $this->likeRepo->remove($existing); - return $this->success(['liked' => false, 'likes' => $comment->getLikes()->count() - 1]); + if ($existing->getValue() === $value) { + $this->likeRepo->remove($existing); + } else { + $existing->setValue($value); + $this->likeRepo->save($existing); + } + } else { + $this->likeRepo->save(new Like($user, $comment, $value)); } - $like = new Like($user, $comment); - $this->likeRepo->save($like); - return $this->success(['liked' => true, 'likes' => $comment->getLikes()->count()], 201); + $counts = $this->likeRepo->countByComment($comment); + $current = $this->likeRepo->findByUserAndComment($user, $comment); + + return $this->success([ + 'like_count' => $counts['like_count'], + 'dislike_count' => $counts['dislike_count'], + 'current_user_like' => [ + 'like' => $current !== null && $current->getValue() >= 0, + 'dislike' => $current !== null && $current->getValue() < 0, + ], + ]); } } diff --git a/src/Rating/Entity/Comment.php b/src/Rating/Entity/Comment.php index 8f7e8447..dad00ff2 100644 --- a/src/Rating/Entity/Comment.php +++ b/src/Rating/Entity/Comment.php @@ -34,6 +34,13 @@ class Comment #[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private Doctor $doctor; + #[ORM\ManyToOne(targetEntity: Comment::class, inversedBy: 'replies')] + #[ORM\JoinColumn(name: 'parent_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + private ?Comment $parent = null; + + #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'parent')] + private Collection $replies; + #[ORM\Column(type: 'text')] private string $body; @@ -49,13 +56,15 @@ class Comment #[ORM\Column(name: 'updated_at', type: 'integer')] private int $updatedAt; - public function __construct(User $user, Doctor $doctor, string $body) + public function __construct(User $user, Doctor $doctor, string $body, ?Comment $parent = null) { $this->uuid = Uuid::v4()->toRfc4122(); $this->user = $user; $this->doctor = $doctor; $this->body = $body; + $this->parent = $parent; $this->likes = new ArrayCollection(); + $this->replies = new ArrayCollection(); $this->createdAt = time(); $this->updatedAt = time(); } @@ -67,22 +76,58 @@ class Comment public function getBody(): string { return $this->body; } public function getStatus(): string { return $this->status; } public function getLikes(): Collection { return $this->likes; } + public function getParent(): ?Comment { return $this->parent; } + public function getReplies(): Collection { return $this->replies; } public function setBody(string $v): self { $this->body = $v; $this->updatedAt = time(); return $this; } public function approve(): self { $this->status = self::STATUS_APPROVED; $this->updatedAt = time(); return $this; } public function reject(): self { $this->status = self::STATUS_REJECTED; $this->updatedAt = time(); return $this; } - public function toArray(): array + public function toArray(?User $currentUser = null): array { + $likeCount = 0; + $dislikeCount = 0; + $userLike = false; + $userDislike = false; + + foreach ($this->likes as $like) { + if ($like->getValue() >= 0) { + $likeCount++; + } else { + $dislikeCount++; + } + if ($currentUser !== null && $like->getUser()->getId() === $currentUser->getId()) { + $userLike = $like->getValue() >= 0; + $userDislike = $like->getValue() < 0; + } + } + + $replies = []; + foreach ($this->replies as $reply) { + if ($reply->getStatus() === self::STATUS_APPROVED) { + $replies[] = $reply->toArray($currentUser); + } + } + return [ - 'uuid' => $this->uuid, - 'doctor_uuid' => $this->doctor->getUuid(), - 'user_uuid' => $this->user->getUuid(), - 'body' => $this->body, - 'status' => $this->status, - 'likes' => $this->likes->count(), - 'created_at' => $this->createdAt, + 'uuid' => $this->uuid, + 'comment' => $this->body, + 'created' => $this->createdAt, + 'parent' => $this->parent?->getUuid(), + 'author' => [ + 'real_name' => $this->user->getRealName() ?? 'کاربر نوبت‌۷۲۴', + 'picture' => [], + ], + 'like_status' => [ + 'like_count' => $likeCount, + 'dislike_count' => $dislikeCount, + 'current_user_like' => [ + 'like' => $userLike, + 'dislike' => $userDislike, + ], + ], + 'replies' => $replies, ]; } } diff --git a/src/Rating/Entity/Like.php b/src/Rating/Entity/Like.php index 1129df34..f379172f 100644 --- a/src/Rating/Entity/Like.php +++ b/src/Rating/Entity/Like.php @@ -27,14 +27,19 @@ class Like #[ORM\JoinColumn(name: 'comment_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private Comment $comment; + /** 1 = like, -1 = dislike */ + #[ORM\Column(type: 'smallint')] + private int $value; + #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; - public function __construct(User $user, Comment $comment) + public function __construct(User $user, Comment $comment, int $value) { $this->uuid = Uuid::v4()->toRfc4122(); $this->user = $user; $this->comment = $comment; + $this->value = $value >= 0 ? 1 : -1; $this->createdAt = time(); } @@ -42,6 +47,9 @@ class Like public function getUuid(): string { return $this->uuid; } public function getUser(): User { return $this->user; } public function getComment(): Comment { return $this->comment; } + public function getValue(): int { return $this->value; } + + public function setValue(int $v): self { $this->value = $v >= 0 ? 1 : -1; return $this; } public function toArray(): array { @@ -49,6 +57,7 @@ class Like 'uuid' => $this->uuid, 'comment_uuid' => $this->comment->getUuid(), 'user_uuid' => $this->user->getUuid(), + 'value' => $this->value, 'created_at' => $this->createdAt, ]; } diff --git a/src/Rating/Entity/Rate.php b/src/Rating/Entity/Rate.php index 8b7f08dd..949a0396 100644 --- a/src/Rating/Entity/Rate.php +++ b/src/Rating/Entity/Rate.php @@ -12,6 +12,15 @@ use Symfony\Component\Uid\Uuid; #[ORM\UniqueConstraint(name: 'idx_rates_user_doctor', columns: ['user_id', 'doctor_id'])] class Rate { + /** Dimension column => Persian label. Drives the public aggregate response. */ + public const DIMENSIONS = [ + 'waiting_time_at_clinic' => 'زمان انتظار در مطب', + 'accuracy_of_diagnosis' => 'تشخیص درست', + 'doctor_behavior' => 'برخورد مناسب پزشک', + 'clinic_cleanliness' => 'نظافت مطب', + 'doctor_expertise' => 'مهارت پزشک', + ]; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -28,8 +37,20 @@ class Rate #[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private Doctor $doctor; - #[ORM\Column(type: 'smallint')] - private int $score; + #[ORM\Column(name: 'waiting_time_at_clinic', type: 'smallint')] + private int $waitingTimeAtClinic = 0; + + #[ORM\Column(name: 'accuracy_of_diagnosis', type: 'smallint')] + private int $accuracyOfDiagnosis = 0; + + #[ORM\Column(name: 'doctor_behavior', type: 'smallint')] + private int $doctorBehavior = 0; + + #[ORM\Column(name: 'clinic_cleanliness', type: 'smallint')] + private int $clinicCleanliness = 0; + + #[ORM\Column(name: 'doctor_expertise', type: 'smallint')] + private int $doctorExpertise = 0; #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -37,31 +58,50 @@ class Rate #[ORM\Column(name: 'updated_at', type: 'integer')] private int $updatedAt; - public function __construct(User $user, Doctor $doctor, int $score) + /** @param array $dimensions keyed by DIMENSIONS keys (0–100) */ + public function __construct(User $user, Doctor $doctor, array $dimensions) { $this->uuid = Uuid::v4()->toRfc4122(); $this->user = $user; $this->doctor = $doctor; - $this->score = max(1, min(5, $score)); $this->createdAt = time(); $this->updatedAt = time(); + $this->setDimensions($dimensions); } public function getId(): ?int { return $this->id; } public function getUuid(): string { return $this->uuid; } public function getUser(): User { return $this->user; } public function getDoctor(): Doctor { return $this->doctor; } - public function getScore(): int { return $this->score; } - public function setScore(int $v): self { $this->score = max(1, min(5, $v)); $this->updatedAt = time(); return $this; } + public function getWaitingTimeAtClinic(): int { return $this->waitingTimeAtClinic; } + public function getAccuracyOfDiagnosis(): int { return $this->accuracyOfDiagnosis; } + public function getDoctorBehavior(): int { return $this->doctorBehavior; } + public function getClinicCleanliness(): int { return $this->clinicCleanliness; } + public function getDoctorExpertise(): int { return $this->doctorExpertise; } - public function toArray(): array + /** @param array $dimensions keyed by DIMENSIONS keys (0–100) */ + public function setDimensions(array $dimensions): self { - return [ - 'uuid' => $this->uuid, - 'doctor_uuid' => $this->doctor->getUuid(), - 'score' => $this->score, - 'created_at' => $this->createdAt, - ]; + $clamp = static fn($v) => max(0, min(100, (int) $v)); + $this->waitingTimeAtClinic = $clamp($dimensions['waiting_time_at_clinic'] ?? $this->waitingTimeAtClinic); + $this->accuracyOfDiagnosis = $clamp($dimensions['accuracy_of_diagnosis'] ?? $this->accuracyOfDiagnosis); + $this->doctorBehavior = $clamp($dimensions['doctor_behavior'] ?? $this->doctorBehavior); + $this->clinicCleanliness = $clamp($dimensions['clinic_cleanliness'] ?? $this->clinicCleanliness); + $this->doctorExpertise = $clamp($dimensions['doctor_expertise'] ?? $this->doctorExpertise); + $this->updatedAt = time(); + return $this; + } + + /** Overall percentage (0–100): mean of the five dimensions. */ + public function overallPercent(): float + { + return ( + $this->waitingTimeAtClinic + + $this->accuracyOfDiagnosis + + $this->doctorBehavior + + $this->clinicCleanliness + + $this->doctorExpertise + ) / 5; } } diff --git a/src/Rating/Repository/CommentRepository.php b/src/Rating/Repository/CommentRepository.php index c78ffbf2..51418cca 100644 --- a/src/Rating/Repository/CommentRepository.php +++ b/src/Rating/Repository/CommentRepository.php @@ -19,6 +19,15 @@ class CommentRepository extends ServiceEntityRepository return $this->findBy(['doctor' => $doctor, 'status' => Comment::STATUS_APPROVED], ['createdAt' => 'DESC']); } + /** @return Comment[] approved root comments (no parent) for a doctor */ + public function findApprovedRootsByDoctor(Doctor $doctor): array + { + return $this->findBy( + ['doctor' => $doctor, 'status' => Comment::STATUS_APPROVED, 'parent' => null], + ['createdAt' => 'DESC'] + ); + } + /** @return Comment[] */ public function findPending(): array { diff --git a/src/Rating/Repository/LikeRepository.php b/src/Rating/Repository/LikeRepository.php index 25bfecc6..406a74b5 100644 --- a/src/Rating/Repository/LikeRepository.php +++ b/src/Rating/Repository/LikeRepository.php @@ -14,6 +14,29 @@ class LikeRepository extends ServiceEntityRepository public function findByUserAndComment(User $user, Comment $comment): ?Like { return $this->findOneBy(['user' => $user, 'comment' => $comment]); } + /** @return array{like_count:int,dislike_count:int} */ + public function countByComment(Comment $comment): array + { + $rows = $this->createQueryBuilder('l') + ->select('l.value as value, COUNT(l.id) as cnt') + ->where('l.comment = :comment') + ->setParameter('comment', $comment) + ->groupBy('l.value') + ->getQuery()->getResult(); + + $likeCount = 0; + $dislikeCount = 0; + foreach ($rows as $row) { + if ((int) $row['value'] >= 0) { + $likeCount = (int) $row['cnt']; + } else { + $dislikeCount = (int) $row['cnt']; + } + } + + return ['like_count' => $likeCount, 'dislike_count' => $dislikeCount]; + } + public function save(Like $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); } public function remove(Like $e, bool $flush = true): void { $this->getEntityManager()->remove($e); if ($flush) $this->getEntityManager()->flush(); } } diff --git a/src/Rating/Repository/RateRepository.php b/src/Rating/Repository/RateRepository.php index 777adf95..c6e7d184 100644 --- a/src/Rating/Repository/RateRepository.php +++ b/src/Rating/Repository/RateRepository.php @@ -14,14 +14,40 @@ class RateRepository extends ServiceEntityRepository public function findByUserAndDoctor(User $user, Doctor $doctor): ?Rate { return $this->findOneBy(['user' => $user, 'doctor' => $doctor]); } - public function getAverageScore(Doctor $doctor): float + /** + * Aggregate the five rating dimensions for a doctor. + * + * @return array{point: float, satisfaction: int, averages: list} + */ + public function getAggregate(Doctor $doctor): array { - $result = $this->createQueryBuilder('r') - ->select('AVG(r.score) as avg, COUNT(r.id) as cnt') + $row = $this->createQueryBuilder('r') + ->select( + 'AVG(r.waitingTimeAtClinic) as waiting_time_at_clinic', + 'AVG(r.accuracyOfDiagnosis) as accuracy_of_diagnosis', + 'AVG(r.doctorBehavior) as doctor_behavior', + 'AVG(r.clinicCleanliness) as clinic_cleanliness', + 'AVG(r.doctorExpertise) as doctor_expertise' + ) ->where('r.doctor = :doctor') ->setParameter('doctor', $doctor) ->getQuery()->getSingleResult(); - return round((float)($result['avg'] ?? 0), 1); + + $averages = []; + $sum = 0; + foreach (Rate::DIMENSIONS as $name => $label) { + $progress = (int) round((float) ($row[$name] ?? 0)); + $averages[] = ['name' => $name, 'label' => $label, 'progress' => $progress]; + $sum += $progress; + } + + $satisfaction = (int) round($sum / count(Rate::DIMENSIONS)); + + return [ + 'point' => round($satisfaction / 20, 1), + 'satisfaction' => $satisfaction, + 'averages' => $averages, + ]; } public function save(Rate $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); } diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php index 094674b8..87496690 100644 --- a/src/Shared/Constant/ErrorCodes.php +++ b/src/Shared/Constant/ErrorCodes.php @@ -69,6 +69,9 @@ class ErrorCodes // Rate Limit public const ERR_RATE_LIMIT_001 = 'ERR_RATE_LIMIT_001'; + // Rating + public const ERR_RATING_NOT_ELIGIBLE = 'ERR_RATING_NOT_ELIGIBLE'; + public static function message(string $code): string { return match ($code) { @@ -105,6 +108,7 @@ class ErrorCodes self::ERR_PATIENT_NOT_FOUND => 'پرونده بیمار یافت نشد', self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد', self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست', + self::ERR_RATING_NOT_ELIGIBLE => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تایید‌شده نزد این پزشک داشته باشید', default => 'خطای ناشناخته', }; }