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:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
Reference in New Issue
Block a user