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>
9.3 KiB
محدودسازی ثبت نظر/امتیاز به کاربرانِ دارای نوبت تاییدشده در ۳۰ روز گذشته
پروژه
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 = Uappointment.doctor = Dappointment.status = Appointment::STATUS_CONFIRMEDappointment.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() — هیچ گاردی ندارد:
$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 الگوی کوئری موجود (برای تقلید):
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:
/** آیا کاربر در 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/ساخت)، در هر دو متد:
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 سبک اضافه کن:
#[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 واقعی.
تست
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 بهروز شود.