fix(bookings): enforce no-overlap atomically via exclusion constraints

Check-then-insert left a race: two concurrent requests for the same
slot could both confirm. Migration 00014 adds three partial btree_gist
exclusion constraints mirroring resourcesCollide semantics (same
staff, same location, both-unassigned), excluding cancelled rows and
class bookings. SQLSTATE 23P01 maps to ErrBookingConflict at all three
write paths so a lost race returns a clean 409.

Validated on a local Postgres 17 container: constraint matrix at SQL
level, repo-level 23P01 detection, concurrent burst yielding exactly
one confirmed booking.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 13:27:47 +02:00
1 parent e4d95bf504
commit 9f92a2026f
6 files changed
+63

No files matched your search

@@ -187,6 +187,9 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
}
if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil {
if db.IsExclusionViolation(err) {
return ErrBookingConflict
}
return err
}
@@ -341,6 +341,9 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
Notes: notes,
})
if err != nil {
if db.IsExclusionViolation(err) {
return domain.CreateBookingResponse{}, ErrBookingConflict
}
return domain.CreateBookingResponse{}, err
}
+3
View File
@@ -1000,6 +1000,9 @@ func (s *Service) CreateBooking(ctx context.Context, principal domain.Principal,
Notes: strings.TrimSpace(req.Notes),
})
if err != nil {
if db.IsExclusionViolation(err) {
return domain.UpcomingBooking{}, ErrBookingConflict
}
return domain.UpcomingBooking{}, err
}
+11
View File
@@ -2,7 +2,10 @@ package db
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
@@ -232,3 +235,11 @@ func (r *PGRepository) DeletePendingReminderJobsForBooking(ctx context.Context,
`, bookingID)
return err
}
// IsExclusionViolation reports whether err is a PostgreSQL exclusion
// constraint violation (SQLSTATE 23P01). The bookings_*_no_overlap
// constraints raise it when a concurrent request wins the race for a slot.
func IsExclusionViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23P01"
}