feat(appointment): book with patient data, TTL lock, atomic conflict
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
class SlotTakenException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user