# محدودسازی ثبت نظر/امتیاز به کاربرانِ دارای نوبت تایید‌شده در ۳۰ روز گذشته ## پروژه `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 به‌روز شود.