From da4f0512821ca74aeebb4c2914f4978e70c9a78f Mon Sep 17 00:00:00 2001 From: Jared Kling Date: Sat, 29 Nov 2025 16:59:02 -0600 Subject: [PATCH] Initial commit --- .air.toml | 52 +++++++ .gitignore | 15 ++ Justfile | 80 +++++++++++ cmd/api/main.go | 83 +++++++++++ docker-compose.yml | 23 +++ flake.lock | 61 ++++++++ flake.nix | 63 ++++++++ go.mod | 19 +++ go.sum | 34 +++++ internal/auth/manager.go | 101 +++++++++++++ internal/database/postgres.go | 41 ++++++ internal/handlers/health.go | 30 ++++ internal/handlers/responses.go | 16 +++ internal/middleware/auth.go | 56 ++++++++ internal/middleware/cors.go | 18 +++ internal/middleware/logging.go | 30 ++++ internal/server/server.go | 108 ++++++++++++++ migrations/000001_initial-schema.down.sql | 1 + migrations/000001_initial-schema.up.sql | 10 ++ .../000002_create_workouts_tables.down.sql | 5 + .../000002_create_workouts_tables.up.sql | 62 ++++++++ seeds/exercise_categories.sql | 9 ++ seeds/exercises.sql | 84 +++++++++++ seeds/users.sql | 7 + seeds/workouts.sql | 136 ++++++++++++++++++ 25 files changed, 1144 insertions(+) create mode 100644 .air.toml create mode 100644 .gitignore create mode 100644 Justfile create mode 100644 cmd/api/main.go create mode 100644 docker-compose.yml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/manager.go create mode 100644 internal/database/postgres.go create mode 100644 internal/handlers/health.go create mode 100644 internal/handlers/responses.go create mode 100644 internal/middleware/auth.go create mode 100644 internal/middleware/cors.go create mode 100644 internal/middleware/logging.go create mode 100644 internal/server/server.go create mode 100644 migrations/000001_initial-schema.down.sql create mode 100644 migrations/000001_initial-schema.up.sql create mode 100644 migrations/000002_create_workouts_tables.down.sql create mode 100644 migrations/000002_create_workouts_tables.up.sql create mode 100644 seeds/exercise_categories.sql create mode 100644 seeds/exercises.sql create mode 100644 seeds/users.sql create mode 100644 seeds/workouts.sql diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..f4dd709 --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./bin/api" + cmd = "go build -v -o ./bin/api ./cmd/api" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = ["cmd", "internal"] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0813481 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Environment variables +.env +.envrc + +# Temporary files +/tmp + +# Binaries +/bin +/api + +# Nix +result* +.direnv +flake-profile* diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..be4933c --- /dev/null +++ b/Justfile @@ -0,0 +1,80 @@ +# Load environment variables from .env +set dotenv-load + +# Default recipe to display help +default: + @just --list + +# Run the application +run: + go run cmd/api/main.go + +# Build the application +build: + go build -o bin/api cmd/api/main.go + +# Start the project with file watchers +dev: db-up migrate-up + air + +# Run tests +test: + go test -v ./... + +# Clean build artifacts +clean: + rm -rf bin/ + +# Start the database +db-up: + docker-compose up -d + @echo "Waiting for database to be ready..." + @sleep 2 + +# Stop the database +db-down: + docker-compose down + +# Reset the database (WARNING: deletes all data) +db-reset: + docker-compose down -v + docker-compose up -d + @echo "Waiting for database to be ready..." + @sleep 2 + just migrate-up + +# Run all database migrations +migrate-up: db-up + go run -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest -path migrations -database "${DATABASE_URL}" up + +# Rollback last migration +migrate-down: + go run -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest -path migrations -database "${DATABASE_URL}" down 1 + +# Create a new migration +migrate-create NAME: + go run -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest create -ext sql -dir migrations -seq {{NAME}} + +# Connect to the database with pgcli +db-connect: + pgcli "${DATABASE_URL}" + +# Run seed data +db-seed: + @echo "Seeding database..." + psql "${DATABASE_URL}" -f seeds/exercise_categories.sql + psql "${DATABASE_URL}" -f seeds/exercises.sql + psql "${DATABASE_URL}" -f seeds/users.sql + psql "${DATABASE_URL}" -f seeds/workouts.sql + @echo "Seed data loaded!" + +# Reset database and run seeds +db-fresh: db-reset db-seed + +# View database logs +db-logs: + docker-compose logs -f postgres + +# Check database status +db-status: + docker-compose ps diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..feca7b8 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "git.kling.dev/jared/WorkoutTrackerAPI/internal/database" + "git.kling.dev/jared/WorkoutTrackerAPI/internal/server" +) + +func main() { + dbHost := os.Getenv("DB_HOST") + dbPort := os.Getenv("DB_PORT") + dbUser := os.Getenv("DB_USER") + dbPassword := os.Getenv("DB_PASSWORD") + dbName := os.Getenv("DB_NAME") + webPort := os.Getenv("PORT") + + if dbHost == "" || dbPort == "" || dbUser == "" || dbPassword == "" || dbName == "" { + log.Fatal("Not all database params are set") + } + dbPortParsed, err := strconv.ParseInt(dbPort, 10, 64) + if err != nil { + log.Fatal("Unable to parse db port to an int") + } + + if webPort == "" { + webPort = "8080" + } + webPortParsed, err := strconv.ParseInt(webPort, 10, 64) + if err != nil { + log.Fatal("Unable to parse web port to an int") + } + + dbConn, err := database.NewConnection(database.Config{ + Host: dbHost, + Port: int(dbPortParsed), + User: dbUser, + Password: dbPassword, + DBName: dbName, + }) + + if err != nil { + log.Fatal("Failed to connect to database: ", err) + } + + defer dbConn.Close() + + jwtSecret := os.Getenv("JWT_SECRET") + + if jwtSecret == "" { + log.Fatal("Unable to determine the JWT_SECRET") + } + + s := server.NewServer(dbConn, jwtSecret) + + go func() { + fmt.Printf("Server listening on port %d\n", webPortParsed) + webAddr := fmt.Sprintf(":%d", webPortParsed) + s.Start(webAddr) + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := s.Shutdown(ctx); err != nil { + log.Fatal("Server forced to shutdown:", err) + } + + log.Println("Server exited") +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..55a8655 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: workout_tracker_db + restart: unless-stopped + environment: + POSTGRES_USER: workout_user + POSTGRES_PASSWORD: workout_pass + POSTGRES_DB: workout_tracker + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U workout_user -d workout_tracker"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..664ccda --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1759381078, + "narHash": "sha256-gTrEEp5gEspIcCOx9PD8kMaF1iEmfBcTbO0Jag2QhQs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7df7ff7d8e00218376575f0acdcc5d66741351ee", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7470c99 --- /dev/null +++ b/flake.nix @@ -0,0 +1,63 @@ +{ + description = "Go development environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + go + gotools + gopls + go-outline + gocode-gomod + gopkgs + godef + golint + air + + postgresql + pgcli + + migrate + + docker + docker-compose + + jq + curl + just + ]; + + shellHook = '' + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🚀 Go Development Environment" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Go version: $(go version | cut -d' ' -f3)" + echo "PostgreSQL: $(postgres --version | cut -d' ' -f3)" + echo "" + echo "Quick commands:" + echo " docker-compose up -d # Start Postgres" + echo " docker-compose down # Stop Postgres" + echo " just migrate-up # Run migrations" + echo " just run # Run the API" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + ''; + }; + } + ); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..148caf9 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module git.kling.dev/jared/WorkoutTrackerAPI + +go 1.25.0 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-migrate/migrate/v4 v4.19.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/lib/pq v1.10.9 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7c57b86 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= +github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/manager.go b/internal/auth/manager.go new file mode 100644 index 0000000..982e44c --- /dev/null +++ b/internal/auth/manager.go @@ -0,0 +1,101 @@ +package auth + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + ErrInvalidToken = errors.New("invalid token") + ErrExpiredToken = errors.New("token has expired") + ErrInvalidSignature = errors.New("invalid token signature") + ErrMissingClaims = errors.New("missing required claims") +) + +// Claims represents the JWT claims structure +type Claims struct { + UserID int64 `json:"user_id"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +type JWTManager struct { + secretKey []byte + tokenDuration time.Duration +} + +func NewJWTManager(secretKey string, tokenDuration time.Duration) *JWTManager { + return &JWTManager{ + secretKey: []byte(secretKey), + tokenDuration: tokenDuration, + } +} + +// GenerateToken creates a new JWT token for a user +func (m *JWTManager) GenerateToken(userID int64, email string) (string, error) { + claims := Claims{ + UserID: userID, + Email: email, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.tokenDuration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: os.Getenv("JWT_ISSUER"), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + tokenString, err := token.SignedString(m.secretKey) + if err != nil { + return "", fmt.Errorf("failed to sign token: %w", err) + } + + return tokenString, nil +} + +// ValidateToken validates the JWT token and returns the claims +func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims( + tokenString, + &Claims{}, + func(token *jwt.Token) (interface{}, error) { + // Verify signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return m.secretKey, nil + }, + ) + + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrExpiredToken + } + if errors.Is(err, jwt.ErrSignatureInvalid) { + return nil, ErrInvalidSignature + } + return nil, ErrInvalidToken + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, ErrInvalidToken + } + + return claims, nil +} + +// RefreshToken generates a new token from an existing valid token +func (m *JWTManager) RefreshToken(tokenString string) (string, error) { + claims, err := m.ValidateToken(tokenString) + if err != nil { + return "", err + } + + return m.GenerateToken(claims.UserID, claims.Email) +} diff --git a/internal/database/postgres.go b/internal/database/postgres.go new file mode 100644 index 0000000..62fa3f4 --- /dev/null +++ b/internal/database/postgres.go @@ -0,0 +1,41 @@ +package database + +import ( + "database/sql" + "fmt" + "time" + + _ "github.com/lib/pq" +) + +type Config struct { + Host string + Port int + User string + Password string + DBName string +} + +func NewConnection(cfg Config) (*sql.DB, error) { + dsn := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, + ) + + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, err + } + + // Connection pooling + db.SetMaxOpenConns(25) + db.SetMaxOpenConns(5) + db.SetConnMaxLifetime(5 * time.Minute) + db.SetConnMaxIdleTime(10 * time.Minute) + + if err := db.Ping(); err != nil { + return nil, err + } + + return db, nil +} diff --git a/internal/handlers/health.go b/internal/handlers/health.go new file mode 100644 index 0000000..4e6c9f0 --- /dev/null +++ b/internal/handlers/health.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "database/sql" + "net/http" +) + +type HealthResponse struct { + Status string `json:"status"` + DBConnected bool `json:"db_connected"` +} + +type HealthcheckService struct { + db *sql.DB +} + +func NewHealthcheckService(db *sql.DB) *HealthcheckService { + return &HealthcheckService{db: db} +} + +func (h *HealthcheckService) Health(w http.ResponseWriter, r *http.Request) { + response := HealthResponse{ + Status: "ok", + } + + err := h.db.Ping() + response.DBConnected = err == nil + + JSON(w, http.StatusOK, response) +} diff --git a/internal/handlers/responses.go b/internal/handlers/responses.go new file mode 100644 index 0000000..56ee8fa --- /dev/null +++ b/internal/handlers/responses.go @@ -0,0 +1,16 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +func JSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func Error(w http.ResponseWriter, status int, message string) { + JSON(w, status, map[string]string{"error": message}) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..50acbc3 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "context" + "errors" + "log" + "net/http" + "strings" + + "git.kling.dev/jared/WorkoutTrackerAPI/internal/auth" +) + +type contextKey string + +const ( + UserIDKey contextKey = "user_id" + EmailKey contextKey = "email" +) + +func ValidateJWT(jwtManager auth.JWTManager) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("Starting ValidateJWT handler") + auth_header := r.Header.Get("Authorization") + if auth_header == "" { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Missing authorization header")) + return + } + + parts := strings.Split(auth_header, " ") + if len(parts) != 2 || strings.ToLower(strings.Trim(parts[0], " ")) != "bearer" { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Invalid authorization header format")) + return + } + + tokenString := parts[1] + claims, err := jwtManager.ValidateToken(tokenString) + + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + if errors.Is(err, auth.ErrExpiredToken) { + w.Write([]byte("Token has expired")) + return + } + w.Write([]byte("Invalid token")) + return + } + + ctx := context.WithValue(r.Context(), UserIDKey, claims.Subject) + ctx = context.WithValue(ctx, EmailKey, claims.Email) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..01b2283 --- /dev/null +++ b/internal/middleware/cors.go @@ -0,0 +1,18 @@ +package middleware + +import "net/http" + +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go new file mode 100644 index 0000000..6d71d34 --- /dev/null +++ b/internal/middleware/logging.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "log" + "net/http" + "time" +) + +func Logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + + next.ServeHTTP(wrapped, r) + + log.Printf( + "%s %s %d %s", + r.Method, + r.URL.Path, + wrapped.statusCode, + time.Since(start), + ) + }) +} + +type responseWriter struct { + http.ResponseWriter + statusCode int +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..4067bd2 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,108 @@ +package server + +import ( + "context" + "database/sql" + "log" + "net/http" + "strconv" + "time" + + "git.kling.dev/jared/WorkoutTrackerAPI/internal/auth" + "git.kling.dev/jared/WorkoutTrackerAPI/internal/handlers" + "git.kling.dev/jared/WorkoutTrackerAPI/internal/middleware" +) + +type Services struct { + healthcheckService handlers.HealthcheckService +} + +type Handlers struct { +} + +type Server struct { + db *sql.DB + router *http.ServeMux + server *http.Server + services *Services + handlers *Handlers + jwtManager *auth.JWTManager +} + +type UserMeResponse struct { + UserId int64 `json:"userId"` + Email string `json:"email"` +} + +func NewServer(db *sql.DB, jwtSecret string) *Server { + s := &Server{ + db: db, + router: http.NewServeMux(), + server: nil, + } + + s.services = &Services{ + healthcheckService: *handlers.NewHealthcheckService(db), + } + + s.jwtManager = auth.NewJWTManager(jwtSecret, 60*time.Minute) + + s.routes() + + return s +} + +func (s *Server) routes() { + auth := middleware.ValidateJWT(*s.jwtManager) + s.router.HandleFunc("GET /health", s.services.healthcheckService.Health) + s.router.HandleFunc("POST /auth", func(w http.ResponseWriter, r *http.Request) { + token, err := s.jwtManager.GenerateToken(123, "jared") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + handlers.JSON(w, http.StatusOK, token) + }) + s.router.HandleFunc("GET /auth/me", auth(func(w http.ResponseWriter, r *http.Request) { + log.Printf("Start of /auth/me") + ctxUserId := r.Context().Value(middleware.UserIDKey).(string) + email := r.Context().Value(middleware.EmailKey).(string) + userId, _ := strconv.ParseInt(ctxUserId, 10, 64) + log.Printf("Values: %d - %s", userId, email) + + response := UserMeResponse{ + UserId: userId, + Email: email, + } + handlers.JSON(w, http.StatusOK, response) + })) +} + +func (s *Server) Start(addr string) error { + handler := middleware.Logging( + middleware.CORS( + s.router, + ), + ) + + server := &http.Server{ + Addr: addr, + Handler: handler, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + s.server = server + + return server.ListenAndServe() +} + +func (s *Server) Shutdown(ctx context.Context) error { + if s.server == nil { + return nil + } + + return s.server.Shutdown(ctx) +} diff --git a/migrations/000001_initial-schema.down.sql b/migrations/000001_initial-schema.down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/migrations/000001_initial-schema.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/migrations/000001_initial-schema.up.sql b/migrations/000001_initial-schema.up.sql new file mode 100644 index 0000000..3d6f442 --- /dev/null +++ b/migrations/000001_initial-schema.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + username VARCHAR(100) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_username ON users(username); diff --git a/migrations/000002_create_workouts_tables.down.sql b/migrations/000002_create_workouts_tables.down.sql new file mode 100644 index 0000000..e75a3cc --- /dev/null +++ b/migrations/000002_create_workouts_tables.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS exercise_sets; +DROP TABLE IF EXISTS workout_exercises; +DROP TABLE IF EXISTS workouts; +DROP TABLE IF EXISTS exercises; +DROP TABLE IF EXISTS exercise_categories; diff --git a/migrations/000002_create_workouts_tables.up.sql b/migrations/000002_create_workouts_tables.up.sql new file mode 100644 index 0000000..f241227 --- /dev/null +++ b/migrations/000002_create_workouts_tables.up.sql @@ -0,0 +1,62 @@ +CREATE TABLE exercise_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE exercises ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + category_id UUID NULL REFERENCES exercise_categories(id) ON DELETE RESTRICT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + +); + +CREATE INDEX idx_exercises_category_id ON exercises(category_id); + +CREATE TABLE workouts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NULL, + notes TEXT, + workout_date TIMESTAMP NOT NULL DEFAULT NOW(), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_workouts_user_id ON workouts(user_id); +CREATE INDEX idx_workouts_workout_date ON workouts(workout_date); + +CREATE TABLE workout_exercises ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workout_id UUID NOT NULL REFERENCES workouts(id) ON DELETE CASCADE, + exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT, + notes TEXT, + order_index INT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_workout_exercises_workout_id ON workout_exercises(workout_id); +CREATE INDEX idx_workout_exercises_exercise_id ON workout_exercises(exercise_id); + +CREATE TABLE exercise_sets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workout_exercise_id UUID NOT NULL REFERENCES workout_exercises(id) ON DELETE CASCADE, + set_number INT NOT NULL, + reps INT, + weight DECIMAL(10,2), + weight_unit VARCHAR(10), + duration_seconds INTEGER, + distance DECIMAL(10,2), + distance_unit VARCHAR(10), + notes TEXT, + rate_perceived_exertion INT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_exercise_sets_workout_exercise_id ON exercise_sets(workout_exercise_id); diff --git a/seeds/exercise_categories.sql b/seeds/exercise_categories.sql new file mode 100644 index 0000000..b6dc820 --- /dev/null +++ b/seeds/exercise_categories.sql @@ -0,0 +1,9 @@ +-- Exercise Categories +INSERT INTO exercise_categories (id, name) VALUES + ('550e8400-e29b-41d4-a716-446655440001', 'Strength'), + ('550e8400-e29b-41d4-a716-446655440002', 'Cardio'), + ('550e8400-e29b-41d4-a716-446655440003', 'Flexibility'), + ('550e8400-e29b-41d4-a716-446655440004', 'Olympic Lifting'), + ('550e8400-e29b-41d4-a716-446655440005', 'Bodyweight'), + ('550e8400-e29b-41d4-a716-446655440006', 'Sports') +ON CONFLICT (name) DO NOTHING; diff --git a/seeds/exercises.sql b/seeds/exercises.sql new file mode 100644 index 0000000..cc6d37a --- /dev/null +++ b/seeds/exercises.sql @@ -0,0 +1,84 @@ +-- Strength Exercises +INSERT INTO exercises (name, description, category_id) VALUES + -- Chest + ('Barbell Bench Press', 'Compound chest exercise', '550e8400-e29b-41d4-a716-446655440001'), + ('Dumbbell Bench Press', 'Chest exercise with dumbbells', '550e8400-e29b-41d4-a716-446655440001'), + ('Incline Barbell Bench Press', 'Upper chest focus', '550e8400-e29b-41d4-a716-446655440001'), + ('Decline Bench Press', 'Lower chest focus', '550e8400-e29b-41d4-a716-446655440001'), + ('Chest Fly', 'Isolation chest exercise', '550e8400-e29b-41d4-a716-446655440001'), + + -- Back + ('Barbell Deadlift', 'Compound posterior chain exercise', '550e8400-e29b-41d4-a716-446655440001'), + ('Romanian Deadlift', 'Hamstring and glute focus', '550e8400-e29b-41d4-a716-446655440001'), + ('Barbell Row', 'Back thickness builder', '550e8400-e29b-41d4-a716-446655440001'), + ('Dumbbell Row', 'Unilateral back exercise', '550e8400-e29b-41d4-a716-446655440001'), + ('Lat Pulldown', 'Lat width builder', '550e8400-e29b-41d4-a716-446655440001'), + ('Pull-ups', 'Bodyweight back exercise', '550e8400-e29b-41d4-a716-446655440005'), + ('Chin-ups', 'Bicep emphasis pull-up variation', '550e8400-e29b-41d4-a716-446655440005'), + + -- Legs + ('Barbell Back Squat', 'Compound leg exercise', '550e8400-e29b-41d4-a716-446655440001'), + ('Front Squat', 'Quad-focused squat variation', '550e8400-e29b-41d4-a716-446655440001'), + ('Bulgarian Split Squat', 'Unilateral leg exercise', '550e8400-e29b-41d4-a716-446655440001'), + ('Leg Press', 'Machine leg exercise', '550e8400-e29b-41d4-a716-446655440001'), + ('Leg Curl', 'Hamstring isolation', '550e8400-e29b-41d4-a716-446655440001'), + ('Leg Extension', 'Quad isolation', '550e8400-e29b-41d4-a716-446655440001'), + ('Calf Raise', 'Calf exercise', '550e8400-e29b-41d4-a716-446655440001'), + ('Walking Lunge', 'Dynamic leg exercise', '550e8400-e29b-41d4-a716-446655440001'), + + -- Shoulders + ('Overhead Press', 'Compound shoulder exercise', '550e8400-e29b-41d4-a716-446655440001'), + ('Dumbbell Shoulder Press', 'Dumbbell shoulder variation', '550e8400-e29b-41d4-a716-446655440001'), + ('Lateral Raise', 'Side delt isolation', '550e8400-e29b-41d4-a716-446655440001'), + ('Front Raise', 'Front delt isolation', '550e8400-e29b-41d4-a716-446655440001'), + ('Face Pull', 'Rear delt and upper back', '550e8400-e29b-41d4-a716-446655440001'), + ('Upright Row', 'Shoulder and trap exercise', '550e8400-e29b-41d4-a716-446655440001'), + + -- Arms + ('Barbell Curl', 'Bicep exercise', '550e8400-e29b-41d4-a716-446655440001'), + ('Dumbbell Curl', 'Unilateral bicep exercise', '550e8400-e29b-41d4-a716-446655440001'), + ('Hammer Curl', 'Brachialis focus', '550e8400-e29b-41d4-a716-446655440001'), + ('Tricep Pushdown', 'Tricep isolation', '550e8400-e29b-41d4-a716-446655440001'), + ('Skull Crusher', 'Tricep exercise', '550e8400-e29b-41d4-a716-446655440001'), + ('Close-Grip Bench Press', 'Tricep compound movement', '550e8400-e29b-41d4-a716-446655440001'), + ('Dips', 'Compound tricep and chest exercise', '550e8400-e29b-41d4-a716-446655440005') +ON CONFLICT DO NOTHING; + +-- Olympic Lifting +INSERT INTO exercises (name, description, category_id) VALUES + ('Clean and Jerk', 'Olympic lift - ground to overhead', '550e8400-e29b-41d4-a716-446655440004'), + ('Snatch', 'Olympic lift - single movement overhead', '550e8400-e29b-41d4-a716-446655440004'), + ('Power Clean', 'Explosive pulling movement', '550e8400-e29b-41d4-a716-446655440004'), + ('Hang Clean', 'Clean from hang position', '550e8400-e29b-41d4-a716-446655440004') +ON CONFLICT DO NOTHING; + +-- Bodyweight +INSERT INTO exercises (name, description, category_id) VALUES + ('Push-ups', 'Bodyweight chest exercise', '550e8400-e29b-41d4-a716-446655440005'), + ('Diamond Push-ups', 'Tricep-focused push-up variation', '550e8400-e29b-41d4-a716-446655440005'), + ('Plank', 'Core stability exercise', '550e8400-e29b-41d4-a716-446655440005'), + ('Side Plank', 'Oblique stability', '550e8400-e29b-41d4-a716-446655440005'), + ('Hanging Leg Raise', 'Core and hip flexor exercise', '550e8400-e29b-41d4-a716-446655440005'), + ('Pistol Squat', 'Single-leg squat', '550e8400-e29b-41d4-a716-446655440005'), + ('Burpees', 'Full body conditioning', '550e8400-e29b-41d4-a716-446655440005'), + ('Mountain Climbers', 'Core and cardio', '550e8400-e29b-41d4-a716-446655440005') +ON CONFLICT DO NOTHING; + +-- Cardio +INSERT INTO exercises (name, description, category_id) VALUES + ('Running', 'Outdoor or treadmill running', '550e8400-e29b-41d4-a716-446655440002'), + ('Cycling', 'Road or stationary bike', '550e8400-e29b-41d4-a716-446655440002'), + ('Rowing', 'Full body cardio on rower', '550e8400-e29b-41d4-a716-446655440002'), + ('Swimming', 'Low impact cardio', '550e8400-e29b-41d4-a716-446655440002'), + ('Jump Rope', 'High intensity cardio', '550e8400-e29b-41d4-a716-446655440002'), + ('Elliptical', 'Low impact machine cardio', '550e8400-e29b-41d4-a716-446655440002'), + ('Stair Climber', 'Lower body cardio', '550e8400-e29b-41d4-a716-446655440002') +ON CONFLICT DO NOTHING; + +-- Flexibility +INSERT INTO exercises (name, description, category_id) VALUES + ('Yoga', 'Flexibility and mindfulness practice', '550e8400-e29b-41d4-a716-446655440003'), + ('Static Stretching', 'Hold stretch positions', '550e8400-e29b-41d4-a716-446655440003'), + ('Dynamic Stretching', 'Active stretching movements', '550e8400-e29b-41d4-a716-446655440003'), + ('Foam Rolling', 'Self-myofascial release', '550e8400-e29b-41d4-a716-446655440003') +ON CONFLICT DO NOTHING; diff --git a/seeds/users.sql b/seeds/users.sql new file mode 100644 index 0000000..4a25044 --- /dev/null +++ b/seeds/users.sql @@ -0,0 +1,7 @@ +-- Seed Users +INSERT INTO users (id, email, username) VALUES + ('650e8400-e29b-41d4-a716-446655440001', 'john.doe@example.com', 'johndoe'), + ('650e8400-e29b-41d4-a716-446655440002', 'jane.smith@example.com', 'janesmith'), + ('650e8400-e29b-41d4-a716-446655440003', 'mike.wilson@example.com', 'mikewilson'), + ('650e8400-e29b-41d4-a716-446655440004', 'sarah.jones@example.com', 'sarahjones') +ON CONFLICT (email) DO NOTHING; diff --git a/seeds/workouts.sql b/seeds/workouts.sql new file mode 100644 index 0000000..9dfcfde --- /dev/null +++ b/seeds/workouts.sql @@ -0,0 +1,136 @@ +-- Workout 1: John's Push Day +INSERT INTO workouts (id, user_id, name, notes, workout_date) VALUES + ('750e8400-e29b-41d4-a716-446655440001', '650e8400-e29b-41d4-a716-446655440001', 'Push Day', 'Felt strong today', '2024-10-01 09:00:00'); + +-- Workout exercises for Push Day +INSERT INTO workout_exercises (id, workout_id, exercise_id, order_index, notes) VALUES + ('850e8400-e29b-41d4-a716-446655440001', '750e8400-e29b-41d4-a716-446655440001', + (SELECT id FROM exercises WHERE name = 'Barbell Bench Press'), 1, 'Felt good on chest'), + ('850e8400-e29b-41d4-a716-446655440002', '750e8400-e29b-41d4-a716-446655440001', + (SELECT id FROM exercises WHERE name = 'Overhead Press'), 2, NULL), + ('850e8400-e29b-41d4-a716-446655440003', '750e8400-e29b-41d4-a716-446655440001', + (SELECT id FROM exercises WHERE name = 'Tricep Pushdown'), 3, 'Burned out on last set'); + +-- Sets for Bench Press +INSERT INTO exercise_sets (workout_exercise_id, set_number, reps, weight, weight_unit, rate_perceived_exertion) VALUES + ('850e8400-e29b-41d4-a716-446655440001', 1, 10, 135.0, 'lbs', 6), + ('850e8400-e29b-41d4-a716-446655440001', 2, 8, 185.0, 'lbs', 7), + ('850e8400-e29b-41d4-a716-446655440001', 3, 6, 205.0, 'lbs', 8), + ('850e8400-e29b-41d4-a716-446655440001', 4, 8, 185.0, 'lbs', 9); + +-- Sets for Overhead Press +INSERT INTO exercise_sets (workout_exercise_id, set_number, reps, weight, weight_unit, rate_perceived_exertion) VALUES + ('850e8400-e29b-41d4-a716-446655440002', 1, 10, 95.0, 'lbs', 6), + ('850e8400-e29b-41d4-a716-446655440002', 2, 8, 115.0, 'lbs', 7), + ('850e8400-e29b-41d4-a716-446655440002', 3, 6, 125.0, 'lbs', 8); + +-- Sets for Tricep Pushdown +INSERT INTO exercise_sets (workout_exercise_id, set_number, reps, weight, weight_unit, rate_perceived_exertion) VALUES + ('850e8400-e29b-41d4-a716-446655440003', 1, 12, 50.0, 'lbs', 6), + ('850e8400-e29b-41d4-a716-446655440003', 2, 10, 60.0, 'lbs', 7), + ('850e8400-e29b-41d4-a716-446655440003', 3, 8, 70.0, 'lbs', 9); + +-- Workout 2: Jane's Leg Day +INSERT INTO workouts (id, user_id, name, notes, workout_date) VALUES + ('750e8400-e29b-41d4-a716-446655440002', '650e8400-e29b-41d4-a716-446655440002', 'Leg Day', 'Tough session but got through it', '2024-10-02 10:30:00'); + +-- Workout exercises for Leg Day +INSERT INTO workout_exercises (id, workout_id, exercise_id, order_index) VALUES + ('850e8400-e29b-41d4-a716-446655440004', '750e8400-e29b-41d4-a716-446655440002', + (SELECT id FROM exercises WHERE name = 'Barbell Back Squat'), 1), + ('850e8400-e29b-41d4-a716-446655440005', '750e8400-e29b-41d4-a716-446655440002', + (SELECT id FROM exercises WHERE name = 'Romanian Deadlift'), 2), + ('850e8400-e29b-41d4-a716-446655440006', '750e8400-e29b-41d4-a716-446655440002', + (SELECT id FROM exercises WHERE name = 'Walking Lunge'), 3); + +-- Sets for Squat +INSERT INTO exercise_sets (workout_exercise_id, set_number, reps, weight, weight_unit, rate_perceived_exertion) VALUES + ('850e8400-e29b-41d4-a716-446655440004', 1, 8, 135.0, 'lbs', 6), + ('850e8400-e29b-41d4-a716-446655440004', 2, 6, 155.0, 'lbs', 7), + ('850e8400-e29b-41d4-a716-446655440004', 3, 5, 175.0, 'lbs', 8), + ('850e8400-e29b-41d4-a716-446655440004', 4, 6, 155.0, 'lbs', 9); + +-- Sets for Romanian Deadlift +INSERT INTO exercise_sets (workout_exercise_id, set_number, reps, weight, weight_unit, rate_perceived_exertion) VALUES + ('850e8400-e29b-41d4-a716-446655440005', 1, 10, 95.0, 'lbs', 6), + ('850e8400-e29b-41d4-a716-446655440005', 2, 10, 115.0, 'lbs', 7), + ('850e8400-e29b-41d4-a716-446655440005', 3, 8, 135.0, 'lbs', 8); + +-- Sets for Walking Lunge +INSERT INTO exercise_sets (workout_exercise_id, set_number, reps, weight, weight_unit, rate_perceived_exertion) VALUES + ('850e8400-e29b-41d4-a716-446655440006', 1, 20, 25.0, 'lbs', 7), + ('850e8400-e29b-41d4-a716-446655440006', 2, 20, 25.0, 'lbs', 8), + ('850e8400-e29b-41d4-a716-446655440006', 3, 16, 30.0, 'lbs', 9); + +-- Workout 3: Mike's Cardio Session +INSERT INTO workouts (id, user_id, name, notes, workout_date) VALUES + ('750e8400-e29b-41d4-a716-446655440003', '650e8400-e29b-41d4-a716-446655440003', 'Morning Run', 'Beautiful weather', '2024-10-03 06:00:00'); + +INSERT INTO workout_exercises (id, workout_id, exercise_id, order_index) VALUES + ('850e8400-e29b-41d4-a716-446655440007', '750e8400-e29b-41d4-a716-446655440003', + (SELECT id FROM exercises WHERE name = 'Running'), 1); + +-- Cardio set with distance and duration +INSERT INTO exercise_sets (workout_exercise_id, set_number, duration_seconds, distance, distance_unit, rate_perceived_exertion) VALUES + ('850e8400-e29b-41d4-a716-446655440007', 1, 1800, 3.1, 'miles', 6); + +-- Workout 4: Sarah's Pull Day +INSERT INTO workouts (id, user_id, name, notes, workout_date) VALUES + ('750e8400-e29b-41d4-a716-446655440004', '650e8400-e29b-41d4-a716-446655440004', 'Pull Day', 'New PR on deadlift!', '2024-10-04 14:00:00'); + +INSERT INTO workout_exercises (id, workout_id, exercise_id, order_index, notes) VALUES + ('850e8400-e29b-41d4-a716-446655440008', '750e8400-e29b-41d4-a716-446655440004', + (SELECT id FROM exercises WHERE name = 'Barbell Deadlift'), 1, 'PR on third set!'), + ('850e8400-e29b-41d4-a716-446655440009', '750e8400-e29b-41d4-a716-446655440004', + (SELECT id FROM exercises WHERE name = 'Pull-ups'), 2, NULL), + ('850e8400-e29b-41d4-a716-446655440010', '750e8400-e29b-41d4-a716-446655440004', + (SELECT id FROM exercises WHERE name = 'Barbell Row'), 3, NULL); + +-- Sets for Deadlift +INSERT INTO exercise_sets (workout_exercise_id, set_number, reps, weight, weight_unit, rate_perceived_exertion) VALUES + ('850e8400-e29b-41d4-a716-446655440008', 1, 5, 185.0, 'lbs', 6), + ('850e8400-e29b-41d4-a716-446655440008', 2, 3, 225.0, 'lbs', 7), + ('850e8400-e29b-41d4-a716-446655440008', 3, 1, 255.0, 'lbs', 9), + ('850e8400-e29b-41d4-a716-446655440008', 4, 5, 205.0, 'lbs', 8); + +-- Sets for Pull-ups (bodyweight) +INSERT INTO exercise_sets (workout_exercise_id, set_number, reps, rate_perceived_exertion) VALUES + ('850e8400-e29b-41d4-a716-446655440009', 1, 10, 7), + ('850e8400-e29b-41d4-a716-446655440009', 2, 8, 8), + ('850e8400-e29b-41d4-a716-446655440009', 3, 6, 9); + +-- Sets for Barbell Row +INSERT INTO exercise_sets (workout_exercise_id, set_number, reps, weight, weight_unit, rate_perceived_exertion) VALUES + ('850e8400-e29b-41d4-a716-446655440010', 1, 10, 95.0, 'lbs', 6), + ('850e8400-e29b-41d4-a716-446655440010', 2, 8, 115.0, 'lbs', 7), + ('850e8400-e29b-41d4-a716-446655440010', 3, 8, 115.0, 'lbs', 8); + +-- Workout 5: John's Second Workout - Full Body +INSERT INTO workouts (id, user_id, name, notes, workout_date) VALUES + ('750e8400-e29b-41d4-a716-446655440005', '650e8400-e29b-41d4-a716-446655440001', 'Full Body', 'Quick session before work', '2024-10-05 07:00:00'); + +INSERT INTO workout_exercises (id, workout_id, exercise_id, order_index) VALUES + ('850e8400-e29b-41d4-a716-446655440011', '750e8400-e29b-41d4-a716-446655440005', + (SELECT id FROM exercises WHERE name = 'Front Squat'), 1), + ('850e8400-e29b-41d4-a716-446655440012', '750e8400-e29b-41d4-a716-446655440005', + (SELECT id FROM exercises WHERE name = 'Dumbbell Bench Press'), 2), + ('850e8400-e29b-41d4-a716-446655440013', '750e8400-e29b-41d4-a716-446655440005', + (SELECT id FROM exercises WHERE name = 'Lat Pulldown'), 3); + +-- Sets for Front Squat +INSERT INTO exercise_sets (workout_exercise_id, set_number, reps, weight, weight_unit, rate_perceived_exertion) VALUES + ('850e8400-e29b-41d4-a716-446655440011', 1, 8, 95.0, 'lbs', 6), + ('850e8400-e29b-41d4-a716-446655440011', 2, 8, 115.0, 'lbs', 7), + ('850e8400-e29b-41d4-a716-446655440011', 3, 6, 135.0, 'lbs', 8); + +-- Sets for Dumbbell Bench Press +INSERT INTO exercise_sets (workout_exercise_id, set_number, reps, weight, weight_unit, rate_perceived_exertion, notes) VALUES + ('850e8400-e29b-41d4-a716-446655440012', 1, 10, 50.0, 'lbs', 6, 'Per dumbbell'), + ('850e8400-e29b-41d4-a716-446655440012', 2, 8, 60.0, 'lbs', 7, 'Per dumbbell'), + ('850e8400-e29b-41d4-a716-446655440012', 3, 8, 60.0, 'lbs', 8, 'Per dumbbell'); + +-- Sets for Lat Pulldown +INSERT INTO exercise_sets (workout_exercise_id, set_number, reps, weight, weight_unit, rate_perceived_exertion) VALUES + ('850e8400-e29b-41d4-a716-446655440013', 1, 12, 100.0, 'lbs', 6), + ('850e8400-e29b-41d4-a716-446655440013', 2, 10, 120.0, 'lbs', 7), + ('850e8400-e29b-41d4-a716-446655440013', 3, 8, 140.0, 'lbs', 8);