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:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 (0–100) */
|
||||
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 (0–100) */
|
||||
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 (0–100): mean of the five dimensions. */
|
||||
public function overallPercent(): float
|
||||
{
|
||||
return (
|
||||
$this->waitingTimeAtClinic
|
||||
+ $this->accuracyOfDiagnosis
|
||||
+ $this->doctorBehavior
|
||||
+ $this->clinicCleanliness
|
||||
+ $this->doctorExpertise
|
||||
) / 5;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user