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>
This commit is contained in:
hamed
2026-06-16 00:25:59 +03:30
co-authored by Claude Opus 4.8
parent a5fca5d1ba
commit 45242a3128
16 changed files with 846 additions and 108 deletions
@@ -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": "<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).
@@ -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 به‌روز شود.
+4 -2
View File
@@ -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]
+2
View File
@@ -665,6 +665,8 @@ List all ratings.
| `limit` | integer | ❌ | Default: 20 |
| `search` | string | ❌ | Search by doctor/patient |
> Ratings are multi-dimensional (five 0100 dimensions). Each row's `overall` is the mean of the five dimensions (`0100`) and `score` is that mean on a 05 scale (`overall / 20`).
---
### GET `/api/v1/admin/comments`
+137 -49
View File
@@ -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 `0100`:
```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 | ✅ | 15 |
| `waiting_time_at_clinic` | integer | ✅ | 0100 |
| `accuracy_of_diagnosis` | integer | ✅ | 0100 |
| `doctor_behavior` | integer | ✅ | 0100 |
| `clinic_cleanliness` | integer | ✅ | 0100 |
| `doctor_expertise` | integer | ✅ | 0100 |
### 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 0100 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 05 scale (`satisfaction / 20`).
- `satisfaction`: mean of all dimensions, percent `0100`.
- `averages[].progress`: per-dimension mean, percent `0100`.
- 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": "<root-uuid>",
"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 }
}
}
```
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260615203529 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
+16 -9
View File
@@ -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);
}
@@ -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
{
+101 -21
View File
@@ -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,
],
]);
}
}
+54 -9
View File
@@ -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,
];
}
}
+10 -1
View File
@@ -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,
];
}
+53 -13
View File
@@ -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<string,int> $dimensions keyed by DIMENSIONS keys (0100) */
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<string,int> $dimensions keyed by DIMENSIONS keys (0100) */
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 (0100): mean of the five dimensions. */
public function overallPercent(): float
{
return (
$this->waitingTimeAtClinic
+ $this->accuracyOfDiagnosis
+ $this->doctorBehavior
+ $this->clinicCleanliness
+ $this->doctorExpertise
) / 5;
}
}
@@ -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
{
+23
View File
@@ -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(); }
}
+30 -4
View File
@@ -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<array{name:string,label:string,progress:int}>}
*/
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(); }
+4
View File
@@ -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 => 'خطای ناشناخته',
};
}