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

9.3 KiB
Raw Blame History

محدودسازی ثبت نظر/امتیاز به کاربرانِ دارای نوبت تایید‌شده در ۳۰ روز گذشته

پروژه

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() — هیچ گاردی ندارد:

$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 به‌روز شود.