Severity: Medium — integrity / potential double-charge.
Problem
BookingEndpoint::book() (src/Booking/BookingEndpoint.php:107-155) checks $slot->isBooked, then later calls insert() + markBooked() with no row lock or transaction. Two concurrent requests for the same slot both pass the check and both book it (and both may be charged).
The insertSeries path is also non-transactional — a partial failure leaves orphaned lesson rows and half-marked slots.
Fix
Use a conditional atomic update (UPDATE ... SET is_booked = 1 WHERE id = ? AND is_booked = 0) and act on the affected-row count to claim the slot before inserting the lesson, or wrap the claim+insert in a DB transaction. Apply the same for the series path.
**Severity: Medium** — integrity / potential double-charge.
## Problem
`BookingEndpoint::book()` ([src/Booking/BookingEndpoint.php:107-155](src/Booking/BookingEndpoint.php#L107-L155)) checks `$slot->isBooked`, then later calls `insert()` + `markBooked()` with no row lock or transaction. Two concurrent requests for the same slot both pass the check and both book it (and both may be charged).
The `insertSeries` path is also non-transactional — a partial failure leaves orphaned lesson rows and half-marked slots.
## Fix
Use a conditional atomic update (`UPDATE ... SET is_booked = 1 WHERE id = ? AND is_booked = 0`) and act on the affected-row count to claim the slot before inserting the lesson, or wrap the claim+insert in a DB transaction. Apply the same for the series path.
Verified resolved on main (061d09e, PR #38): slots are claimed via an atomic guarded update — AvailabilityRepository::claim() runs UPDATE ... SET is_booked = 1 WHERE id = %d AND is_booked = 0 and the lesson is only inserted when the affected-row count confirms this request won the claim. The weekly path claims each occurrence individually and creates lessons only for the slots actually claimed, so a racing request never double-books and no half-marked rows are left behind. Re-confirmed during the 2026-06-10 security review pass.
Verified resolved on main (061d09e, PR #38): slots are claimed via an atomic guarded update — AvailabilityRepository::claim() runs UPDATE ... SET is_booked = 1 WHERE id = %d AND is_booked = 0 and the lesson is only inserted when the affected-row count confirms this request won the claim. The weekly path claims each occurrence individually and creates lessons only for the slots actually claimed, so a racing request never double-books and no half-marked rows are left behind. Re-confirmed during the 2026-06-10 security review pass.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Severity: Medium — integrity / potential double-charge.
Problem
BookingEndpoint::book()(src/Booking/BookingEndpoint.php:107-155) checks$slot->isBooked, then later callsinsert()+markBooked()with no row lock or transaction. Two concurrent requests for the same slot both pass the check and both book it (and both may be charged).The
insertSeriespath is also non-transactional — a partial failure leaves orphaned lesson rows and half-marked slots.Fix
Use a conditional atomic update (
UPDATE ... SET is_booked = 1 WHERE id = ? AND is_booked = 0) and act on the affected-row count to claim the slot before inserting the lesson, or wrap the claim+insert in a DB transaction. Apply the same for the series path.Verified resolved on main (
061d09e, PR #38): slots are claimed via an atomic guarded update — AvailabilityRepository::claim() runs UPDATE ... SET is_booked = 1 WHERE id = %d AND is_booked = 0 and the lesson is only inserted when the affected-row count confirms this request won the claim. The weekly path claims each occurrence individually and creates lessons only for the slots actually claimed, so a racing request never double-books and no half-marked rows are left behind. Re-confirmed during the 2026-06-10 security review pass.