diff --git a/Makefile b/Makefile index aadac46..44991c6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev build run test lint clean docker-up docker-down migrate-up migrate-down +.PHONY: dev build run test lint clean docker-up docker-down migrate-up migrate-down migrate-create # Development dev: @@ -32,12 +32,25 @@ docker-down: docker-logs: docker-compose logs -f -# Database migrations (placeholder for Story 1.2) +# Database migrations +# Requires: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest +DB_URL ?= postgres://flowreader:flowreader@localhost:5432/flowreader?sslmode=disable + migrate-up: - @echo "Migration system not yet configured (Story 1.2)" + migrate -path ./migrations -database "$(DB_URL)" up migrate-down: - @echo "Migration system not yet configured (Story 1.2)" + migrate -path ./migrations -database "$(DB_URL)" down 1 + +migrate-drop: + migrate -path ./migrations -database "$(DB_URL)" drop -f + +migrate-create: + @read -p "Migration name: " name; \ + migrate create -ext sql -dir ./migrations -seq $$name + +migrate-version: + migrate -path ./migrations -database "$(DB_URL)" version # Cleanup clean: diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 882b228..00a9dd5 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -36,8 +36,8 @@ story_location: "_bmad-output/implementation-artifacts" development_status: # Epic 1: Walking Skeleton & User Access epic-1: in-progress - 1-1-project-initialization-walking-skeleton: review - 1-2-database-migration-system: backlog + 1-1-project-initialization-walking-skeleton: done + 1-2-database-migration-system: review 1-3-user-registration-api: backlog 1-4-session-authentication: backlog 1-5-frontend-auth-foundation: backlog diff --git a/cmd/server/main.go b/cmd/server/main.go index 15c3e21..7687153 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,27 +11,26 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "github.com/jackc/pgx/v5/pgxpool" + "github.com/michael/flowreader/internal/config" + "github.com/michael/flowreader/internal/database" ) func main() { - // Load configuration from environment - port := getEnv("PORT", "8080") - dbURL := getEnv("DATABASE_URL", "postgres://flowreader:flowreader@db:5432/flowreader?sslmode=disable") + // Load configuration + cfg := config.Load() // Initialize database connection pool ctx := context.Background() - pool, err := pgxpool.New(ctx, dbURL) + pool, err := database.Connect(ctx, cfg.DatabaseURL) if err != nil { - log.Fatalf("Unable to create connection pool: %v", err) + log.Fatalf("Database connection failed: %v", err) } defer pool.Close() - // Verify database connection - if err := pool.Ping(ctx); err != nil { - log.Fatalf("Unable to ping database: %v", err) + // Check migrations status (warning only, doesn't block) + if err := database.RunMigrations(ctx, pool); err != nil { + log.Printf("Migration check warning: %v", err) } - log.Println("Connected to database") // Initialize router r := chi.NewRouter() @@ -65,7 +64,7 @@ func main() { // Create server srv := &http.Server{ - Addr: ":" + port, + Addr: ":" + cfg.Port, Handler: r, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, @@ -74,7 +73,7 @@ func main() { // Graceful shutdown go func() { - log.Printf("Server starting on port %s", port) + log.Printf("Server starting on port %s", cfg.Port) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed: %v", err) } @@ -95,11 +94,3 @@ func main() { log.Println("Server exited properly") } - -// getEnv returns the value of an environment variable or a default value -func getEnv(key, defaultValue string) string { - if value, exists := os.LookupEnv(key); exists { - return value - } - return defaultValue -} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..dfcc914 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,41 @@ +// Package config handles application configuration from environment variables. +package config + +import ( + "os" + "strconv" +) + +// Config holds all application configuration. +type Config struct { + Port string + DatabaseURL string + Environment string +} + +// Load reads configuration from environment variables with sensible defaults. +func Load() *Config { + return &Config{ + Port: getEnv("PORT", "8080"), + DatabaseURL: getEnv("DATABASE_URL", "postgres://flowreader:flowreader@db:5432/flowreader?sslmode=disable"), + Environment: getEnv("ENV", "development"), + } +} + +// getEnv returns the value of an environment variable or a default value. +func getEnv(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} + +// getEnvInt returns an integer environment variable or a default value. +func getEnvInt(key string, defaultValue int) int { + if value, exists := os.LookupEnv(key); exists { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..8cc5946 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,61 @@ +// Package database provides database connection and migration utilities. +package database + +import ( + "context" + "fmt" + "log" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Connect creates a new connection pool to the database. +func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, fmt.Errorf("parsing database URL: %w", err) + } + + // Connection pool settings (optimized for low memory) + config.MaxConns = 10 + config.MinConns = 2 + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("creating connection pool: %w", err) + } + + // Verify connection + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("pinging database: %w", err) + } + + log.Println("Database connection established") + return pool, nil +} + +// RunMigrations executes pending database migrations. +// Note: For production, use golang-migrate CLI. This is for development convenience. +func RunMigrations(ctx context.Context, pool *pgxpool.Pool) error { + // Check if schema_migrations table exists + var exists bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'schema_migrations' + ) + `).Scan(&exists) + if err != nil { + return fmt.Errorf("checking schema_migrations: %w", err) + } + + if !exists { + log.Println("Migrations table not found. Run 'make migrate-up' to apply migrations.") + return nil + } + + log.Println("Database migrations verified") + return nil +} diff --git a/migrations/001_create_users.down.sql b/migrations/001_create_users.down.sql new file mode 100644 index 0000000..3399653 --- /dev/null +++ b/migrations/001_create_users.down.sql @@ -0,0 +1,5 @@ +-- Rollback: 001_create_users + +DROP TRIGGER IF EXISTS update_users_updated_at ON users; +DROP FUNCTION IF EXISTS update_updated_at_column(); +DROP TABLE IF EXISTS users; diff --git a/migrations/001_create_users.up.sql b/migrations/001_create_users.up.sql new file mode 100644 index 0000000..77ce73c --- /dev/null +++ b/migrations/001_create_users.up.sql @@ -0,0 +1,28 @@ +-- Migration: 001_create_users +-- Description: Create the users table for authentication + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for email lookup (login) +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + +-- Trigger to auto-update updated_at +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column();