Skip to main content

Overview

BoxApp uses PostgreSQL via Supabase with a normalized relational schema. All tables include tenant isolation via box_id foreign keys and are protected by Row-Level Security (RLS) policies.

Core Tables

boxes

Stores tenant (gym) configurations:
CREATE TABLE boxes (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL,
    slug TEXT NOT NULL UNIQUE,
    subscription_status subscription_status_type NOT NULL DEFAULT 'trial',
    logo_url TEXT,
    favicon_url TEXT,
    login_background_url TEXT,
    theme_config JSONB,
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);
Key Fields:
  • slug: Unique identifier for subdomain routing (e.g., crossfitbox)
  • subscription_status: Enum: trial, active, suspended, cancelled
  • theme_config: JSONB storing primary color, radius, design style
The slug must be globally unique across all tenants. It’s used for subdomain resolution in production.

profiles

Extends Supabase Auth users with application-specific data:
CREATE TABLE profiles (
    id UUID PRIMARY KEY REFERENCES auth.users(id),
    box_id UUID REFERENCES boxes(id),
    role_id TEXT REFERENCES roles(id),
    email TEXT,
    full_name TEXT,
    avatar_url TEXT,
    phone TEXT,
    emergency_contact TEXT,
    emergency_phone TEXT,
    date_of_birth DATE,
    address TEXT,
    force_password_change BOOLEAN DEFAULT false,
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);
Key Relationships:
  • Links to auth.users (Supabase Auth)
  • Scoped to a box_id (tenant)
  • Has a role_id for RBAC
The force_password_change flag forces users to reset password on next login. Used for admin password resets.

roles

Defines available user roles:
CREATE TABLE roles (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    description TEXT,
    created_at TIMESTAMPTZ DEFAULT now()
);
Standard Roles:
  • admin: Full access to box management
  • coach: Can manage WODs, members, competitions
  • receptionist: Can manage members, leads, billing
  • athlete: Standard member with limited access
Roles are system-wide and shared across tenants. They’re read-only for regular users.

Member Management

leads

Tracks potential members:
CREATE TABLE leads (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID NOT NULL REFERENCES boxes(id),
    name TEXT NOT NULL,
    email TEXT,
    phone TEXT,
    source TEXT,
    status TEXT,
    notes TEXT,
    assigned_to UUID REFERENCES profiles(id),
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);
Access Control:
  • Admins and receptionists can create/update
  • Coaches can view only

plans

Defines membership plans:
CREATE TABLE plans (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID NOT NULL REFERENCES boxes(id),
    name TEXT NOT NULL,
    description TEXT,
    price DECIMAL(10,2),
    billing_period TEXT, -- 'monthly', 'quarterly', 'annual'
    sessions_per_week INTEGER,
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ DEFAULT now()
);

memberships

Tracks active member subscriptions:
CREATE TABLE memberships (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES profiles(id),
    plan_id UUID NOT NULL REFERENCES plans(id),
    start_date DATE NOT NULL,
    end_date DATE,
    status TEXT, -- 'active', 'paused', 'cancelled'
    created_at TIMESTAMPTZ DEFAULT now()
);

Workout Management

wods

Stores Workout of the Day entries:
CREATE TABLE wods (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID NOT NULL REFERENCES boxes(id),
    title TEXT NOT NULL,
    date DATE NOT NULL,
    wod_type wod_type, -- ENUM: 'amrap', 'emom', 'fortime', 'strength', 'skill'
    description TEXT,
    time_cap_minutes INTEGER,
    rounds INTEGER,
    rest_seconds INTEGER,
    notes TEXT,
    created_by UUID REFERENCES profiles(id),
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);
WOD Types:
CREATE TYPE wod_type AS ENUM (
    'amrap',    -- As Many Rounds/Reps As Possible
    'emom',     -- Every Minute On the Minute
    'fortime',  -- For Time (fastest completion)
    'strength', -- Strength training
    'skill'     -- Skill development
);

results

Tracks athlete performance on WODs:
CREATE TABLE results (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    wod_id UUID NOT NULL REFERENCES wods(id),
    athlete_id UUID NOT NULL REFERENCES profiles(id),
    score_value NUMERIC,
    score_display TEXT, -- e.g., "15:32", "135 reps", "225 lbs"
    notes TEXT,
    rx BOOLEAN DEFAULT false, -- As prescribed (no scaling)
    created_at TIMESTAMPTZ DEFAULT now()
);

movements

Library of CrossFit movements:
CREATE TABLE movements (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID NOT NULL REFERENCES boxes(id),
    name TEXT NOT NULL,
    category TEXT, -- 'gymnastics', 'weightlifting', 'cardio'
    description TEXT,
    video_url TEXT,
    difficulty TEXT, -- 'beginner', 'intermediate', 'advanced'
    created_at TIMESTAMPTZ DEFAULT now()
);

personal_records

Tracks athlete PRs:
CREATE TABLE personal_records (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID NOT NULL REFERENCES boxes(id),
    athlete_id UUID NOT NULL REFERENCES profiles(id),
    movement_id UUID REFERENCES movements(id),
    record_type TEXT, -- '1RM', '3RM', '5RM', 'max_reps', 'fastest_time'
    value NUMERIC,
    unit TEXT, -- 'lbs', 'kg', 'seconds', 'reps'
    achieved_date DATE,
    notes TEXT,
    created_at TIMESTAMPTZ DEFAULT now()
);

Schedule & Booking

classes

Defines class schedule:
CREATE TABLE classes (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID NOT NULL REFERENCES boxes(id),
    name TEXT NOT NULL,
    description TEXT,
    coach_id UUID REFERENCES profiles(id),
    start_time TIMESTAMPTZ NOT NULL,
    end_time TIMESTAMPTZ NOT NULL,
    duration_minutes INTEGER NOT NULL,
    capacity INTEGER NOT NULL,
    program_id UUID,
    recurrence_rule TEXT, -- iCal RRULE format
    is_canceled BOOLEAN DEFAULT false,
    created_at TIMESTAMPTZ DEFAULT now()
);

bookings

Tracks class reservations:
CREATE TABLE bookings (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID NOT NULL REFERENCES boxes(id),
    user_id UUID NOT NULL REFERENCES profiles(id),
    class_id UUID REFERENCES classes(id),
    status TEXT, -- 'confirmed', 'waitlist', 'cancelled', 'completed'
    checked_in BOOLEAN DEFAULT false,
    created_at TIMESTAMPTZ DEFAULT now()
);

Competition Management

competitions

Main competition entity:
CREATE TABLE competitions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID NOT NULL REFERENCES boxes(id),
    name TEXT NOT NULL,
    description TEXT,
    start_date DATE NOT NULL,
    end_date DATE NOT NULL,
    registration_deadline DATE,
    status TEXT, -- 'draft', 'open', 'in_progress', 'completed'
    max_participants INTEGER,
    created_by UUID REFERENCES profiles(id),
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);

competition_events

Individual workouts within a competition:
CREATE TABLE competition_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID NOT NULL REFERENCES boxes(id),
    competition_id UUID NOT NULL REFERENCES competitions(id),
    title TEXT NOT NULL,
    name TEXT,
    description TEXT,
    wod_id UUID REFERENCES wods(id),
    wod_type wod_type,
    order_index INTEGER,
    time_cap_seconds INTEGER,
    scoring_type TEXT, -- 'time', 'reps', 'weight', 'points'
    tie_break_strategy TEXT,
    standards_text TEXT,
    standards_video_url TEXT,
    created_at TIMESTAMPTZ DEFAULT now()
);

competition_participants

Registrations for competitions:
CREATE TABLE competition_participants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID NOT NULL REFERENCES boxes(id),
    competition_id UUID NOT NULL REFERENCES competitions(id),
    user_id UUID REFERENCES profiles(id),
    team_id UUID REFERENCES competition_teams(id),
    division_id UUID REFERENCES competition_divisions(id),
    division TEXT,
    status TEXT, -- 'registered', 'withdrawn', 'disqualified'
    waiver_signed BOOLEAN DEFAULT false,
    checked_in BOOLEAN DEFAULT false,
    created_at TIMESTAMPTZ DEFAULT now()
);

competition_scores

Event results:
CREATE TABLE competition_scores (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID NOT NULL REFERENCES boxes(id),
    event_id UUID NOT NULL REFERENCES competition_events(id),
    participant_id UUID NOT NULL REFERENCES competition_participants(id),
    score_value NUMERIC NOT NULL,
    score_display TEXT NOT NULL,
    tie_break_value NUMERIC,
    judge_user_id UUID REFERENCES profiles(id),
    judge_signature TEXT,
    athlete_signature BOOLEAN DEFAULT false,
    is_validated BOOLEAN DEFAULT false,
    validated_by UUID REFERENCES profiles(id),
    status score_status, -- ENUM: 'pending', 'validated', 'disputed'
    notes TEXT,
    created_at TIMESTAMPTZ DEFAULT now()
);
Score Status Enum:
CREATE TYPE score_status AS ENUM ('pending', 'validated', 'disputed');

competition_divisions

Divisions for fair competition:
CREATE TABLE competition_divisions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    competition_id UUID NOT NULL REFERENCES competitions(id),
    name TEXT NOT NULL,
    description TEXT,
    gender TEXT, -- 'male', 'female', 'mixed'
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);

competition_teams

Team competitions:
CREATE TABLE competition_teams (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    competition_id UUID NOT NULL REFERENCES competitions(id),
    name TEXT NOT NULL,
    captain_id UUID REFERENCES profiles(id),
    created_at TIMESTAMPTZ DEFAULT now()
);

competition_heats

Heat scheduling:
CREATE TABLE competition_heats (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    competition_id UUID NOT NULL REFERENCES competitions(id),
    event_id UUID REFERENCES competition_events(id),
    name TEXT NOT NULL,
    start_time TIMESTAMPTZ,
    status TEXT, -- 'scheduled', 'in_progress', 'completed'
    created_at TIMESTAMPTZ DEFAULT now()
);

lane_assignments

Lane assignments within heats:
CREATE TABLE lane_assignments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    heat_id UUID NOT NULL REFERENCES competition_heats(id),
    participant_id UUID REFERENCES competition_participants(id),
    lane_number INTEGER NOT NULL
);

Billing

invoices

Member invoices:
CREATE TABLE invoices (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID NOT NULL REFERENCES boxes(id),
    user_id UUID REFERENCES profiles(id),
    invoice_number TEXT UNIQUE,
    amount DECIMAL(10,2) NOT NULL,
    due_date DATE,
    status TEXT, -- 'draft', 'sent', 'paid', 'overdue', 'cancelled'
    items JSONB,
    created_at TIMESTAMPTZ DEFAULT now()
);

expenses

Gym operating expenses:
CREATE TABLE expenses (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID NOT NULL REFERENCES boxes(id),
    description TEXT NOT NULL,
    amount DECIMAL(10,2) NOT NULL,
    category TEXT,
    date DATE NOT NULL,
    vendor TEXT,
    receipt_url TEXT,
    created_by UUID REFERENCES profiles(id),
    created_at TIMESTAMPTZ DEFAULT now()
);

Audit & Security

audit_logs

Automatic change tracking:
CREATE TABLE audit_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID,
    table_name TEXT NOT NULL,
    record_id TEXT NOT NULL,
    action TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
    old_data JSONB,
    new_data JSONB,
    changed_by UUID REFERENCES profiles(id),
    created_at TIMESTAMPTZ DEFAULT now()
);
Trigger Function:
-- supabase/migrations/20260219_rls_multi_tenant_isolation.sql:529-551
CREATE OR REPLACE FUNCTION public.proc_audit_log()
RETURNS TRIGGER AS $$
DECLARE
    current_user_id UUID; current_box_id UUID; v_record_id TEXT;
BEGIN
    current_user_id := auth.uid();
    SELECT box_id INTO current_box_id FROM public.profiles WHERE id = current_user_id;
    IF (TG_OP = 'DELETE') THEN
        v_record_id := (row_to_json(OLD)->>'id')::TEXT;
        INSERT INTO public.audit_logs (table_name, record_id, action, old_data, changed_by, box_id) VALUES (TG_TABLE_NAME, v_record_id, TG_OP, row_to_json(OLD), current_user_id, current_box_id);
        RETURN OLD;
    ELSIF (TG_OP = 'UPDATE') THEN
        v_record_id := (row_to_json(NEW)->>'id')::TEXT;
        INSERT INTO public.audit_logs (table_name, record_id, action, old_data, new_data, changed_by, box_id) VALUES (TG_TABLE_NAME, v_record_id, TG_OP, row_to_json(OLD), row_to_json(NEW), current_user_id, current_box_id);
        RETURN NEW;
    ELSIF (TG_OP = 'INSERT') THEN
        v_record_id := (row_to_json(NEW)->>'id')::TEXT;
        INSERT INTO public.audit_logs (table_name, record_id, action, new_data, changed_by, box_id) VALUES (TG_TABLE_NAME, v_record_id, TG_OP, row_to_json(NEW), current_user_id, current_box_id);
        RETURN NEW;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Audit triggers are automatically attached to critical tables (profiles, boxes, plans, memberships, wods, competitions, leads, expenses).

automation_logs

Tracks automated actions:
CREATE TABLE automation_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    box_id UUID,
    action_type TEXT NOT NULL,
    target_user_id UUID,
    performed_by UUID,
    status TEXT,
    metadata JSONB,
    created_at TIMESTAMPTZ DEFAULT now()
);

Storage

Avatars Bucket

Supabase Storage bucket for user avatars:
-- supabase/migrations/20260206_create_avatars_bucket.sql
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', true);
RLS Policies:
  • Anyone can view avatars (public bucket)
  • Only authenticated users can upload their own avatar
  • Users can update/delete their own avatar

Database Functions

current_user_box_id()

Helper function for RLS policies:
-- supabase/migrations/20260219_rls_multi_tenant_isolation.sql:8-11
CREATE OR REPLACE FUNCTION public.current_user_box_id()
RETURNS UUID LANGUAGE sql SECURITY DEFINER STABLE AS $$
  SELECT box_id FROM public.profiles WHERE id = auth.uid()
$$;
This function is used extensively in RLS policies to enforce tenant isolation.

is_tenant_active()

Checks if tenant subscription is active:
-- supabase/migrations/20260302_add_subscription_status.sql:51-61
CREATE OR REPLACE FUNCTION public.is_tenant_active()
RETURNS BOOLEAN LANGUAGE sql SECURITY DEFINER STABLE AS $$
  SELECT COALESCE(
    (
      SELECT subscription_status IN ('active', 'trial')
      FROM public.boxes
      WHERE id = public.current_user_box_id()
    ),
    false
  )
$$;

admin_reset_password()

Allows admins to reset user passwords within their tenant:
-- supabase/migrations/20260219_rls_multi_tenant_isolation.sql:501-524
CREATE OR REPLACE FUNCTION public.admin_reset_password(target_user_id UUID)
RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public, auth, extensions AS $$
DECLARE
    caller_role TEXT; caller_email TEXT; caller_box_id UUID;
    target_box_id UUID; hashed TEXT; default_pw TEXT := '12345678';
BEGIN
    caller_email := auth.email();
    SELECT role_id, box_id INTO caller_role, caller_box_id FROM public.profiles WHERE id = auth.uid();
    IF caller_role IS DISTINCT FROM 'admin' AND caller_email IS DISTINCT FROM '[email protected]' THEN
        RETURN json_build_object('error', 'Unauthorized');
    END IF;
    IF NOT EXISTS (SELECT 1 FROM auth.users WHERE id = target_user_id) THEN
        RETURN json_build_object('error', 'User not found');
    END IF;
    SELECT box_id INTO target_box_id FROM public.profiles WHERE id = target_user_id;
    IF caller_email IS DISTINCT FROM '[email protected]' AND target_box_id IS DISTINCT FROM caller_box_id THEN
        RETURN json_build_object('error', 'Cannot reset password for user outside your box');
    END IF;
    hashed := extensions.crypt(default_pw, extensions.gen_salt('bf'));
    UPDATE auth.users SET encrypted_password = hashed, updated_at = now() WHERE id = target_user_id;
    UPDATE public.profiles SET force_password_change = true WHERE id = target_user_id;
    RETURN json_build_object('success', true);
END; $$;
This function enforces tenant isolation: admins can only reset passwords for users in their own box (except super admin).

Indexes

You should create indexes on frequently queried columns:
-- Tenant isolation
CREATE INDEX idx_profiles_box_id ON profiles(box_id);
CREATE INDEX idx_wods_box_id ON wods(box_id);
CREATE INDEX idx_leads_box_id ON leads(box_id);

-- Foreign keys
CREATE INDEX idx_results_wod_id ON results(wod_id);
CREATE INDEX idx_results_athlete_id ON results(athlete_id);
CREATE INDEX idx_bookings_class_id ON bookings(class_id);
CREATE INDEX idx_bookings_user_id ON bookings(user_id);

-- Lookups
CREATE INDEX idx_boxes_slug ON boxes(slug);
CREATE INDEX idx_wods_date ON wods(date);

Migration Strategy

Migrations are located in supabase/migrations/ and follow a date-based naming convention:
20260206_create_avatars_bucket.sql
20260208_security_audit_system.sql
20260219_rls_multi_tenant_isolation.sql
20260302_add_subscription_status.sql
All migrations use defensive SQL (IF EXISTS checks) to allow safe re-running.

Build docs developers (and LLMs) love