From aa2e46dfdba111bf81e047c8cb4d4cfef2f501e7 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 15 Jun 2026 17:56:56 +0330 Subject: [PATCH] feat(appointment): book with patient data, TTL lock, atomic conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit book() now accepts for_self plus patient_* fields: for_self fills the patient from the paying user's profile, otherwise patient_name/mobile are required (422 if missing) and the rest are stored. Every booking starts pending with a 15-minute expires_at. Persisting goes through bookAtomically (re-check inside a transaction) so two concurrent requests for the same slot can't both win — the loser gets 409. Co-Authored-By: Claude Opus 4.8 --- .../Controller/AppointmentController.php | 29 ++++++++++++++++--- .../Repository/AppointmentRepository.php | 18 ++++++++++++ .../Repository/SlotTakenException.php | 7 +++++ 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 src/Appointment/Repository/SlotTakenException.php diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 58bbe8ce..0549e221 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -4,6 +4,7 @@ namespace App\Appointment\Controller; use App\Appointment\Entity\Appointment; use App\Appointment\Repository\AppointmentRepository; +use App\Appointment\Repository\SlotTakenException; use App\Appointment\Service\SlotCalculatorService; use App\Auth\Entity\User; use App\Doctor\Repository\DoctorRepository; @@ -184,14 +185,34 @@ class AppointmentController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } - if ($this->appointmentRepo->isSlotTaken($doctor, $slotStart, $slotEnd)) { - return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409); - } + $forSelf = (bool) ($data['for_self'] ?? true); $appointment = new Appointment($doctor, $user, $slotStart, $slotEnd); if (isset($data['note'])) $appointment->setNote($data['note']); - $this->appointmentRepo->save($appointment); + if ($forSelf) { + $appointment->setPatientName($user->getRealName()); + $appointment->setPatientMobile($user->getMobileNumber()); + } else { + $patientName = trim($data['patient_name'] ?? ''); + $patientMobile = trim($data['patient_mobile'] ?? ''); + if ($patientName === '' || $patientMobile === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام و شماره موبایل بیمار الزامی است', 422); + } + $appointment->setPatientName($patientName); + $appointment->setPatientMobile($patientMobile); + $appointment->setPatientNationalCode($data['patient_national_code'] ?? null); + $appointment->setPatientGender($data['patient_gender'] ?? null); + $appointment->setPatientReason($data['patient_reason'] ?? null); + } + + $appointment->markPendingWithTtl(Appointment::PAYMENT_TTL); + + try { + $this->appointmentRepo->bookAtomically($appointment); + } catch (SlotTakenException) { + return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409); + } return $this->success(['data' => $appointment->toArray()], 201); } diff --git a/src/Appointment/Repository/AppointmentRepository.php b/src/Appointment/Repository/AppointmentRepository.php index 13ca2f0d..00e6c155 100644 --- a/src/Appointment/Repository/AppointmentRepository.php +++ b/src/Appointment/Repository/AppointmentRepository.php @@ -21,6 +21,24 @@ class AppointmentRepository extends ServiceEntityRepository return $this->findOneBy(['uuid' => $uuid]); } + /** + * Persist a booking atomically: re-check the slot inside a transaction so + * two concurrent requests for the same slot cannot both succeed. + * + * @throws SlotTakenException if the slot is taken when the transaction commits + */ + public function bookAtomically(Appointment $appointment): void + { + $em = $this->getEntityManager(); + $em->wrapInTransaction(function () use ($em, $appointment): void { + if ($this->isSlotTaken($appointment->getDoctor(), $appointment->getSlotStart(), $appointment->getSlotEnd())) { + throw new SlotTakenException(); + } + $em->persist($appointment); + $em->flush(); + }); + } + /** Check if a slot is already taken (confirmed or pending) */ public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool { diff --git a/src/Appointment/Repository/SlotTakenException.php b/src/Appointment/Repository/SlotTakenException.php new file mode 100644 index 00000000..8821bbd3 --- /dev/null +++ b/src/Appointment/Repository/SlotTakenException.php @@ -0,0 +1,7 @@ +