-- Enable required extensionsCREATE EXTENSION IF NOT EXISTS postgis; -- Geographic queriesCREATE EXTENSION IF NOT EXISTS pgcrypto; -- UUID generationCREATE EXTENSION IF NOT EXISTS btree_gist; -- Exclusion constraints
CREATE TABLE venue ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner_id UUID NOT NULL REFERENCES user_profile(id) ON DELETE RESTRICT, name VARCHAR(150) NOT NULL, description TEXT, -- Address street VARCHAR(200), city VARCHAR(100), country VARCHAR(100), postal_code VARCHAR(20), -- Geographic location for search location geography(Point, 4326), -- Status status VARCHAR(30) NOT NULL DEFAULT 'PENDING_REVIEW', reject_reason TEXT, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX idx_venue_owner_id ON venue(owner_id);CREATE INDEX idx_venue_status ON venue(status);CREATE INDEX idx_venue_location ON venue USING GIST(location);
Venue Images:
CREATE TABLE venue_image ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), venue_id UUID NOT NULL REFERENCES venue(id) ON DELETE CASCADE, url VARCHAR(500) NOT NULL, public_id VARCHAR(200) NOT NULL, -- Cloudinary ID for deletion display_order INT DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX idx_venue_image_venue_id ON venue_image(venue_id);
CREATE TABLE resource ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), venue_id UUID NOT NULL REFERENCES venue(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, description TEXT, resource_type VARCHAR(30) NOT NULL, slot_duration INT NOT NULL, -- Minutes per booking slot status VARCHAR(30) NOT NULL DEFAULT 'PENDING_REVIEW', reject_reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX idx_resource_venue_id ON resource(venue_id);CREATE INDEX idx_resource_status ON resource(status);CREATE INDEX idx_resource_venue_id_status ON resource (venue_id, status);
Resource Schedules (operating hours):
CREATE TABLE resource_schedule ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), resource_id UUID NOT NULL REFERENCES resource(id) ON DELETE CASCADE, day_of_week VARCHAR(10) NOT NULL, opening_time TIME NOT NULL, closing_time TIME NOT NULL);CREATE UNIQUE INDEX ux_resource_schedule_unique_slot ON resource_schedule(resource_id, day_of_week, opening_time, closing_time);CREATE INDEX idx_schedule_resource_id ON resource_schedule(resource_id);
Price Rules (dynamic pricing):
CREATE TABLE resource_price_rule ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), resource_id UUID NOT NULL REFERENCES resource(id) ON DELETE CASCADE, day_type VARCHAR(20) NOT NULL, -- WEEKDAY, WEEKEND, specific days start_time TIME NOT NULL, end_time TIME NOT NULL, price DECIMAL(8,2) NOT NULL, currency VARCHAR(3) NOT NULL DEFAULT 'EUR');CREATE INDEX idx_price_rule_resource_id ON resource_price_rule(resource_id);
Resource Images:
CREATE TABLE resource_image ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), resource_id UUID NOT NULL REFERENCES resource(id) ON DELETE CASCADE, url VARCHAR(500) NOT NULL, public_id VARCHAR(200) NOT NULL, display_order INT DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX idx_resource_image_resource_id ON resource_image(resource_id);
Key Features:
One-to-many schedules for flexible operating hours
CREATE TABLE booking ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), resource_id UUID NOT NULL REFERENCES resource(id), player_id UUID NOT NULL REFERENCES user_profile(id), booking_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, price_paid DECIMAL(8,2) NOT NULL, currency VARCHAR(3) NOT NULL DEFAULT 'EUR', -- Status tracking status VARCHAR(20) NOT NULL DEFAULT 'CONFIRMED', payment_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- Cancellation cancelled_at TIMESTAMPTZ, cancel_reason TEXT, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ, -- Payment hold expiration CONSTRAINT booking_time_order CHECK (end_time > start_time));
Overlap Prevention (critical constraint):
ALTER TABLE booking ADD CONSTRAINT booking_no_overlap EXCLUDE USING gist ( resource_id WITH =, tsrange( booking_date::timestamp + start_time, booking_date::timestamp + end_time, '[)' ) WITH && ) WHERE (status IN ('PENDING_PAYMENT', 'CONFIRMED', 'PENDING_MATCH'));
Indexes:
CREATE INDEX idx_booking_resource_date ON booking (resource_id, booking_date);CREATE INDEX idx_booking_player_date ON booking (player_id, booking_date);CREATE INDEX idx_booking_pending_expires ON booking (expires_at) WHERE status = 'PENDING_PAYMENT';CREATE INDEX idx_booking_pending_match_expires ON booking (expires_at) WHERE status = 'PENDING_MATCH';
Key Features:
Exclusion constraint prevents double-booking
Time range overlap detection using tsrange and && operator
CREATE TABLE payment ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), booking_id UUID NOT NULL REFERENCES booking(id), player_id UUID REFERENCES user_profile(id), stripe_payment_intent_id VARCHAR(100) UNIQUE NOT NULL, amount DECIMAL(8,2) NOT NULL, currency VARCHAR(3) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'PENDING', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX idx_payment_player_id ON payment(player_id) WHERE player_id IS NOT NULL;
match_request, match_player, and match_invitation Tables
Purpose: Match finding and organizationMatch Requests:
CREATE TABLE match_request ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organizer_id UUID NOT NULL REFERENCES user_profile(id), resource_id UUID NOT NULL REFERENCES resource(id), booking_date DATE NOT NULL, start_time TIME NOT NULL, slot_duration_minutes INTEGER NOT NULL, -- Match configuration format VARCHAR(20) NOT NULL, -- SINGLES, DOUBLES skill_level VARCHAR(20) NOT NULL, custom_message TEXT, -- Invitation invitation_token UUID NOT NULL UNIQUE, -- Geographic search search_lat DOUBLE PRECISION NOT NULL, search_lng DOUBLE PRECISION NOT NULL, search_radius_km DOUBLE PRECISION NOT NULL, -- Pricing price_per_player DECIMAL(10,2), -- Status status VARCHAR(30) NOT NULL DEFAULT 'OPEN', expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX idx_match_request_organizer ON match_request(organizer_id);CREATE INDEX idx_match_request_token ON match_request(invitation_token);CREATE INDEX idx_match_request_status ON match_request(status) WHERE status = 'OPEN';
Match Players:
CREATE TABLE match_player ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), match_request_id UUID NOT NULL REFERENCES match_request(id) ON DELETE CASCADE, player_id UUID NOT NULL REFERENCES user_profile(id), team VARCHAR(10) NOT NULL, -- TEAM_A, TEAM_B role VARCHAR(20) NOT NULL, -- ORGANIZER, GUEST joined_at TIMESTAMPTZ NOT NULL, absence_reported BOOLEAN NOT NULL DEFAULT FALSE, checked_in BOOLEAN NOT NULL DEFAULT FALSE, checked_in_at TIMESTAMPTZ, CONSTRAINT match_player_unique UNIQUE (match_request_id, player_id));CREATE INDEX idx_match_player_request ON match_player(match_request_id);
Match Invitations:
CREATE TABLE match_invitation ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), match_request_id UUID NOT NULL REFERENCES match_request(id), player_id UUID NOT NULL REFERENCES user_profile(id), player_email VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'PENDING', free_substitute BOOLEAN NOT NULL DEFAULT FALSE, sent_at TIMESTAMPTZ NOT NULL, responded_at TIMESTAMPTZ, UNIQUE (match_request_id, player_id));CREATE INDEX idx_invitation_player_id ON match_invitation(player_id);CREATE INDEX idx_invitation_match_request ON match_invitation(match_request_id);
Purpose: Pre-populated city data for location selection
CREATE TABLE city ( id BIGSERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, country_code VARCHAR(3) NOT NULL DEFAULT 'ES', latitude DOUBLE PRECISION NOT NULL, longitude DOUBLE PRECISION NOT NULL, location geography(Point, 4326) NOT NULL);CREATE INDEX idx_city_country ON city(country_code);CREATE INDEX idx_city_location ON city USING GIST(location);
-- Fast auth lookupCREATE UNIQUE INDEX idx_user_auth0_id ON user_profile(auth0_id);
Venue:
-- Owner's venuesCREATE INDEX idx_venue_owner_id ON venue(owner_id);-- Public venue filteringCREATE INDEX idx_venue_status ON venue(status);-- Geographic searchCREATE INDEX idx_venue_location ON venue USING GIST(location);
Resource:
-- Venue's resourcesCREATE INDEX idx_resource_venue_id ON resource(venue_id);-- Public resource filteringCREATE INDEX idx_resource_status ON resource(status);-- Combined venue + statusCREATE INDEX idx_resource_venue_id_status ON resource(venue_id, status);
Booking:
-- Availability queriesCREATE INDEX idx_booking_resource_date ON booking(resource_id, booking_date);-- User booking historyCREATE INDEX idx_booking_player_date ON booking(player_id, booking_date);-- Expiration job (partial indexes)CREATE INDEX idx_booking_pending_expires ON booking(expires_at) WHERE status = 'PENDING_PAYMENT';CREATE INDEX idx_booking_pending_match_expires ON booking(expires_at) WHERE status = 'PENDING_MATCH';
Match Request:
-- User's matchesCREATE INDEX idx_match_request_organizer ON match_request(organizer_id);-- Token lookupCREATE INDEX idx_match_request_token ON match_request(invitation_token);-- Open match filtering (partial)CREATE INDEX idx_match_request_status ON match_request(status) WHERE status = 'OPEN';
-- Venue location searchCREATE INDEX idx_venue_location ON venue USING GIST(location);-- City distance calculationsCREATE INDEX idx_city_location ON city USING GIST(location);
Usage Example:
-- Find venues within 10km of Madrid centerSELECT * FROM venueWHERE ST_DWithin( location, ST_SetSRID(ST_MakePoint(-3.7038, 40.4168), 4326)::geography, 10000 -- meters)AND status = 'ACTIVE';
-- Find available slots for a resource on a dateSELECT start_time, end_timeFROM resource_schedule rsWHERE rs.resource_id = :resourceId AND rs.day_of_week = :dayOfWeek AND NOT EXISTS ( SELECT 1 FROM booking b WHERE b.resource_id = :resourceId AND b.booking_date = :date AND b.status IN ('CONFIRMED', 'PENDING_PAYMENT', 'PENDING_MATCH') AND tsrange( :date::timestamp + :startTime, :date::timestamp + :endTime, '[)' ) && tsrange( b.booking_date::timestamp + b.start_time, b.booking_date::timestamp + b.end_time, '[)' ) );
-- Find players for match invitationsSELECT up.*FROM user_profile upWHERE up.active = true AND up.match_notifications_enabled = true AND up.skill_level = :skillLevel AND up.preferred_sport = :sport AND up.match_banned_until IS NULL OR up.match_banned_until < NOW() AND ST_DWithin( (SELECT location FROM city WHERE id = up.city_id), ST_SetSRID(ST_MakePoint(:searchLng, :searchLat), 4326)::geography, :radiusMeters ) AND up.id NOT IN ( SELECT player_id FROM match_player WHERE match_request_id = :matchId );