Files
clinicpro/migrations/Version20260628152738.php
T
hamedandClaude Opus 4.8 aa87b4a9cb 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>
2026-06-28 19:04:54 +03:30

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');
}
}