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:
hamed
2026-06-28 19:04:54 +03:30
co-authored by Claude Opus 4.8
parent c084571bf0
commit aa87b4a9cb
10 changed files with 343 additions and 38 deletions
@@ -25,7 +25,9 @@ class AppointmentExpiryServiceTest extends ApiTestCase
$appointments = [];
for ($i = 0; $i < 5; $i++) {
$patient = $this->createUser(['ROLE_USER']);
$appt = new Appointment($doctor, $patient, $past, $past + 900);
// distinct past slots — one live booking per (doctor, slot)
$slotStart = $past - $i * 1000;
$appt = new Appointment($doctor, $patient, $slotStart, $slotStart + 900);
$this->em->persist($appt);
$payment = new Payment($patient, 100_000, 'mellat', 'appointment');