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:
hamed
2026-06-16 00:25:59 +03:30
co-authored by Claude Opus 4.8
parent a5fca5d1ba
commit 45242a3128
16 changed files with 846 additions and 108 deletions
@@ -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
{