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:
@@ -38,7 +38,7 @@ _None outstanding._
|
||||
| # | Task | File:line | Cat | How to test |
|
||||
|---|------|-----------|-----|-------------|
|
||||
| ✅H1 | IDOR write: `createAppointment` trusts request `doctor_uuid`, no scope check — any staff books onto any doctor's calendar | src/Appointment/Controller/MyAppointmentsController.php:59 | security-idor | **DONE** — `canBookForDoctor()` scope gate + `tests/Appointment/BookingScopeTest` |
|
||||
| H2 | No UNIQUE `(doctor_id, slot_start)` on Appointment → double-booking race (index is non-unique) | src/Appointment/Entity/Appointment.php:13 | db-unique | Concurrent POST same doctor+slot → only one persists. ⚠️ check existing dup data before adding constraint |
|
||||
| ✅H2 | No UNIQUE `(doctor_id, slot_start)` on Appointment → double-booking race (index is non-unique) | src/Appointment/Entity/Appointment.php | db-unique | **DONE** — nullable unique `active_slot_key` (occupying = pending/confirmed, mirrors `isSlotTaken`); `bookAtomically` catches the unique violation + expires lapsed pendings in-txn; all 3 booking paths (online/my/admin) routed through it. Migration backfills one row per slot (non-destructive). `tests/Appointment/SlotUniquenessTest`. **NB:** backfill surfaced a real pre-existing double-booked slot in dev data (doctor 1764, two `expired` rows) — harmless (both expired = key NULL). |
|
||||
| H3 | `Payment.referenceId` not unique → same gateway callback credited twice | src/Payment/Entity/Payment.php:61-62 | db-unique | Persist two Payments same reference_id → 2nd rejected. (pairs w/ C1) |
|
||||
| H4 | `FinancialBreakdown.payment` onDelete CASCADE on non-nullable FK → deleting a Payment destroys ledger rows; should be RESTRICT | src/Settlement/Entity/FinancialBreakdown.php:28-30 | db-ondelete | Delete a Payment w/ breakdown → expect FK restrict error, ledger preserved |
|
||||
| H5 | Insurance pricing/coverage modeled as raw int FKs (no FK/onDelete) → orphan rows on delete: `EntityInsurancePricing.entity_id/insurance_id`, `TenantInsurance.entity_id/insurance_id`, `TenantServiceCoverage.tenant_insurance_id` | src/Insurance/Entity/EntityInsurancePricing.php:25-29 · TenantInsurance.php:29,32 · TenantServiceCoverage.php:23-24 | db-ondelete | Delete insurance/tenant → children removed or restricted, no dangling rows |
|
||||
@@ -105,6 +105,7 @@ _None outstanding._
|
||||
| E2 | **No DTO/validator on sensitive writes** — admin create, auth flows, payment verify, booking read raw `json_decode` arrays | Admin, Auth, Payment, Appointment, Settlement controllers | Introduce request DTOs + validator incrementally. Large. |
|
||||
| E3 | **Fat controllers** — AdminApiController (1938 LOC), MyAppointmentsController booking, RepresentationActionController (835), DoctorController/ClinicController | extract per-domain Services | SOLID refactor; lower urgency than security/db. |
|
||||
| E4 | **CI** — no `.github/workflows`; add phpunit + phpstan + migrate-on-empty-db | devops | prompt var §DevOps |
|
||||
| E5 | **phpstan baseline dirty** — 41 pre-existing errors across the codebase (D9 only repaired the config so it *runs*). Audit fixes must not add new ones; cleaning the 41 is its own task. | devops | `ddev exec php vendor/bin/phpstan analyse` → 41 errors (e.g. SlotCalculatorService.php:233, SubscriptionController.php:33) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user