Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/ccasro/hub/llms.txt

Use this file to discover all available pages before exploring further.

The Hub platform uses PostgreSQL with PostGIS extensions for geographic queries. Database migrations are managed with Flyway.

Database Technology Stack

Database

PostgreSQL 15+

Extensions

PostGIS for geographic queries

Migrations

Flyway for version control

ORM

Spring Data JPA + Hibernate

PostgreSQL Extensions

Location: Migration V1__init.sql
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS postgis;       -- Geographic queries
CREATE EXTENSION IF NOT EXISTS pgcrypto;      -- UUID generation
CREATE EXTENSION IF NOT EXISTS btree_gist;    -- Exclusion constraints

PostGIS Usage

PostGIS enables geographic operations:
  • Venue location storage - geography(Point, 4326) for lat/lng
  • Radius search - Find venues within distance
  • City lookup - Pre-populated Spanish cities

Migration Structure

Flyway migrations are versioned SQL files:
Location: backend/src/main/resources/db/migration/
FileDescription
V1__init.sqlBase schema with extensions and city data
V2__users.sqlUser profile table
V3__create_venue.sqlVenue and venue images
V4__create_resource.sqlResources, schedules, pricing, images
V5__create_booking.sqlBookings with overlap prevention
V6__create_payment.sqlPayment tracking
V7__create_match_request.sqlMatch requests, players, invitations
V8__seed_dev_data.sqlDevelopment test data
Naming Convention:
  • V{version}__{description}.sql - Versioned migrations
  • Executed in order, tracked in flyway_schema_history table
  • Never modify existing migrations in production

Core Tables

User Profile

Purpose: User accounts and authentication
CREATE TABLE user_profile (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    auth0_id       VARCHAR(128) UNIQUE NOT NULL,  -- External auth
    email          VARCHAR(255) UNIQUE,
    email_verified BOOLEAN DEFAULT FALSE,
    
    -- Profile information
    display_name   VARCHAR(100),
    description    TEXT,
    phone_number   VARCHAR(20),
    
    -- Avatar
    avatar_url     VARCHAR(500),
    avatar_public_id VARCHAR(200),
    
    -- Role management
    role VARCHAR(20) NOT NULL DEFAULT 'PLAYER'
        CHECK (role IN ('PLAYER', 'OWNER', 'ADMIN')),
    owner_request_status VARCHAR(20) DEFAULT NULL,
    
    -- Sports preferences
    preferred_sport VARCHAR(50)
        CHECK (preferred_sport IN ('PADEL', 'TENNIS', 'SQUASH', 'BADMINTON')),
    skill_level VARCHAR(20)
        CHECK (skill_level IN ('BEGINNER', 'INTERMEDIATE', 'ADVANCED')),
    
    -- Location
    city         VARCHAR(100),
    country_code VARCHAR(3),
    
    -- Status
    active BOOLEAN DEFAULT TRUE,
    onboarding_completed BOOLEAN DEFAULT FALSE,
    
    -- Match system
    match_notifications_enabled BOOLEAN NOT NULL DEFAULT FALSE,
    match_search_radius_km      INTEGER NOT NULL DEFAULT 10,
    city_id                     BIGINT REFERENCES city(id),
    no_show_count               INTEGER NOT NULL DEFAULT 0,
    match_banned_until          TIMESTAMPTZ,
    last_match_cancelled_at     TIMESTAMPTZ,
    
    -- Timestamps
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_login_at TIMESTAMPTZ
);
Key Features:
  • Auth0 integration via auth0_id
  • Role-based access control
  • Match reliability tracking (no-show count, bans)
  • Geographic preferences for match finding

Venue

Purpose: Padel venue management
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);
Key Features:
  • PostGIS location for geographic search
  • Cascade delete for images when venue deleted
  • Status-based filtering for public visibility

Resource

Booking

Purpose: Court bookings with payment tracking
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
  • Partial indexes for efficient expiration queries
  • Only active bookings checked for overlap

Payment

Purpose: Payment transaction tracking
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;
Key Features:
  • Payment intent tracking
  • One payment per booking
  • Nullable player_id for match split payments

Match Request

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);
Key Features:
  • Invitation token for public join links
  • Geographic search center stored as lat/lng
  • Team and role tracking for match organization
  • Check-in and absence reporting
  • Cascade delete players/invitations with match

City Reference Data

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);
Key Features:
  • Spanish cities pre-loaded in V1 migration
  • PostGIS geography for distance calculations
  • Used for user location and match search

Database Constraints

Referential Integrity

-- Venue ownership
ALTER TABLE venue 
    ADD FOREIGN KEY (owner_id) 
    REFERENCES user_profile(id) ON DELETE RESTRICT;

-- Resource belongs to venue
ALTER TABLE resource 
    ADD FOREIGN KEY (venue_id) 
    REFERENCES venue(id) ON DELETE CASCADE;

-- Booking references
ALTER TABLE booking
    ADD FOREIGN KEY (resource_id) REFERENCES resource(id),
    ADD FOREIGN KEY (player_id) REFERENCES user_profile(id);

Exclusion Constraints

Booking Overlap Prevention:
-- Prevents double-booking using temporal overlap
ALTER TABLE booking
  ADD CONSTRAINT booking_no_overlap
  EXCLUDE USING gist (
    resource_id WITH =,           -- Same resource
    tsrange(                      -- Time range
      booking_date::timestamp + start_time,
      booking_date::timestamp + end_time,
      '[)'                        -- Half-open interval
    ) WITH &&                     -- Overlaps operator
  )
  WHERE (status IN ('PENDING_PAYMENT', 'CONFIRMED', 'PENDING_MATCH'));
This constraint requires the btree_gist extension and ensures no two active bookings overlap for the same resource.

Indexes

Performance Indexes

User Profile:
-- Fast auth lookup
CREATE UNIQUE INDEX idx_user_auth0_id ON user_profile(auth0_id);
Venue:
-- Owner's venues
CREATE INDEX idx_venue_owner_id ON venue(owner_id);
-- Public venue filtering
CREATE INDEX idx_venue_status ON venue(status);
-- Geographic search
CREATE INDEX idx_venue_location ON venue USING GIST(location);
Resource:
-- Venue's resources
CREATE INDEX idx_resource_venue_id ON resource(venue_id);
-- Public resource filtering
CREATE INDEX idx_resource_status ON resource(status);
-- Combined venue + status
CREATE INDEX idx_resource_venue_id_status ON resource(venue_id, status);
Booking:
-- Availability queries
CREATE INDEX idx_booking_resource_date ON booking(resource_id, booking_date);
-- User booking history
CREATE 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 matches
CREATE INDEX idx_match_request_organizer ON match_request(organizer_id);
-- Token lookup
CREATE 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';

Geographic Indexes

PostGIS uses GiST indexes for spatial queries:
-- Venue location search
CREATE INDEX idx_venue_location ON venue USING GIST(location);

-- City distance calculations
CREATE INDEX idx_city_location ON city USING GIST(location);
Usage Example:
-- Find venues within 10km of Madrid center
SELECT * FROM venue
WHERE ST_DWithin(
    location,
    ST_SetSRID(ST_MakePoint(-3.7038, 40.4168), 4326)::geography,
    10000  -- meters
)
AND status = 'ACTIVE';

JPA Entity Mapping

Booking Entity:
@Entity
@Table(name = "booking")
public class BookingEntity {
    @Id
    private UUID id;
    
    @Column(name = "resource_id", nullable = false)
    private UUID resourceId;
    
    @Column(name = "player_id", nullable = false)
    private UUID playerId;
    
    @Column(name = "booking_date", nullable = false)
    private LocalDate bookingDate;
    
    @Column(name = "start_time", nullable = false)
    private LocalTime startTime;
    
    @Column(name = "end_time", nullable = false)
    private LocalTime endTime;
    
    @Column(name = "price_paid", nullable = false)
    private BigDecimal pricePaid;
    
    @Column(length = 3, nullable = false)
    private String currency;
    
    @Enumerated(EnumType.STRING)
    @Column(length = 20, nullable = false)
    private BookingStatusDb status;
    
    @Enumerated(EnumType.STRING)
    @Column(name = "payment_status", length = 20, nullable = false)
    private PaymentStatusDb paymentStatus;
    
    @Column(name = "cancelled_at")
    private Instant cancelledAt;
    
    @Column(name = "cancel_reason")
    private String cancelReason;
    
    @Column(name = "created_at", nullable = false)
    private Instant createdAt;
    
    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;
    
    @Column(name = "expires_at")
    private Instant expiresAt;
}
Venue Entity with PostGIS:
@Entity
@Table(name = "venue")
public class VenueEntity {
    @Id
    private UUID id;
    
    @Column(name = "owner_id", nullable = false)
    private UUID ownerId;
    
    @Column(length = 150, nullable = false)
    private String name;
    
    @Column(columnDefinition = "TEXT")
    private String description;
    
    private String street;
    private String city;
    private String country;
    
    @Column(name = "postal_code", length = 20)
    private String postalCode;
    
    // PostGIS geography type
    @Column(columnDefinition = "geography(Point,4326)")
    private Point location;
    
    @Enumerated(EnumType.STRING)
    @Column(length = 30, nullable = false)
    private VenueStatusDb status;
    
    @Column(name = "reject_reason", columnDefinition = "TEXT")
    private String rejectReason;
    
    @Column(name = "created_at", nullable = false)
    private Instant createdAt;
    
    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;
    
    // One-to-many relationship (not used in domain, for JPA queries)
    @OneToMany(mappedBy = "venue", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<VenueImageEntity> images = new ArrayList<>();
}

Data Types

Common Types

Domain ConceptPostgreSQL TypeJava Type
IDUUIDUUID
TimestampTIMESTAMPTZInstant
DateDATELocalDate
TimeTIMELocalTime
MoneyDECIMAL(8,2)BigDecimal
TextTEXT / VARCHAR(n)String
BooleanBOOLEANboolean
EnumVARCHAR(n) + CHECKenum
Locationgeography(Point,4326)Point (JTS)

Geographic Types

PostGIS Geography:
-- WGS84 latitude/longitude
location geography(Point, 4326)
Java Mapping (Hibernate Spatial):
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Coordinate;

GeometryFactory gf = new GeometryFactory();
Point point = gf.createPoint(new Coordinate(longitude, latitude));

Query Patterns

Availability Check

-- Find available slots for a resource on a date
SELECT start_time, end_time
FROM resource_schedule rs
WHERE 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 venues within radius
SELECT v.*, ST_Distance(v.location, :searchPoint::geography) AS distance
FROM venue v
WHERE v.status = 'ACTIVE'
  AND ST_DWithin(v.location, :searchPoint::geography, :radiusMeters)
ORDER BY distance;

Match Eligible Players

-- Find players for match invitations
SELECT up.*
FROM user_profile up
WHERE 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
  );

Transaction Management

Transactions are managed at the use case level:
@Service
@Transactional  // All methods transactional by default
public class CreateBookingService {
    
    @Transactional  // Explicit for clarity
    public BookingId execute(CreateBookingCommand cmd) {
        // 1. Validate resource availability
        // 2. Create booking
        // 3. Create payment
        // All-or-nothing transaction
    }
    
    @Transactional(readOnly = true)  // Optimization for queries
    public List<Booking> findUserBookings(UserId userId) {
        return bookingRepository.findByPlayerId(userId);
    }
}

Database Configuration

Application Properties:
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/hub_db
    username: hub_user
    password: ${DB_PASSWORD}
  jpa:
    hibernate:
      ddl-auto: validate  # Flyway manages schema
    properties:
      hibernate:
        dialect: org.hibernate.spatial.dialect.postgis.PostgisPG95Dialect
  flyway:
    enabled: true
    baseline-on-migrate: true
    locations: classpath:db/migration

Backend Modules

Module architecture and structure

Domain Model

Domain entities and value objects

Build docs developers (and LLMs) love