Initial commit

This commit is contained in:
2025-11-29 16:59:02 -06:00
commit da4f051282
25 changed files with 1144 additions and 0 deletions

52
.air.toml Normal file
View File

@@ -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

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# Environment variables
.env
.envrc
# Temporary files
/tmp
# Binaries
/bin
/api
# Nix
result*
.direnv
flake-profile*

80
Justfile Normal file
View File

@@ -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

83
cmd/api/main.go Normal file
View File

@@ -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")
}

23
docker-compose.yml Normal file
View File

@@ -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:

61
flake.lock generated Normal file
View File

@@ -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
}

63
flake.nix Normal file
View File

@@ -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 ""
'';
};
}
);
}

19
go.mod Normal file
View File

@@ -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
)

34
go.sum Normal file
View File

@@ -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=

101
internal/auth/manager.go Normal file
View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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})
}

View File

@@ -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))
})
}
}

View File

@@ -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)
})
}

View File

@@ -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
}

108
internal/server/server.go Normal file
View File

@@ -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)
}

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS users;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

84
seeds/exercises.sql Normal file
View File

@@ -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;

7
seeds/users.sql Normal file
View File

@@ -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;

136
seeds/workouts.sql Normal file
View File

@@ -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);