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:
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260628152738 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add unique active_slot_key to appointments to prevent double-booking a slot';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE appointments ADD active_slot_key VARCHAR(64) DEFAULT NULL');
|
||||
// Backfill slot-occupying appointments (pending/confirmed only — matches
|
||||
// isSlotTaken) so the unique index protects existing rows too. NULLs do
|
||||
// not collide in a MySQL/MariaDB unique index, so released slots are fine.
|
||||
// Assign the key to at most one row per (doctor, slot) — the latest id —
|
||||
// so the index builds even where dirty historical data already
|
||||
// double-booked a slot, without destructively cancelling any booking.
|
||||
$this->addSql(<<<SQL
|
||||
UPDATE appointments a
|
||||
JOIN (
|
||||
SELECT MAX(id) AS keep_id
|
||||
FROM appointments
|
||||
WHERE status IN ('pending','confirmed')
|
||||
GROUP BY doctor_id, slot_start
|
||||
) k ON a.id = k.keep_id
|
||||
SET a.active_slot_key = CONCAT(a.doctor_id, ':', a.slot_start)
|
||||
SQL);
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6A41727AE228889B ON appointments (active_slot_key)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('DROP INDEX UNIQ_6A41727AE228889B ON appointments');
|
||||
$this->addSql('ALTER TABLE appointments DROP active_slot_key');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user