Files
clinicpro/.claude/prompt/rating-require-recent-confirmed-appointment.md
T
hamedandClaude Opus 4.8 45242a3128 feat(rating): multi-dimensional ratings, rich comments, eligibility guard
Rebuild the doctor rating/review system to power the public site's rich
review UI, and restrict who may submit.

Ratings:
- Rate entity holds five 0–100 dimensions (waiting time, diagnosis
  accuracy, behaviour, cleanliness, expertise) instead of a single score.
- GET /rate/{uuid} returns aggregate {point, satisfaction, averages[]}.
- POST /rate upserts all five dimensions and returns the new aggregate.

Comments:
- Comment gains parent/replies (threaded) and a rich toArray with author,
  like_status (like/dislike counts + current user's vote) and nested
  approved replies. POST /comment accepts {comment, parent}.
- Likes are directional (value 1=like, -1=dislike) with toggle/replace;
  POST /like/{uuid} returns like_count/dislike_count/current_user_like.

Eligibility:
- Only a user with a confirmed appointment in the last 30 days may rate or
  comment (AppointmentRepository::hasRecentConfirmed); otherwise
  403 ERR_RATING_NOT_ELIGIBLE. New GET /rate/{uuid}/eligibility for the UI.
- security.yaml: narrow the public rate pattern so /eligibility stays auth'd.

Also updates admin rates listing to the new dimensions and the rating/admin
API docs. Includes migration for the new columns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:25:59 +03:30

169 lines
9.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# محدودسازی ثبت نظر/امتیاز به کاربرانِ دارای نوبت تایید‌شده در ۳۰ روز گذشته
## پروژه
`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 به‌روز شود.