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
@@ -19,6 +19,15 @@ class CommentRepository extends ServiceEntityRepository
return $this->findBy(['doctor' => $doctor, 'status' => Comment::STATUS_APPROVED], ['createdAt' => 'DESC']);
}
/** @return Comment[] approved root comments (no parent) for a doctor */
public function findApprovedRootsByDoctor(Doctor $doctor): array
{
return $this->findBy(
['doctor' => $doctor, 'status' => Comment::STATUS_APPROVED, 'parent' => null],
['createdAt' => 'DESC']
);
}
/** @return Comment[] */
public function findPending(): array
{
+23
View File
@@ -14,6 +14,29 @@ class LikeRepository extends ServiceEntityRepository
public function findByUserAndComment(User $user, Comment $comment): ?Like { return $this->findOneBy(['user' => $user, 'comment' => $comment]); }
/** @return array{like_count:int,dislike_count:int} */
public function countByComment(Comment $comment): array
{
$rows = $this->createQueryBuilder('l')
->select('l.value as value, COUNT(l.id) as cnt')
->where('l.comment = :comment')
->setParameter('comment', $comment)
->groupBy('l.value')
->getQuery()->getResult();
$likeCount = 0;
$dislikeCount = 0;
foreach ($rows as $row) {
if ((int) $row['value'] >= 0) {
$likeCount = (int) $row['cnt'];
} else {
$dislikeCount = (int) $row['cnt'];
}
}
return ['like_count' => $likeCount, 'dislike_count' => $dislikeCount];
}
public function save(Like $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
public function remove(Like $e, bool $flush = true): void { $this->getEntityManager()->remove($e); if ($flush) $this->getEntityManager()->flush(); }
}
+30 -4
View File
@@ -14,14 +14,40 @@ class RateRepository extends ServiceEntityRepository
public function findByUserAndDoctor(User $user, Doctor $doctor): ?Rate { return $this->findOneBy(['user' => $user, 'doctor' => $doctor]); }
public function getAverageScore(Doctor $doctor): float
/**
* Aggregate the five rating dimensions for a doctor.
*
* @return array{point: float, satisfaction: int, averages: list<array{name:string,label:string,progress:int}>}
*/
public function getAggregate(Doctor $doctor): array
{
$result = $this->createQueryBuilder('r')
->select('AVG(r.score) as avg, COUNT(r.id) as cnt')
$row = $this->createQueryBuilder('r')
->select(
'AVG(r.waitingTimeAtClinic) as waiting_time_at_clinic',
'AVG(r.accuracyOfDiagnosis) as accuracy_of_diagnosis',
'AVG(r.doctorBehavior) as doctor_behavior',
'AVG(r.clinicCleanliness) as clinic_cleanliness',
'AVG(r.doctorExpertise) as doctor_expertise'
)
->where('r.doctor = :doctor')
->setParameter('doctor', $doctor)
->getQuery()->getSingleResult();
return round((float)($result['avg'] ?? 0), 1);
$averages = [];
$sum = 0;
foreach (Rate::DIMENSIONS as $name => $label) {
$progress = (int) round((float) ($row[$name] ?? 0));
$averages[] = ['name' => $name, 'label' => $label, 'progress' => $progress];
$sum += $progress;
}
$satisfaction = (int) round($sum / count(Rate::DIMENSIONS));
return [
'point' => round($satisfaction / 20, 1),
'satisfaction' => $satisfaction,
'averages' => $averages,
];
}
public function save(Rate $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }