63 lines
3.0 KiB
SQL
63 lines
3.0 KiB
SQL
-- AK-P16b · P16 Booking — HARD DOUBLE-BOOKING GUARD (NOT in the Prisma schema)
|
|
-- =============================================================================
|
|
-- SHIPPED: since 20260717090000_ak_p16b_booking_overlap_constraint this
|
|
-- constraint is part of the tracked migration chain (that migration is the
|
|
-- authoritative copy, incl. a pre-flight overlap check). This file remains the
|
|
-- feature-branch template for `psql -f` on db-push-only databases.
|
|
--
|
|
-- The race-protection backstop for DoD 2: a PostgreSQL EXCLUDE USING gist
|
|
-- constraint that makes it physically impossible to persist two OVERLAPPING active
|
|
-- appointments for the SAME provider WITHIN A TENANT (tenant-scoped like the
|
|
-- slot engine; tenant-agnostic would leak cross-tenant busy state). This cannot be expressed in the Prisma schema
|
|
-- (Prisma has no exclusion-constraint primitive), so it stays raw SQL.
|
|
--
|
|
-- This is the second layer. The first layer is the application Serializable
|
|
-- transaction in BookingSubmitService that re-checks slot availability and returns
|
|
-- Klartext alternatives; this constraint guarantees correctness even under a lost
|
|
-- race / concurrent writer, surfacing as a unique-violation the submit path maps to
|
|
-- the same "slot just taken" Klartext.
|
|
--
|
|
-- `held` counts as occupying (a past appointment that took place still blocks its
|
|
-- slot for history integrity). `cancelled` / `no_show` / `rescheduled` free the slot.
|
|
--
|
|
-- Forward-only (DoD 28). Idempotent + DO-guarded (reproducible from an empty DB,
|
|
-- P3018-safe — order-independent except for the extension, which is IF NOT EXISTS).
|
|
--
|
|
-- Inverse SQL:
|
|
-- ALTER TABLE "Appointment" DROP CONSTRAINT IF EXISTS appointment_no_overlap;
|
|
-- -- (leave btree_gist installed; other modules may rely on it)
|
|
-- =============================================================================
|
|
|
|
-- gist over a scalar (providerUserId) + a range (tsrange) needs the btree_gist
|
|
-- extension to put the equality column into the same gist index.
|
|
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
|
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint WHERE conname = 'appointment_no_overlap'
|
|
) THEN
|
|
-- One active appointment per provider per time range. The half-open range
|
|
-- [startAt, endAt) means back-to-back appointments (end == next start) do NOT
|
|
-- conflict. Partial WHERE so only occupying statuses participate.
|
|
EXECUTE '
|
|
ALTER TABLE "Appointment"
|
|
ADD CONSTRAINT appointment_no_overlap
|
|
EXCLUDE USING gist (
|
|
"tenantId" WITH =,
|
|
"providerUserId" WITH =,
|
|
tsrange("startAt", "endAt", ''[)'') WITH &&
|
|
)
|
|
WHERE (status IN (''scheduled'', ''held''))
|
|
';
|
|
END IF;
|
|
END
|
|
$$;
|
|
|
|
-- =============================================================================
|
|
-- Verify (after `prisma db push`):
|
|
-- psql "$DIRECT_URL" -f prisma/rls/booking-constraints.sql
|
|
-- -- then attempt two overlapping inserts for one providerUserId → 2nd raises
|
|
-- -- 23P01 exclusion_violation (mapped to Klartext "slot just taken").
|
|
-- =============================================================================
|