mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
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:
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
-- +goose Up
|
||||
-- Hard guarantee against double-booking. The service layer checks for
|
||||
-- collisions before inserting, but check-then-insert is racy under concurrent
|
||||
-- requests; these exclusion constraints enforce the same rules atomically.
|
||||
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||||
|
||||
-- Two appointments may not overlap for the same staff member.
|
||||
ALTER TABLE bookings
|
||||
ADD CONSTRAINT bookings_staff_no_overlap
|
||||
EXCLUDE USING gist (
|
||||
tenant_id WITH =,
|
||||
staff_id WITH =,
|
||||
tstzrange(starts_at, ends_at) WITH &&
|
||||
) WHERE (booking_mode = 'appointment' AND status <> 'cancelled' AND staff_id IS NOT NULL);
|
||||
|
||||
-- Two appointments may not overlap in the same location.
|
||||
ALTER TABLE bookings
|
||||
ADD CONSTRAINT bookings_location_no_overlap
|
||||
EXCLUDE USING gist (
|
||||
tenant_id WITH =,
|
||||
location_id WITH =,
|
||||
tstzrange(starts_at, ends_at) WITH &&
|
||||
) WHERE (booking_mode = 'appointment' AND status <> 'cancelled' AND location_id IS NOT NULL);
|
||||
|
||||
-- Appointments with no staff or location assignment occupy the tenant's
|
||||
-- single implicit resource (mirrors resourcesCollide in the service layer).
|
||||
ALTER TABLE bookings
|
||||
ADD CONSTRAINT bookings_unassigned_no_overlap
|
||||
EXCLUDE USING gist (
|
||||
tenant_id WITH =,
|
||||
tstzrange(starts_at, ends_at) WITH &&
|
||||
) WHERE (booking_mode = 'appointment' AND status <> 'cancelled' AND staff_id IS NULL AND location_id IS NULL);
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_unassigned_no_overlap;
|
||||
ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_location_no_overlap;
|
||||
ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_staff_no_overlap;
|
||||
Reference in new issue
Block a user