Skip to content

Sport Tech Club - Entity-Relationship Diagram (MER)

📊 Overview

This document presents the complete Entity-Relationship Model for the Sport Tech Club platform, a multi-tenant SaaS system for beach sports arena management with gamification, queue management, and payment processing.

Architecture Principles

  • Multi-tenant isolation: Row-level security (RLS) and schema-based isolation
  • Domain-Driven Design (DDD): Organized into bounded contexts
  • Event sourcing ready: Audit logs track all entity changes
  • LGPD compliant: Consent tracking and data retention policies
  • Scalability: Partitioning strategy for high-volume tables

🏗️ Bounded Contexts

The database is organized into 4 PostgreSQL schemas:

  1. identity - User management, authentication, gamification
  2. arena - Arena, courts, queues, game sessions
  3. booking - Bookings, payments, check-ins
  4. public - Audit logs, shared data

📐 Complete Entity-Relationship Diagram


🔐 Multi-Tenant Isolation Strategy

Row-Level Security (RLS)

sql
-- Enable RLS on tenant-scoped tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE bookings ENABLE ROW LEVEL SECURITY;

-- Policy example: Users can only see their tenant's data
CREATE POLICY tenant_isolation_policy ON users
  USING (tenant_id = current_setting('app.current_tenant')::UUID);

-- Application sets tenant context per request
BEGIN;
  SET LOCAL app.current_tenant = 'uuid-of-tenant';
  SELECT * FROM users; -- Automatically filtered
COMMIT;

Schema-Based Isolation

  • identity: Tenant data with RLS
  • arena: Per-tenant arenas with foreign keys to identity.tenant
  • booking: Bookings always linked to arena (transitive tenant isolation)
  • public: Audit logs with explicit tenantId column

📊 Indexing Strategy

Critical Indexes for Performance

sql
-- Multi-tenant queries (most common)
CREATE INDEX idx_users_tenant_status ON users(tenant_id, status);
CREATE INDEX idx_arenas_tenant_status ON arenas(tenant_id, status);
CREATE INDEX idx_bookings_arena_time ON bookings(arena_id, start_time, end_time);

-- Time-based queries (booking availability)
CREATE INDEX idx_bookings_court_time ON bookings(court_id, start_time, end_time)
  WHERE status NOT IN ('CANCELLED', 'NO_SHOW');

-- Player matchmaking
CREATE INDEX idx_skill_rating_sport_level ON skill_ratings(sport, rating DESC);
CREATE INDEX idx_queue_entries_status_position ON queue_entries(queue_id, status, position);

-- Payment reconciliation
CREATE INDEX idx_payments_status_created ON payments(status, created_at);
CREATE INDEX idx_payments_gateway_external ON payments(gateway, external_id);

-- Audit logs (time-series)
CREATE INDEX idx_audit_tenant_created ON audit_logs(tenant_id, created_at DESC);
CREATE INDEX idx_audit_entity ON audit_logs(entity_type, entity_id);

-- Full-text search
CREATE INDEX idx_users_search ON users USING gin(to_tsvector('portuguese', display_name || ' ' || email));
CREATE INDEX idx_arenas_search ON arenas USING gin(to_tsvector('portuguese', name || ' ' || address_city));

Composite Indexes for Common Queries

sql
-- "Show available courts for beach tennis on Saturday 2pm"
CREATE INDEX idx_courts_sport_status ON courts(arena_id, sport, status)
  WHERE status = 'ACTIVE';

-- "Top 10 players by skill rating for beach tennis"
CREATE INDEX idx_skill_rating_leaderboard ON skill_ratings(sport, rating DESC, games_played);

-- "User's active bookings"
CREATE INDEX idx_bookings_user_active ON bookings(user_id, status, start_time)
  WHERE status IN ('CONFIRMED', 'CHECKED_IN');

🗂️ Partitioning Strategy

Time-Series Partitioning

High-volume tables partitioned by month for efficient archival:

sql
-- Bookings (estimated 10K-50K/month per large arena)
CREATE TABLE bookings_2025_01 PARTITION OF bookings
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE bookings_2025_02 PARTITION OF bookings
  FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

-- Payments (follows booking volume)
CREATE TABLE payments_2025_01 PARTITION OF payments
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

-- Audit logs (estimated 100K-500K/month)
CREATE TABLE audit_logs_2025_01 PARTITION OF audit_logs
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

Retention Policy

sql
-- Automated archival job
CREATE OR REPLACE FUNCTION archive_old_partitions()
RETURNS void AS $$
BEGIN
  -- Detach partitions older than 2 years
  EXECUTE format('
    ALTER TABLE bookings DETACH PARTITION bookings_%s;
  ', to_char(NOW() - INTERVAL '2 years', 'YYYY_MM'));
  
  -- Move to archival schema or external storage (S3 via pg_dump)
END;
$$ LANGUAGE plpgsql;

-- Schedule via pg_cron
SELECT cron.schedule('archive-old-data', '0 2 1 * *', 'SELECT archive_old_partitions();');

🔗 Key Relationships Explained

1. Multi-Tenant Hierarchy

Tenant (SaaS Customer)
  └── Arena (Physical Location)
       ├── Court (Bookable Resource)
       ├── User (Staff/Players)
       └── Booking (Reservation)

Design Decision: Arena is child of Tenant to support franchises with multiple locations.


2. Booking Lifecycle

Booking (PENDING)
  ├── Payment (PROCESSING)
  │    └── Refund (if cancelled)
  ├── CheckIn (QR code scan)
  └── BookingParticipant (invited players)

States:

  1. PENDING → User selects time slot
  2. CONFIRMED → Payment successful
  3. CHECKED_IN → User arrives at arena
  4. IN_PROGRESS → Game started
  5. COMPLETED → Game finished (triggers XP/achievements)

3. Queue System (Winner Stays)

GameQueue (per Court)
  ├── QueueEntry (position 1)
  │    └── GameTeam [Player A, Player B]
  ├── QueueEntry (position 2)
  │    └── GameTeam [Player C, Player D]
  └── GameSession (active match)
       ├── HomeTeam (from position 1)
       ├── AwayTeam (from position 2)
       └── MatchParticipant (stats)

Algorithm:

  1. Winner stays on court
  2. Loser goes to end of queue
  3. Next team (position 2) challenges winner
  4. ELO rating updated after each match

4. Gamification Flow

MatchParticipant (game completed)
  ├── Update SkillRating (ELO +15)
  ├── Update PlayerGamification (XP +50)
  ├── Check Achievement Criteria
  └── Create PlayerAchievement (if unlocked)
       └── Notification (ACHIEVEMENT_UNLOCKED)

XP Sources:

  • Complete game: +50 XP
  • Win game: +30 XP
  • Win streak (3+): +20 XP bonus
  • First game of day: +10 XP
  • Achievement unlock: Variable (10-500 XP)

5. Payment Flow

Booking (totalPrice = 120.00)
  ├── Promotion (code: SUMMER2025, -20.00)
  ├── PricingRule (peak hour, +30.00)
  └── Payment
       ├── Gateway: MERCADO_PAGO
       ├── Method: PIX
       ├── Amount: 130.00
       ├── Fees: 3.99
       └── NetAmount: 126.01

Gateway Integration:

  • Webhook receives payment confirmation
  • Update Payment.status = PAID
  • Update Booking.status = CONFIRMED
  • Send Notification (BOOKING_CONFIRMATION)

🎯 Critical Constraints

Business Rules Enforced by DB

sql
-- No overlapping bookings
CREATE UNIQUE INDEX idx_bookings_no_overlap ON bookings(court_id, start_time, end_time)
  WHERE status NOT IN ('CANCELLED', 'NO_SHOW');

-- Max 1 active queue entry per player
CREATE UNIQUE INDEX idx_queue_one_per_player ON queue_entries(queue_id, unnest(player_ids))
  WHERE status = 'WAITING';

-- Achievement can only be unlocked once per player
ALTER TABLE player_achievements ADD CONSTRAINT uq_achievement_once
  UNIQUE (player_profile_id, achievement_id);

-- Refund cannot exceed payment amount
ALTER TABLE refunds ADD CONSTRAINT chk_refund_amount
  CHECK (amount <= (SELECT amount FROM payments WHERE id = payment_id));

Soft Delete Pattern

Tables with deleted_at column use soft delete:

sql
-- Archive instead of delete
UPDATE users SET deleted_at = NOW(), status = 'DELETED'
WHERE id = 'uuid-here';

-- Queries exclude deleted rows
SELECT * FROM users WHERE deleted_at IS NULL;

-- Permanent deletion after 90 days (LGPD right to be forgotten)
DELETE FROM users WHERE deleted_at < NOW() - INTERVAL '90 days';

📈 Scalability Considerations

Performance Targets

OperationTarget LatencyVolume
Booking availability check< 100ms1000 req/s
Payment processing< 500ms100 req/s
Queue position update< 50ms500 req/s
Skill rating calculation< 200ms50 req/s
Dashboard analytics< 2s10 req/s

Optimization Strategies

  1. Materialized Views for analytics:
sql
CREATE MATERIALIZED VIEW mv_arena_daily_stats AS
SELECT
  arena_id,
  DATE(start_time) as date,
  COUNT(*) as total_bookings,
  SUM(total_price) as revenue,
  AVG(EXTRACT(EPOCH FROM (end_time - start_time))/60) as avg_duration_minutes
FROM bookings
WHERE status = 'COMPLETED'
GROUP BY arena_id, DATE(start_time);

-- Refresh nightly
CREATE INDEX ON mv_arena_daily_stats(arena_id, date DESC);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_arena_daily_stats;
  1. Read Replicas for analytics queries:

    • Primary: Write operations (bookings, payments)
    • Replica 1: Dashboard queries (read-only)
    • Replica 2: Reporting/data exports
  2. Caching Layer (Redis):

    • Court availability (5min TTL)
    • Queue positions (30s TTL)
    • User sessions (24h TTL)
    • Pricing rules (1h TTL)

🔄 Data Migration Strategy

Version Control

sql
-- migrations/001_create_identity_schema.sql
CREATE SCHEMA IF NOT EXISTS identity;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- migrations/002_create_tenants_table.sql
CREATE TABLE identity.tenants (...);

-- migrations/003_add_rls_policies.sql
ALTER TABLE identity.users ENABLE ROW LEVEL SECURITY;

Zero-Downtime Deployment

  1. Backward Compatible Changes:

    • Add nullable columns first
    • Populate data in background job
    • Make column NOT NULL in next release
  2. Schema Changes:

sql
-- Bad: ALTER TABLE locks table
ALTER TABLE bookings ADD COLUMN notes TEXT;

-- Good: Use transactional DDL
BEGIN;
  ALTER TABLE bookings ADD COLUMN notes TEXT;
  CREATE INDEX CONCURRENTLY idx_bookings_notes ON bookings USING gin(to_tsvector('portuguese', notes));
COMMIT;

📚 Data Quality Tests (dbt)

Referential Integrity

yaml
# models/schema.yml
models:
  - name: bookings
    columns:
      - name: id
        tests:
          - unique
          - not_null
      - name: arena_id
        tests:
          - relationships:
              to: ref('arenas')
              field: id
      - name: total_price
        tests:
          - dbt_utils.accepted_range:
              min_value: 0
              max_value: 10000

Business Logic Validation

sql
-- tests/no_overlapping_bookings.sql
SELECT
  court_id,
  start_time,
  end_time,
  COUNT(*) as overlaps
FROM bookings
WHERE status NOT IN ('CANCELLED', 'NO_SHOW')
GROUP BY court_id, start_time, end_time
HAVING COUNT(*) > 1;
-- Should return 0 rows

🛡️ Security & Compliance

LGPD (Brazilian GDPR) Compliance

  1. Right to Access: User can export all their data
  2. Right to Deletion: Soft delete + permanent purge after 90 days
  3. Consent Tracking: user_consents table with IP + timestamp
  4. Data Minimization: Only collect necessary fields
  5. Audit Trail: All changes logged in audit_logs

Sensitive Data Encryption

sql
-- PII encrypted at rest (using pgcrypto)
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- Encrypt CPF
UPDATE users SET cpf = pgp_sym_encrypt(cpf, 'encryption-key')
WHERE cpf IS NOT NULL;

-- Decrypt on read (application layer)
SELECT id, pgp_sym_decrypt(cpf::bytea, 'encryption-key') as cpf
FROM users WHERE id = 'uuid';

📊 Analytics Queries Examples

Daily Revenue Report

sql
SELECT
  a.name as arena_name,
  DATE(b.start_time) as date,
  COUNT(DISTINCT b.id) as bookings,
  SUM(p.net_amount) as revenue,
  AVG(p.net_amount) as avg_ticket
FROM bookings b
JOIN arenas a ON b.arena_id = a.id
JOIN payments p ON b.id = p.booking_id
WHERE p.status = 'PAID'
  AND b.start_time >= NOW() - INTERVAL '30 days'
GROUP BY a.id, a.name, DATE(b.start_time)
ORDER BY date DESC, revenue DESC;

Player Engagement Metrics

sql
WITH player_stats AS (
  SELECT
    pp.id,
    u.display_name,
    pg.level,
    pg.total_games_played,
    pg.current_streak,
    COUNT(DISTINCT mp.game_session_id) as games_last_30d,
    AVG(sr.rating) as avg_rating
  FROM player_profiles pp
  JOIN users u ON pp.user_id = u.id
  JOIN player_gamification pg ON pp.id = pg.player_profile_id
  LEFT JOIN match_participants mp ON pp.id = mp.player_profile_id
    AND mp.created_at >= NOW() - INTERVAL '30 days'
  LEFT JOIN skill_ratings sr ON pp.id = sr.player_profile_id
  GROUP BY pp.id, u.display_name, pg.level, pg.total_games_played, pg.current_streak
)
SELECT
  display_name,
  level,
  total_games_played,
  games_last_30d,
  CASE
    WHEN games_last_30d >= 15 THEN 'High Engagement'
    WHEN games_last_30d >= 5 THEN 'Medium Engagement'
    ELSE 'Low Engagement'
  END as engagement_tier,
  current_streak,
  ROUND(avg_rating) as skill_rating
FROM player_stats
ORDER BY games_last_30d DESC
LIMIT 100;

Court Utilization Rate

sql
SELECT
  c.name as court_name,
  COUNT(*) as total_bookings,
  SUM(EXTRACT(EPOCH FROM (b.end_time - b.start_time))/3600) as hours_booked,
  ROUND(
    SUM(EXTRACT(EPOCH FROM (b.end_time - b.start_time))/3600) /
    (oh.close_time - oh.open_time) * 30, -- assuming 30 days in month
    2
  ) as utilization_rate_pct
FROM courts c
JOIN bookings b ON c.id = b.court_id
JOIN operating_hours oh ON c.arena_id = oh.arena_id
WHERE b.start_time >= DATE_TRUNC('month', NOW())
  AND b.status IN ('COMPLETED', 'IN_PROGRESS')
GROUP BY c.id, c.name, oh.open_time, oh.close_time
ORDER BY utilization_rate_pct DESC;

🎓 Design Decisions Rationale

1. Why UUID over Auto-Increment IDs?

Advantages:

  • Multi-tenant safe (no ID leaking between tenants)
  • Distributed systems friendly (no coordination needed)
  • Merge-friendly (staging → production)
  • URL-safe (no sequential ID enumeration attacks)

⚠️ Trade-offs:

  • Larger index size (16 bytes vs 4 bytes)
  • Slightly slower joins (mitigated by proper indexing)

2. Why JSONB over Separate Tables?

Use JSONB when:

  • Schema is dynamic (user settings, metadata)
  • Rarely queried (audit log details)
  • Nested structures (scoreboard sets, dimension measurements)

Use separate tables when:

  • Need to query/filter (skill ratings per sport)
  • Referential integrity required (booking participants)
  • Frequent updates (counters, aggregates)

3. Why Soft Delete?

  • LGPD compliance: Users can request data deletion, but need grace period
  • Audit trail: Keep record of "who deleted what when"
  • Accidental deletion recovery: 7-day window to restore

Permanent deletion after 90 days via scheduled job.

4. Why Enum over Varchar?

Type safety: Database rejects invalid values ✅ Storage efficiency: Stored as integers internally ✅ Performance: Faster comparisons vs string ⚠️ Migration complexity: Adding enum value requires ALTER TYPE

Alternative: varchar with CHECK constraint for easier updates.


🚀 Next Steps

  1. Create seed data: Use Prisma seed script to populate dev database
  2. Generate documentation: prisma generate + dbdocs.io
  3. Setup CI/CD: Automate migration testing
  4. Monitor slow queries: pg_stat_statements extension
  5. Implement caching: Redis for hot paths

📞 Contact

For questions about this data model, contact:

  • DataFlow (Data Engineering Team)
  • Architecture Review: Weekly Thursdays 3pm

Last Updated: 2025-01-09 Schema Version: 1.0.0 PostgreSQL Version: 16.x