From ec250570a6bc3af95267fd55a1bc604327b6263d Mon Sep 17 00:00:00 2001 From: Jean Jacques Avril Date: Tue, 11 Mar 2025 12:35:04 +0000 Subject: [PATCH] feat: Refactor database configuration loading and seeding logic for improved clarity and maintainability --- .clinerules | 13 +-- backend/Makefile | 1 + backend/cmd/api/main.go | 6 -- backend/cmd/seed/main.go | 129 +++++++++++++++--------------- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/config/config.go | 86 ++++++++++++++++++++ backend/internal/models/db.go | 58 -------------- 8 files changed, 164 insertions(+), 132 deletions(-) create mode 100644 backend/internal/config/config.go diff --git a/.clinerules b/.clinerules index 9527ec1..725e07d 100644 --- a/.clinerules +++ b/.clinerules @@ -33,14 +33,17 @@ - Client components must use TanStack Query - UI state management via Zustand -7. DEVOPS -- Docker builds must pass Hadolint checks -- Kubernetes manifests in gitops/ directory -- Monitoring via OpenTelemetry instrumentation 8. DEVELOPMENT WORKFLOW -- Use Makefile commands for common tasks: +- Makefile commands are only available in the backend folder +- Common make commands: - make generate: Run code generation (tygo, swagger, etc.) - make test: Run all tests - make build: Build the application + +9. CUSTOM RULES +- Add custom rules to .clinerules if: + - Unexpected behavior is encountered + - Specific conditions require warnings + - New patterns emerge that need documentation - make run: Start the development server \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile index 0371fb8..dc34852 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -1,3 +1,4 @@ + # Time Tracker Backend Makefile .PHONY: db-start db-stop db-test model-test run build clean migrate seed help diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index aebf127..f163a42 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -86,12 +86,6 @@ func main() { log.Fatalf("Error migrating database: %v", err) } - // Seed database with initial data if needed - ctx := context.Background() - if err := models.SeedDB(ctx); err != nil { - log.Fatalf("Error seeding database: %v", err) - } - // Create Gin router r := gin.Default() diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go index 80f2543..5736613 100644 --- a/backend/cmd/seed/main.go +++ b/backend/cmd/seed/main.go @@ -2,56 +2,28 @@ package main import ( "context" + "flag" "fmt" "log" - "os" - "time" + "github.com/timetracker/backend/internal/config" "github.com/timetracker/backend/internal/models" - "gorm.io/gorm/logger" + "gorm.io/gorm" ) func main() { - // Parse command line flags - force := false - for _, arg := range os.Args[1:] { - if arg == "--force" || arg == "-f" { - force = true - } - } + // Parse CLI flags + _ = flag.String("config", "", "Path to .env config file") + flag.Parse() - // Get database configuration with sensible defaults - dbConfig := models.DefaultDatabaseConfig() - - // Override with environment variables if provided - if host := os.Getenv("DB_HOST"); host != "" { - dbConfig.Host = host + // Load configuration + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("Failed to load config: %v", err) } - if port := os.Getenv("DB_PORT"); port != "" { - var portInt int - if _, err := fmt.Sscanf(port, "%d", &portInt); err == nil && portInt > 0 { - dbConfig.Port = portInt - } - } - if user := os.Getenv("DB_USER"); user != "" { - dbConfig.User = user - } - if password := os.Getenv("DB_PASSWORD"); password != "" { - dbConfig.Password = password - } - if dbName := os.Getenv("DB_NAME"); dbName != "" { - dbConfig.DBName = dbName - } - if sslMode := os.Getenv("DB_SSLMODE"); sslMode != "" { - dbConfig.SSLMode = sslMode - } - - // Set log level - dbConfig.LogLevel = logger.Info // Initialize database - fmt.Println("Connecting to database...") - if err := models.InitDB(dbConfig); err != nil { + if err := models.InitDB(cfg.Database); err != nil { log.Fatalf("Error initializing database: %v", err) } defer func() { @@ -59,31 +31,62 @@ func main() { log.Printf("Error closing database connection: %v", err) } }() - fmt.Println("✓ Database connection successful") - // Create context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Check if we need to seed (e.g., no companies exist) - if !force { - var count int64 - db := models.GetEngine(ctx) - if err := db.Model(&models.Company{}).Count(&count).Error; err != nil { - log.Fatalf("Error checking if seeding is needed: %v", err) - } - - // If data already exists, skip seeding - if count > 0 { - fmt.Println("Database already contains data. Use --force to override.") - return - } - } - - // Seed the database - fmt.Println("Seeding database with initial data...") - if err := models.SeedDB(ctx); err != nil { + // Execute seed operation + if err := seedDatabase(context.Background()); err != nil { log.Fatalf("Error seeding database: %v", err) } - fmt.Println("✓ Database seeding completed successfully") + + log.Println("Database seeding completed successfully") +} + +// seedDatabase performs the database seeding operation +func seedDatabase(ctx context.Context) error { + // Check if seeding is needed + var count int64 + if err := models.GetEngine(ctx).Model(&models.Company{}).Count(&count).Error; err != nil { + return fmt.Errorf("error checking if seeding is needed: %w", err) + } + + // If data exists, skip seeding + if count > 0 { + log.Println("Database already contains data, skipping seeding") + return nil + } + + log.Println("Seeding database with initial data...") + + // Start transaction + return models.GetEngine(ctx).Transaction(func(tx *gorm.DB) error { + // Create default company + defaultCompany := models.Company{ + Name: "Default Company", + } + if err := tx.Create(&defaultCompany).Error; err != nil { + return fmt.Errorf("error creating default company: %w", err) + } + + // Create admin user + adminUser := models.User{ + Email: "admin@example.com", + Role: models.RoleAdmin, + CompanyID: defaultCompany.ID, + HourlyRate: 100.0, + } + + // Hash password + pwData, err := models.HashPassword("Admin@123456") + if err != nil { + return fmt.Errorf("error hashing password: %w", err) + } + + adminUser.Salt = pwData.Salt + adminUser.Hash = pwData.Hash + + if err := tx.Create(&adminUser).Error; err != nil { + return fmt.Errorf("error creating admin user: %w", err) + } + + return nil + }) } diff --git a/backend/go.mod b/backend/go.mod index 63ceee3..04028de 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,7 @@ go 1.23.6 require ( github.com/gin-gonic/gin v1.10.0 + github.com/joho/godotenv v1.5.1 github.com/oklog/ulid/v2 v2.1.0 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 diff --git a/backend/go.sum b/backend/go.sum index d44fa84..cf643f7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -54,6 +54,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..d911363 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,86 @@ +package config + +import ( + "errors" + "fmt" + "log" + "os" + "strconv" + + "github.com/joho/godotenv" + "github.com/timetracker/backend/internal/models" + "gorm.io/gorm/logger" +) + +// Config represents the application configuration +type Config struct { + Database models.DatabaseConfig +} + +// LoadConfig loads configuration from environment variables and .env file +func LoadConfig() (*Config, error) { + // Try loading .env file, but don't fail if it doesn't exist + _ = godotenv.Load() + + cfg := &Config{ + Database: models.DefaultDatabaseConfig(), + } + + // Load database configuration + if err := loadDatabaseConfig(cfg); err != nil { + return nil, fmt.Errorf("failed to load database config: %w", err) + } + + return cfg, nil +} + +// loadDatabaseConfig loads database configuration from environment +func loadDatabaseConfig(cfg *Config) error { + // Required fields + cfg.Database.Host = getEnv("DB_HOST", cfg.Database.Host) + cfg.Database.User = getEnv("DB_USER", cfg.Database.User) + cfg.Database.Password = getEnv("DB_PASSWORD", cfg.Database.Password) + cfg.Database.DBName = getEnv("DB_NAME", cfg.Database.DBName) + cfg.Database.SSLMode = getEnv("DB_SSLMODE", cfg.Database.SSLMode) + + // Optional fields with parsing + if port := getEnv("DB_PORT", ""); port != "" { + portInt, err := strconv.Atoi(port) + if err != nil || portInt <= 0 { + return errors.New("invalid DB_PORT value") + } + cfg.Database.Port = portInt + } + + // Log level based on environment + if os.Getenv("ENVIRONMENT") == "production" { + cfg.Database.LogLevel = logger.Error + } else { + cfg.Database.LogLevel = logger.Info + } + + // Validate required fields + if cfg.Database.Host == "" || cfg.Database.User == "" || + cfg.Database.Password == "" || cfg.Database.DBName == "" { + return errors.New("missing required database configuration") + } + + return nil +} + +// getEnv gets an environment variable with fallback +func getEnv(key, fallback string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return fallback +} + +// MustLoadConfig loads configuration or panics on failure +func MustLoadConfig() *Config { + cfg, err := LoadConfig() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + return cfg +} diff --git a/backend/internal/models/db.go b/backend/internal/models/db.go index bc81683..cd02074 100644 --- a/backend/internal/models/db.go +++ b/backend/internal/models/db.go @@ -114,64 +114,6 @@ func MigrateDB() error { return nil } -// SeedDB seeds the database with initial data if needed -func SeedDB(ctx context.Context) error { - if defaultDB == nil { - return errors.New("database not initialized") - } - - log.Println("Checking if database seeding is needed...") - - // Check if we need to seed (e.g., no companies exist) - var count int64 - if err := defaultDB.Model(&Company{}).Count(&count).Error; err != nil { - return fmt.Errorf("error checking if seeding is needed: %w", err) - } - - // If data already exists, skip seeding - if count > 0 { - log.Println("Database already contains data, skipping seeding") - return nil - } - - log.Println("Seeding database with initial data...") - - // Start a transaction for all seed operations - return defaultDB.Transaction(func(tx *gorm.DB) error { - // Create a default company - defaultCompany := Company{ - Name: "Default Company", - } - if err := tx.Create(&defaultCompany).Error; err != nil { - return fmt.Errorf("error creating default company: %w", err) - } - - // Create an admin user - adminUser := User{ - Email: "admin@example.com", - Role: RoleAdmin, - CompanyID: defaultCompany.ID, - HourlyRate: 100.0, - } - - // Hash a default password - pwData, err := HashPassword("Admin@123456") - if err != nil { - return fmt.Errorf("error hashing password: %w", err) - } - - adminUser.Salt = pwData.Salt - adminUser.Hash = pwData.Hash - - if err := tx.Create(&adminUser).Error; err != nil { - return fmt.Errorf("error creating admin user: %w", err) - } - - log.Println("Database seeding completed successfully") - return nil - }) -} - // GetEngine returns the DB instance, possibly with context func GetEngine(ctx context.Context) *gorm.DB { if defaultDB == nil {