fix(db): prevent double-booking a slot via unique active_slot_key (H2)
A non-unique index on (doctor_id, slot_start) plus a count-then-insert check left a TOCTOU race: two concurrent requests could both pass isSlotTaken and both insert. wrapInTransaction alone doesn't stop the phantom under InnoDB REPEATABLE-READ. Add a nullable, unique active_slot_key on Appointment = "doctorId:slotStart" while the booking occupies the slot (pending/confirmed — in lockstep with isSlotTaken); NULL once expired/completed/no_show/cancelled (NULLs don't collide in a MySQL unique index, so released slots rebook freely). bookAtomically now: catches the unique violation -> SlotTakenException, and expires lapsed pendings in-transaction so the ~1-min window before the expiry cron doesn't wrongly block rebooking. All three booking paths (online / my / admin) routed through it. Migration backfills one row per (doctor, slot) — the latest id — so the index builds even on dirty historical data without destructively cancelling bookings. (Backfill surfaced a real pre-existing double-booked slot in dev data.) Regression: tests/Appointment/SlotUniquenessTest. Adjusted the expiry-service test fixture to use distinct slots (one live booking per slot is now enforced). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
namespace App\Admin\Controller;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\SlotTakenException;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Shared\Service\InputValidator;
|
||||
use App\Location\Entity\City;
|
||||
@@ -805,26 +806,16 @@ class AdminApiController extends BaseController
|
||||
$this->em->persist($patient);
|
||||
}
|
||||
|
||||
$conflict = $this->em->createQueryBuilder()
|
||||
->select('COUNT(a.id)')
|
||||
->from(Appointment::class, 'a')
|
||||
->where('a.doctor = :doctor')
|
||||
->andWhere('a.slotStart < :end AND a.slotEnd > :start')
|
||||
->andWhere("a.status NOT IN ('cancelled_by_doctor','cancelled_by_user','cancelled_by_admin','auto_cancel_unpaid')")
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('start', $slotStart)
|
||||
->setParameter('end', $slotEnd)
|
||||
->getQuery()->getSingleScalarResult();
|
||||
|
||||
if ($conflict > 0) return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
|
||||
|
||||
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
|
||||
if (!empty($data['note'])) $appointment->setNote($data['note']);
|
||||
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
|
||||
if ($locationId !== null) $appointment->setAddressId($locationId);
|
||||
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
try {
|
||||
$this->em->getRepository(Appointment::class)->bookAtomically($appointment);
|
||||
} catch (SlotTakenException) {
|
||||
return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'uuid' => $appointment->getUuid(),
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
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\Auth\Repository\UserActiveContextRepository;
|
||||
@@ -25,6 +27,7 @@ class MyAppointmentsController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
@@ -72,24 +75,16 @@ class MyAppointmentsController extends BaseController
|
||||
$this->em->persist($patient);
|
||||
}
|
||||
|
||||
$conflict = $this->em->createQueryBuilder()
|
||||
->select('COUNT(a.id)')->from(Appointment::class, 'a')
|
||||
->where('a.doctor = :doctor')
|
||||
->andWhere('a.slotStart < :end AND a.slotEnd > :start')
|
||||
->andWhere("a.status NOT IN ('cancelled_by_doctor','cancelled_by_user','cancelled_by_admin','auto_cancel_unpaid')")
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('start', $slotStart)
|
||||
->setParameter('end', $slotEnd)
|
||||
->getQuery()->getSingleScalarResult();
|
||||
|
||||
if ($conflict > 0) return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
|
||||
|
||||
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
|
||||
if (!empty($data['note'])) $appointment->setNote($data['note']);
|
||||
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
|
||||
if ($locationId !== null) $appointment->setAddressId($locationId);
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
try {
|
||||
$this->appointmentRepo->bookAtomically($appointment);
|
||||
} catch (SlotTakenException) {
|
||||
return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'uuid' => $appointment->getUuid(),
|
||||
|
||||
@@ -34,6 +34,19 @@ class Appointment
|
||||
self::STATUS_CONFIRMED => [self::STATUS_COMPLETED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
|
||||
];
|
||||
|
||||
/**
|
||||
* Statuses in which an appointment occupies its (doctor, slot_start) — kept
|
||||
* in lockstep with AppointmentRepository::isSlotTaken (a slot is taken only
|
||||
* by a confirmed booking or a still-live pending one). While occupying, the
|
||||
* row carries a non-null, unique active_slot_key so two live bookings on the
|
||||
* same slot cannot coexist even under a race. Every other status (expired,
|
||||
* completed, no_show, cancelled_*) releases the slot → key NULL.
|
||||
*/
|
||||
private const SLOT_OCCUPYING_STATUSES = [
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_CONFIRMED,
|
||||
];
|
||||
|
||||
// Optimistic locking
|
||||
#[ORM\Version]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
@@ -64,6 +77,9 @@ class Appointment
|
||||
#[ORM\Column(type: 'string', length: 30)]
|
||||
private string $status = self::STATUS_PENDING;
|
||||
|
||||
#[ORM\Column(name: 'active_slot_key', type: 'string', length: 64, nullable: true, unique: true)]
|
||||
private ?string $activeSlotKey = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $note = null;
|
||||
|
||||
@@ -106,6 +122,18 @@ class Appointment
|
||||
$this->slotEnd = $slotEnd;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
$this->refreshActiveSlotKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the unique active-slot key from the current status. Non-null
|
||||
* while the appointment occupies the slot; null once it is cancelled.
|
||||
*/
|
||||
private function refreshActiveSlotKey(): void
|
||||
{
|
||||
$this->activeSlotKey = in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
|
||||
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
@@ -160,6 +188,7 @@ class Appointment
|
||||
if ($newStatus !== self::STATUS_PENDING) {
|
||||
$this->expiresAt = null;
|
||||
}
|
||||
$this->refreshActiveSlotKey();
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\OptimisticLockException;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
@@ -30,13 +31,60 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
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();
|
||||
});
|
||||
try {
|
||||
$em->wrapInTransaction(function () use ($em, $appointment): void {
|
||||
$doctor = $appointment->getDoctor();
|
||||
$start = $appointment->getSlotStart();
|
||||
$end = $appointment->getSlotEnd();
|
||||
|
||||
if ($this->isSlotTaken($doctor, $start, $end)) {
|
||||
throw new SlotTakenException();
|
||||
}
|
||||
// Free the unique slot key of any pending booking whose payment
|
||||
// window has lapsed but the expiry cron hasn't run yet, so the
|
||||
// new booking can take the slot.
|
||||
$this->expireLapsedPending($doctor, $start, $end);
|
||||
|
||||
$em->persist($appointment);
|
||||
$em->flush();
|
||||
});
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// Lost the race on the (doctor, slot_start) unique key — the
|
||||
// in-transaction re-check passed for two concurrent requests but
|
||||
// only one INSERT can win.
|
||||
throw new SlotTakenException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire pending bookings overlapping this slot whose payment window has
|
||||
* lapsed, releasing their active_slot_key so the slot can be rebooked.
|
||||
*/
|
||||
private function expireLapsedPending(Doctor $doctor, int $slotStart, int $slotEnd): void
|
||||
{
|
||||
$lapsed = $this->createQueryBuilder('a')
|
||||
->where('a.doctor = :doctor')
|
||||
->andWhere('a.slotStart < :slotEnd')
|
||||
->andWhere('a.slotEnd > :slotStart')
|
||||
->andWhere('a.status = :pending')
|
||||
->andWhere('a.expiresAt IS NOT NULL AND a.expiresAt <= :now')
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('pending', Appointment::STATUS_PENDING)
|
||||
->setParameter('slotStart', $slotStart)
|
||||
->setParameter('slotEnd', $slotEnd)
|
||||
->setParameter('now', time())
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
if ($lapsed === []) {
|
||||
return;
|
||||
}
|
||||
foreach ($lapsed as $appt) {
|
||||
$appt->transitionTo(Appointment::STATUS_EXPIRED);
|
||||
}
|
||||
// Flush the key-releasing UPDATEs before the new INSERT so they don't
|
||||
// collide on the unique key within the same flush.
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
/** Check if a slot is already taken (confirmed or pending) */
|
||||
|
||||
Reference in New Issue
Block a user