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
+29
View File
@@ -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;
}