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
+101 -21
View File
@@ -2,6 +2,7 @@
namespace App\Rating\Controller;
use App\Appointment\Repository\AppointmentRepository;
use App\Auth\Entity\User;
use App\Doctor\Repository\DoctorRepository;
use App\Rating\Entity\Comment;
@@ -27,6 +28,7 @@ class RatingController extends BaseController
private readonly CommentRepository $commentRepo,
private readonly LikeRepository $likeRepo,
private readonly DoctorRepository $doctorRepo,
private readonly AppointmentRepository $appointmentRepo,
) {}
// ── Ratings ───────────────────────────────────────────────────────────────
@@ -67,10 +69,14 @@ class RatingController extends BaseController
{
$data = json_decode($request->getContent(), true) ?? [];
$doctorUuid = trim($data['doctor_uuid'] ?? '');
$score = (int) ($data['score'] ?? 0);
if ($score < 1 || $score > 5) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'امتیاز باید بین ۱ تا ۵ باشد', 422);
$dimensions = [];
foreach (array_keys(Rate::DIMENSIONS) as $name) {
$value = (int) ($data[$name] ?? -1);
if ($value < 0 || $value > 100) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'هر امتیاز باید بین ۰ تا ۱۰۰ باشد', 422);
}
$dimensions[$name] = $value;
}
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
@@ -78,17 +84,21 @@ class RatingController extends BaseController
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
}
$existing = $this->rateRepo->findByUserAndDoctor($user, $doctor);
if ($existing !== null) {
$existing->setScore($score);
$this->rateRepo->save($existing);
return $this->success(['data' => $existing->toArray()]);
if (!$this->appointmentRepo->hasRecentConfirmed($user, $doctor)) {
return $this->error(ErrorCodes::ERR_RATING_NOT_ELIGIBLE, ErrorCodes::message(ErrorCodes::ERR_RATING_NOT_ELIGIBLE), 403);
}
$rate = new Rate($user, $doctor, $score);
$existing = $this->rateRepo->findByUserAndDoctor($user, $doctor);
if ($existing !== null) {
$existing->setDimensions($dimensions);
$this->rateRepo->save($existing);
return $this->success(['data' => $this->rateRepo->getAggregate($doctor)]);
}
$rate = new Rate($user, $doctor, $dimensions);
$this->rateRepo->save($rate);
return $this->success(['data' => $rate->toArray()], 201);
return $this->success(['data' => $this->rateRepo->getAggregate($doctor)], 201);
}
#[OA\Get(
@@ -125,7 +135,47 @@ class RatingController extends BaseController
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
}
return $this->success(['average' => $this->rateRepo->getAverageScore($doctor)]);
return $this->success(['data' => $this->rateRepo->getAggregate($doctor)]);
}
#[OA\Get(
path: '/api/v1/rate/{doctorUuid}/eligibility',
summary: 'Whether the current user may rate/comment on this doctor',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'doctorUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Eligibility status',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'eligible', type: 'boolean'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'Doctor not found'),
]
)]
#[Route('/api/v1/rate/{doctorUuid}/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)]);
}
// ── Comments ──────────────────────────────────────────────────────────────
@@ -166,7 +216,8 @@ class RatingController extends BaseController
{
$data = json_decode($request->getContent(), true) ?? [];
$doctorUuid = trim($data['doctor_uuid'] ?? '');
$body = trim($data['body'] ?? '');
$body = trim($data['comment'] ?? '');
$parentUuid = trim($data['parent'] ?? '');
if (empty($body)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'متن نظر الزامی است', 422);
@@ -177,10 +228,22 @@ class RatingController extends BaseController
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
}
$comment = new Comment($user, $doctor, $body);
if (!$this->appointmentRepo->hasRecentConfirmed($user, $doctor)) {
return $this->error(ErrorCodes::ERR_RATING_NOT_ELIGIBLE, ErrorCodes::message(ErrorCodes::ERR_RATING_NOT_ELIGIBLE), 403);
}
$parent = null;
if ($parentUuid !== '') {
$parent = $this->commentRepo->findByUuid($parentUuid);
if ($parent === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر والد یافت نشد', 404);
}
}
$comment = new Comment($user, $doctor, $body, $parent);
$this->commentRepo->save($comment);
return $this->success(['data' => $comment->toArray()], 201);
return $this->success(['data' => $comment->toArray($user)], 201);
}
#[OA\Get(
@@ -213,7 +276,7 @@ class RatingController extends BaseController
$comments = array_map(
fn(Comment $c) => $c->toArray(),
$this->commentRepo->findApprovedByDoctor($doctor)
$this->commentRepo->findApprovedRootsByDoctor($doctor)
);
return $this->success(['data' => $comments]);
@@ -412,21 +475,38 @@ class RatingController extends BaseController
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/like/{commentUuid}', methods: ['POST'])]
public function toggleLike(string $commentUuid, #[CurrentUser] User $user): JsonResponse
public function toggleLike(string $commentUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$comment = $this->commentRepo->findByUuid($commentUuid);
if ($comment === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$value = ((int) ($data['value'] ?? 1)) >= 0 ? 1 : -1;
$existing = $this->likeRepo->findByUserAndComment($user, $comment);
if ($existing !== null) {
$this->likeRepo->remove($existing);
return $this->success(['liked' => false, 'likes' => $comment->getLikes()->count() - 1]);
if ($existing->getValue() === $value) {
$this->likeRepo->remove($existing);
} else {
$existing->setValue($value);
$this->likeRepo->save($existing);
}
} else {
$this->likeRepo->save(new Like($user, $comment, $value));
}
$like = new Like($user, $comment);
$this->likeRepo->save($like);
return $this->success(['liked' => true, 'likes' => $comment->getLikes()->count()], 201);
$counts = $this->likeRepo->countByComment($comment);
$current = $this->likeRepo->findByUserAndComment($user, $comment);
return $this->success([
'like_count' => $counts['like_count'],
'dislike_count' => $counts['dislike_count'],
'current_user_like' => [
'like' => $current !== null && $current->getValue() >= 0,
'dislike' => $current !== null && $current->getValue() < 0,
],
]);
}
}