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:
@@ -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 بهروز شود.
|
||||
@@ -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]
|
||||
|
||||
@@ -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`
|
||||
|
||||
+137
-49
@@ -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": "<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 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 (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<string,int> $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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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 => 'خطای ناشناخته',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user