From 9f92a2026f331a2d19ca6d76b97246c91ad3e043 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 13:27:47 +0200 Subject: [PATCH] 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 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal/bookings/customer_service.go | 3 ++ apps/backend/internal/bookings/service.go | 3 ++ apps/backend/internal/catalog/service.go | 3 ++ apps/backend/internal/db/bookings.go | 11 ++++++ .../migrations/00014_booking_no_overlap.sql | 37 +++++++++++++++++++ project.md | 6 +++ 6 files changed, 63 insertions(+) create mode 100644 apps/backend/migrations/00014_booking_no_overlap.sql diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index 6679193..3bc02d6 100644 --- a/apps/backend/internal/bookings/customer_service.go +++ b/apps/backend/internal/bookings/customer_service.go @@ -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 } diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 6fc9196..a53cad1 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -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 } diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index 21a7858..d7b0962 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -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 } diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index c66105f..88549a0 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -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" +} diff --git a/apps/backend/migrations/00014_booking_no_overlap.sql b/apps/backend/migrations/00014_booking_no_overlap.sql new file mode 100644 index 0000000..aa55dbd --- /dev/null +++ b/apps/backend/migrations/00014_booking_no_overlap.sql @@ -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; diff --git a/project.md b/project.md index 756880a..b7a5e84 100644 --- a/project.md +++ b/project.md @@ -121,6 +121,12 @@ - capacity states read honestly: spots-left in success/warning tones, `Obsazeno`/`Full` when empty, and the action switches to `Na čekací listinu`/`Join waitlist` (previously a full session still said "Book now" while silently producing a waitlisted booking) - reminder arming extracted into a shared `armReminderJobs` helper (email + opt-in SMS) used by booking creation and waitlist promotion — a booking promoted after its reminder already dispatched-as-skipped now gets re-armed instead of going unreminded - remaining: live staging validation +- `[done]` Milestone 19: Double-booking race hardening (validated on local Postgres) + - migration `00014_booking_no_overlap.sql`: `btree_gist` extension plus three partial exclusion constraints on `bookings` mirroring `resourcesCollide` semantics — same staff, same location, or both-unassigned appointments may not overlap (`tstzrange`, `[)` bounds so abutting slots are legal); `cancelled` rows and `booking_mode='class'` are excluded + - `db.IsExclusionViolation` maps SQLSTATE 23P01 to `ErrBookingConflict` at all three write paths (public create, customer reschedule, dashboard manual create) — a lost race now surfaces as a proper 409 instead of a silent double-booking + - validated end-to-end against a local `postgres:17` container: SQL-level matrix (staff/location/unassigned overlap blocked, abutting allowed, cancel frees the slot), repo-level 23P01 detection, and a 10-request concurrent burst on one slot producing exactly one 201 + 409s for the rest; down/up migration roundtrip clean + - local test container: `docker run -d --name bookra-pg-local -p 5439:5432 -e POSTGRES_PASSWORD=bookra-local postgres:17-alpine`, migrations via `npm run db:migrate:up` with `BOOKRA_DATABASE_DIRECT_URL` pointed at it + - remaining: live staging validation on Neon ## Overview Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.