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