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>
49 lines
1.8 KiB
PHP
49 lines
1.8 KiB
PHP
<?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');
|
|
}
|
|
}
|