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
+54 -9
View File
@@ -34,6 +34,13 @@ class Comment
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
#[ORM\ManyToOne(targetEntity: Comment::class, inversedBy: 'replies')]
#[ORM\JoinColumn(name: 'parent_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Comment $parent = null;
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'parent')]
private Collection $replies;
#[ORM\Column(type: 'text')]
private string $body;
@@ -49,13 +56,15 @@ class Comment
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(User $user, Doctor $doctor, string $body)
public function __construct(User $user, Doctor $doctor, string $body, ?Comment $parent = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->user = $user;
$this->doctor = $doctor;
$this->body = $body;
$this->parent = $parent;
$this->likes = new ArrayCollection();
$this->replies = new ArrayCollection();
$this->createdAt = time();
$this->updatedAt = time();
}
@@ -67,22 +76,58 @@ class Comment
public function getBody(): string { return $this->body; }
public function getStatus(): string { return $this->status; }
public function getLikes(): Collection { return $this->likes; }
public function getParent(): ?Comment { return $this->parent; }
public function getReplies(): Collection { return $this->replies; }
public function setBody(string $v): self { $this->body = $v; $this->updatedAt = time(); return $this; }
public function approve(): self { $this->status = self::STATUS_APPROVED; $this->updatedAt = time(); return $this; }
public function reject(): self { $this->status = self::STATUS_REJECTED; $this->updatedAt = time(); return $this; }
public function toArray(): array
public function toArray(?User $currentUser = null): array
{
$likeCount = 0;
$dislikeCount = 0;
$userLike = false;
$userDislike = false;
foreach ($this->likes as $like) {
if ($like->getValue() >= 0) {
$likeCount++;
} else {
$dislikeCount++;
}
if ($currentUser !== null && $like->getUser()->getId() === $currentUser->getId()) {
$userLike = $like->getValue() >= 0;
$userDislike = $like->getValue() < 0;
}
}
$replies = [];
foreach ($this->replies as $reply) {
if ($reply->getStatus() === self::STATUS_APPROVED) {
$replies[] = $reply->toArray($currentUser);
}
}
return [
'uuid' => $this->uuid,
'doctor_uuid' => $this->doctor->getUuid(),
'user_uuid' => $this->user->getUuid(),
'body' => $this->body,
'status' => $this->status,
'likes' => $this->likes->count(),
'created_at' => $this->createdAt,
'uuid' => $this->uuid,
'comment' => $this->body,
'created' => $this->createdAt,
'parent' => $this->parent?->getUuid(),
'author' => [
'real_name' => $this->user->getRealName() ?? 'کاربر نوبت‌۷۲۴',
'picture' => [],
],
'like_status' => [
'like_count' => $likeCount,
'dislike_count' => $dislikeCount,
'current_user_like' => [
'like' => $userLike,
'dislike' => $userDislike,
],
],
'replies' => $replies,
];
}
}
+10 -1
View File
@@ -27,14 +27,19 @@ class Like
#[ORM\JoinColumn(name: 'comment_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Comment $comment;
/** 1 = like, -1 = dislike */
#[ORM\Column(type: 'smallint')]
private int $value;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(User $user, Comment $comment)
public function __construct(User $user, Comment $comment, int $value)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->user = $user;
$this->comment = $comment;
$this->value = $value >= 0 ? 1 : -1;
$this->createdAt = time();
}
@@ -42,6 +47,9 @@ class Like
public function getUuid(): string { return $this->uuid; }
public function getUser(): User { return $this->user; }
public function getComment(): Comment { return $this->comment; }
public function getValue(): int { return $this->value; }
public function setValue(int $v): self { $this->value = $v >= 0 ? 1 : -1; return $this; }
public function toArray(): array
{
@@ -49,6 +57,7 @@ class Like
'uuid' => $this->uuid,
'comment_uuid' => $this->comment->getUuid(),
'user_uuid' => $this->user->getUuid(),
'value' => $this->value,
'created_at' => $this->createdAt,
];
}
+53 -13
View File
@@ -12,6 +12,15 @@ use Symfony\Component\Uid\Uuid;
#[ORM\UniqueConstraint(name: 'idx_rates_user_doctor', columns: ['user_id', 'doctor_id'])]
class Rate
{
/** Dimension column => Persian label. Drives the public aggregate response. */
public const DIMENSIONS = [
'waiting_time_at_clinic' => 'زمان انتظار در مطب',
'accuracy_of_diagnosis' => 'تشخیص درست',
'doctor_behavior' => 'برخورد مناسب پزشک',
'clinic_cleanliness' => 'نظافت مطب',
'doctor_expertise' => 'مهارت پزشک',
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -28,8 +37,20 @@ class Rate
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
#[ORM\Column(type: 'smallint')]
private int $score;
#[ORM\Column(name: 'waiting_time_at_clinic', type: 'smallint')]
private int $waitingTimeAtClinic = 0;
#[ORM\Column(name: 'accuracy_of_diagnosis', type: 'smallint')]
private int $accuracyOfDiagnosis = 0;
#[ORM\Column(name: 'doctor_behavior', type: 'smallint')]
private int $doctorBehavior = 0;
#[ORM\Column(name: 'clinic_cleanliness', type: 'smallint')]
private int $clinicCleanliness = 0;
#[ORM\Column(name: 'doctor_expertise', type: 'smallint')]
private int $doctorExpertise = 0;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -37,31 +58,50 @@ class Rate
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(User $user, Doctor $doctor, int $score)
/** @param array<string,int> $dimensions keyed by DIMENSIONS keys (0100) */
public function __construct(User $user, Doctor $doctor, array $dimensions)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->user = $user;
$this->doctor = $doctor;
$this->score = max(1, min(5, $score));
$this->createdAt = time();
$this->updatedAt = time();
$this->setDimensions($dimensions);
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getUser(): User { return $this->user; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getScore(): int { return $this->score; }
public function setScore(int $v): self { $this->score = max(1, min(5, $v)); $this->updatedAt = time(); return $this; }
public function getWaitingTimeAtClinic(): int { return $this->waitingTimeAtClinic; }
public function getAccuracyOfDiagnosis(): int { return $this->accuracyOfDiagnosis; }
public function getDoctorBehavior(): int { return $this->doctorBehavior; }
public function getClinicCleanliness(): int { return $this->clinicCleanliness; }
public function getDoctorExpertise(): int { return $this->doctorExpertise; }
public function toArray(): array
/** @param array<string,int> $dimensions keyed by DIMENSIONS keys (0100) */
public function setDimensions(array $dimensions): self
{
return [
'uuid' => $this->uuid,
'doctor_uuid' => $this->doctor->getUuid(),
'score' => $this->score,
'created_at' => $this->createdAt,
];
$clamp = static fn($v) => max(0, min(100, (int) $v));
$this->waitingTimeAtClinic = $clamp($dimensions['waiting_time_at_clinic'] ?? $this->waitingTimeAtClinic);
$this->accuracyOfDiagnosis = $clamp($dimensions['accuracy_of_diagnosis'] ?? $this->accuracyOfDiagnosis);
$this->doctorBehavior = $clamp($dimensions['doctor_behavior'] ?? $this->doctorBehavior);
$this->clinicCleanliness = $clamp($dimensions['clinic_cleanliness'] ?? $this->clinicCleanliness);
$this->doctorExpertise = $clamp($dimensions['doctor_expertise'] ?? $this->doctorExpertise);
$this->updatedAt = time();
return $this;
}
/** Overall percentage (0100): mean of the five dimensions. */
public function overallPercent(): float
{
return (
$this->waitingTimeAtClinic
+ $this->accuracyOfDiagnosis
+ $this->doctorBehavior
+ $this->clinicCleanliness
+ $this->doctorExpertise
) / 5;
}
}