diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..0371fb8 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,77 @@ +# Time Tracker Backend Makefile + +.PHONY: db-start db-stop db-test model-test run build clean migrate seed help + +# Default target +.DEFAULT_GOAL := help + +# Variables +BINARY_NAME=timetracker +DB_CONTAINER=timetracker_db + +# Help target +help: + @echo "Time Tracker Backend Makefile" + @echo "" + @echo "Usage:" + @echo " make db-start - Start the PostgreSQL database container" + @echo " make db-stop - Stop the PostgreSQL database container" + @echo " make db-test - Test the database connection" + @echo " make model-test - Test the database models" + @echo " make run - Run the application" + @echo " make build - Build the application" + @echo " make clean - Remove build artifacts" + @echo " make migrate - Run database migrations" + @echo " make seed - Seed the database with initial data" + @echo " make help - Show this help message" + +# Start the database +db-start: + @echo "Starting PostgreSQL database container..." + @cd .. && docker-compose up -d db + @echo "Database container started" + +# Stop the database +db-stop: + @echo "Stopping PostgreSQL database container..." + @cd .. && docker-compose stop db + @echo "Database container stopped" + +# Test the database connection +db-test: + @echo "Testing database connection..." + @go run cmd/dbtest/main.go + +# Test the database models +model-test: + @echo "Testing database models..." + @go run cmd/modeltest/main.go + +# Run the application +run: + @echo "Running the application..." + @go run cmd/api/main.go + +# Build the application +build: + @echo "Building the application..." + @go build -o $(BINARY_NAME) cmd/api/main.go + @echo "Build complete: $(BINARY_NAME)" + +# Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + @rm -f $(BINARY_NAME) + @echo "Clean complete" + +# Run database migrations +migrate: + @echo "Running database migrations..." + @go run -mod=mod cmd/migrate/main.go + @echo "Migrations complete" + +# Seed the database with initial data +seed: + @echo "Seeding the database..." + @go run -mod=mod cmd/seed/main.go + @echo "Seeding complete" diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..1eb1e64 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,147 @@ +# Time Tracker Backend + +This is the backend service for the Time Tracker application, built with Go, Gin, and GORM. + +## Database Setup + +The application uses PostgreSQL as its database. The database connection is configured using GORM, a popular Go ORM library. + +### Configuration + +Database configuration is handled through the `models.DatabaseConfig` struct in `internal/models/db.go`. The application uses sensible defaults that can be overridden with environment variables: + +- `DB_HOST`: Database host (default: "localhost") +- `DB_PORT`: Database port (default: 5432) +- `DB_USER`: Database user (default: "timetracker") +- `DB_PASSWORD`: Database password (default: "password") +- `DB_NAME`: Database name (default: "timetracker") +- `DB_SSLMODE`: SSL mode (default: "disable") + +### Running with Docker + +The easiest way to run the database is using Docker Compose: + +```bash +# Start the database +docker-compose up -d db + +# Check if the database is running +docker-compose ps +``` + +### Database Migrations + +The application automatically migrates the database schema on startup using GORM's AutoMigrate feature. This creates all necessary tables based on the model definitions. + +### Initial Data Seeding + +The application seeds the database with initial data if it's empty. This includes: + +- A default company +- An admin user with email "admin@example.com" and password "Admin@123456" + +## Running the Application + +### Using Make Commands + +The project includes a Makefile with common commands: + +```bash +# Start the database +make db-start + +# Test the database connection +make db-test + +# Run database migrations +make migrate + +# Seed the database with initial data +make seed + +# Test the database models +make model-test + +# Run the application +make run + +# Build the application +make build + +# Show all available commands +make help +``` + +### Manual Commands + +If you prefer not to use Make, you can run the commands directly: + +```bash +# Start the database +cd /path/to/timetracker +docker-compose up -d db + +# Test the database connection +cd backend +go run cmd/dbtest/main.go + +# Run database migrations +cd backend +go run cmd/migrate/main.go + +# Seed the database with initial data +cd backend +go run cmd/seed/main.go + +# Run the backend application +cd backend +go run cmd/api/main.go +``` + +The API will be available at http://localhost:8080/api and the Swagger documentation at http://localhost:8080/swagger/index.html. + +### Environment Variables + +You can configure the database connection using environment variables: + +```bash +# Example: Connect to a different database +DB_HOST=my-postgres-server DB_PORT=5432 DB_USER=myuser DB_PASSWORD=mypassword DB_NAME=mydb go run cmd/api/main.go +``` + +## Database Models + +The application uses the following models: + +- `User`: Represents a user in the system +- `Company`: Represents a company +- `Customer`: Represents a customer +- `Project`: Represents a project +- `Activity`: Represents an activity +- `TimeEntry`: Represents a time entry + +Each model has corresponding CRUD operations and relationships defined in the `internal/models` directory. + +## GORM Best Practices + +The application follows these GORM best practices: + +1. **Connection Pooling**: Configured with sensible defaults for maximum idle connections, maximum open connections, and connection lifetime. + +2. **Migrations**: Uses GORM's AutoMigrate to automatically create and update database tables. + +3. **Transactions**: Uses transactions for operations that require multiple database changes to ensure data consistency. + +4. **Soft Deletes**: Uses GORM's soft delete feature to mark records as deleted without actually removing them from the database. + +5. **Relationships**: Properly defines relationships between models using GORM's relationship features. + +6. **Error Handling**: Properly handles database errors and returns appropriate error messages. + +7. **Context Support**: Uses context for database operations to support timeouts and cancellation. + +8. **Logging**: Configures GORM's logger for appropriate logging based on the environment. + +9. **Graceful Shutdown**: Properly closes database connections when the application shuts down. + +10. **Validation**: Implements validation for model fields before saving to the database. diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index fefff99..f954e9b 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -1,9 +1,14 @@ package main import ( + "context" "fmt" "log" "net/http" + "os" + "os/signal" + "syscall" + "time" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" @@ -12,6 +17,7 @@ import ( "github.com/timetracker/backend/internal/api/routes" "github.com/timetracker/backend/internal/models" _ "gorm.io/driver/postgres" + "gorm.io/gorm/logger" // GORM IMPORTS MARKER ) @@ -35,20 +41,59 @@ func helloHandler(c *gin.Context) { } func main() { - // Configure database - dbConfig := models.DatabaseConfig{ - Host: "localhost", - Port: 5432, - User: "timetracker", - Password: "password", - DBName: "timetracker", - SSLMode: "disable", // For development environment + // 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 + } + 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 based on environment + if gin.Mode() == gin.ReleaseMode { + dbConfig.LogLevel = logger.Error // Only log errors in production + } else { + dbConfig.LogLevel = logger.Info // Log more in development } // Initialize database if err := models.InitDB(dbConfig); err != nil { log.Fatalf("Error initializing database: %v", err) } + defer func() { + if err := models.CloseDB(); err != nil { + log.Printf("Error closing database connection: %v", err) + } + }() + + // Migrate database schema + if err := models.MigrateDB(); err != nil { + 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() @@ -62,7 +107,34 @@ func main() { // Swagger documentation r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) - // Start server - fmt.Println("Server listening on port 8080") - r.Run(":8080") + // Create a server with graceful shutdown + srv := &http.Server{ + Addr: ":8080", + Handler: r, + } + + // Start server in a goroutine + go func() { + fmt.Println("Server listening on port 8080") + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Error starting server: %v", err) + } + }() + + // Wait for interrupt signal to gracefully shut down the server + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down server...") + + // Create a deadline for server shutdown + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Shutdown the server + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server exited properly") } diff --git a/backend/cmd/dbtest/main.go b/backend/cmd/dbtest/main.go new file mode 100644 index 0000000..191adf2 --- /dev/null +++ b/backend/cmd/dbtest/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/timetracker/backend/internal/models" +) + +func main() { + // Get database configuration with sensible defaults + dbConfig := models.DefaultDatabaseConfig() + + // Initialize database + fmt.Println("Connecting to database...") + if err := models.InitDB(dbConfig); err != nil { + log.Fatalf("Error initializing database: %v", err) + } + defer func() { + if err := models.CloseDB(); err != nil { + log.Printf("Error closing database connection: %v", err) + } + }() + fmt.Println("✓ Database connection successful") + + // Test a simple query + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Get the database engine + db := models.GetEngine(ctx) + + // Test database connection with a simple query + var result int + err := db.Raw("SELECT 1").Scan(&result).Error + if err != nil { + log.Fatalf("Error executing test query: %v", err) + } + fmt.Println("✓ Test query executed successfully") + + // Check if tables exist + fmt.Println("Checking database tables...") + var tables []string + err = db.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'").Scan(&tables).Error + if err != nil { + log.Fatalf("Error checking tables: %v", err) + } + + if len(tables) == 0 { + fmt.Println("No tables found. You may need to run migrations.") + fmt.Println("Attempting to run migrations...") + + // Run migrations + if err := models.MigrateDB(); err != nil { + log.Fatalf("Error migrating database: %v", err) + } + fmt.Println("✓ Migrations completed successfully") + } else { + fmt.Println("Found tables:") + for _, table := range tables { + fmt.Printf(" - %s\n", table) + } + } + + // Count users + var userCount int64 + err = db.Model(&models.User{}).Count(&userCount).Error + if err != nil { + log.Fatalf("Error counting users: %v", err) + } + fmt.Printf("✓ User count: %d\n", userCount) + + // Count companies + var companyCount int64 + err = db.Model(&models.Company{}).Count(&companyCount).Error + if err != nil { + log.Fatalf("Error counting companies: %v", err) + } + fmt.Printf("✓ Company count: %d\n", companyCount) + + fmt.Println("\nDatabase test completed successfully!") +} diff --git a/backend/cmd/migrate/main.go b/backend/cmd/migrate/main.go new file mode 100644 index 0000000..11c7069 --- /dev/null +++ b/backend/cmd/migrate/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/timetracker/backend/internal/models" + "gorm.io/gorm/logger" +) + +func main() { + // Parse command line flags + verbose := false + for _, arg := range os.Args[1:] { + if arg == "--verbose" || arg == "-v" { + verbose = true + } + } + + if verbose { + fmt.Println("Running in verbose mode") + } + + // 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 + } + 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 { + log.Fatalf("Error initializing database: %v", err) + } + defer func() { + if err := models.CloseDB(); err != nil { + log.Printf("Error closing database connection: %v", err) + } + }() + fmt.Println("✓ Database connection successful") + + // Run migrations + fmt.Println("Running database migrations...") + if err := models.MigrateDB(); err != nil { + log.Fatalf("Error migrating database: %v", err) + } + fmt.Println("✓ Database migrations completed successfully") +} diff --git a/backend/cmd/modeltest/main.go b/backend/cmd/modeltest/main.go new file mode 100644 index 0000000..7b69d6a --- /dev/null +++ b/backend/cmd/modeltest/main.go @@ -0,0 +1,207 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/oklog/ulid/v2" + "github.com/timetracker/backend/internal/models" +) + +func main() { + // Get database configuration with sensible defaults + dbConfig := models.DefaultDatabaseConfig() + + // Initialize database + fmt.Println("Connecting to database...") + if err := models.InitDB(dbConfig); err != nil { + log.Fatalf("Error initializing database: %v", err) + } + defer func() { + if err := models.CloseDB(); err != nil { + 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() + + // Test Company model + fmt.Println("\n=== Testing Company Model ===") + testCompanyModel(ctx) + + // Test User model + fmt.Println("\n=== Testing User Model ===") + testUserModel(ctx) + + // Test relationships + fmt.Println("\n=== Testing Relationships ===") + testRelationships(ctx) + + fmt.Println("\nModel tests completed successfully!") +} + +func testCompanyModel(ctx context.Context) { + // Create a new company + companyCreate := models.CompanyCreate{ + Name: "Test Company", + } + + company, err := models.CreateCompany(ctx, companyCreate) + if err != nil { + log.Fatalf("Error creating company: %v", err) + } + fmt.Printf("✓ Created company: %s (ID: %s)\n", company.Name, company.ID) + + // Get the company by ID + retrievedCompany, err := models.GetCompanyByID(ctx, company.ID) + if err != nil { + log.Fatalf("Error getting company: %v", err) + } + if retrievedCompany == nil { + log.Fatalf("Company not found") + } + fmt.Printf("✓ Retrieved company: %s\n", retrievedCompany.Name) + + // Update the company + newName := "Updated Test Company" + companyUpdate := models.CompanyUpdate{ + ID: company.ID, + Name: &newName, + } + + updatedCompany, err := models.UpdateCompany(ctx, companyUpdate) + if err != nil { + log.Fatalf("Error updating company: %v", err) + } + fmt.Printf("✓ Updated company name to: %s\n", updatedCompany.Name) + + // Get all companies + companies, err := models.GetAllCompanies(ctx) + if err != nil { + log.Fatalf("Error getting all companies: %v", err) + } + fmt.Printf("✓ Retrieved %d companies\n", len(companies)) +} + +func testUserModel(ctx context.Context) { + // Get a company to associate with the user + companies, err := models.GetAllCompanies(ctx) + if err != nil || len(companies) == 0 { + log.Fatalf("Error getting companies or no companies found: %v", err) + } + companyID := companies[0].ID + + // Create a new user + userCreate := models.UserCreate{ + Email: "test@example.com", + Password: "Test@123456", + Role: models.RoleUser, + CompanyID: companyID, + HourlyRate: 50.0, + } + + user, err := models.CreateUser(ctx, userCreate) + if err != nil { + log.Fatalf("Error creating user: %v", err) + } + fmt.Printf("✓ Created user: %s (ID: %s)\n", user.Email, user.ID) + + // Get the user by ID + retrievedUser, err := models.GetUserByID(ctx, user.ID) + if err != nil { + log.Fatalf("Error getting user: %v", err) + } + if retrievedUser == nil { + log.Fatalf("User not found") + } + fmt.Printf("✓ Retrieved user: %s\n", retrievedUser.Email) + + // Get the user by email + emailUser, err := models.GetUserByEmail(ctx, user.Email) + if err != nil { + log.Fatalf("Error getting user by email: %v", err) + } + if emailUser == nil { + log.Fatalf("User not found by email") + } + fmt.Printf("✓ Retrieved user by email: %s\n", emailUser.Email) + + // Update the user + newEmail := "updated@example.com" + newRole := models.RoleAdmin + newHourlyRate := 75.0 + userUpdate := models.UserUpdate{ + ID: user.ID, + Email: &newEmail, + Role: &newRole, + HourlyRate: &newHourlyRate, + } + + updatedUser, err := models.UpdateUser(ctx, userUpdate) + if err != nil { + log.Fatalf("Error updating user: %v", err) + } + fmt.Printf("✓ Updated user email to: %s, role to: %s\n", updatedUser.Email, updatedUser.Role) + + // Test authentication + authUser, err := models.AuthenticateUser(ctx, updatedUser.Email, "Test@123456") + if err != nil { + log.Fatalf("Error authenticating user: %v", err) + } + if authUser == nil { + log.Fatalf("Authentication failed") + } + fmt.Printf("✓ User authentication successful\n") + + // Get all users + users, err := models.GetAllUsers(ctx) + if err != nil { + log.Fatalf("Error getting all users: %v", err) + } + fmt.Printf("✓ Retrieved %d users\n", len(users)) + + // Get users by company ID + companyUsers, err := models.GetUsersByCompanyID(ctx, companyID) + if err != nil { + log.Fatalf("Error getting users by company ID: %v", err) + } + fmt.Printf("✓ Retrieved %d users for company ID: %s\n", len(companyUsers), companyID) +} + +func testRelationships(ctx context.Context) { + // Get a user with company + users, err := models.GetAllUsers(ctx) + if err != nil || len(users) == 0 { + log.Fatalf("Error getting users or no users found: %v", err) + } + userID := users[0].ID + + // Get user with company + user, err := models.GetUserWithCompany(ctx, userID) + if err != nil { + log.Fatalf("Error getting user with company: %v", err) + } + if user == nil { + log.Fatalf("User not found") + } + if user.Company == nil { + log.Fatalf("User's company not loaded") + } + fmt.Printf("✓ Retrieved user %s with company %s\n", user.Email, user.Company.Name) + + // Test invalid ID + invalidID := ulid.MustNew(ulid.Timestamp(time.Now()), ulid.DefaultEntropy()) + invalidUser, err := models.GetUserByID(ctx, invalidID) + if err != nil { + log.Fatalf("Error getting user with invalid ID: %v", err) + } + if invalidUser != nil { + log.Fatalf("User found with invalid ID") + } + fmt.Printf("✓ Correctly handled invalid user ID\n") +} diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go new file mode 100644 index 0000000..80f2543 --- /dev/null +++ b/backend/cmd/seed/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/timetracker/backend/internal/models" + "gorm.io/gorm/logger" +) + +func main() { + // Parse command line flags + force := false + for _, arg := range os.Args[1:] { + if arg == "--force" || arg == "-f" { + force = true + } + } + + // 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 + } + 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 { + log.Fatalf("Error initializing database: %v", err) + } + defer func() { + if err := models.CloseDB(); err != nil { + 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 { + log.Fatalf("Error seeding database: %v", err) + } + fmt.Println("✓ Database seeding completed successfully") +} diff --git a/backend/internal/models/db.go b/backend/internal/models/db.go index 0672549..bc81683 100644 --- a/backend/internal/models/db.go +++ b/backend/internal/models/db.go @@ -4,11 +4,14 @@ import ( "context" "errors" "fmt" + "log" "reflect" "strings" + "time" "gorm.io/driver/postgres" // For PostgreSQL "gorm.io/gorm" + "gorm.io/gorm/logger" ) // Global variable for the DB connection @@ -16,12 +19,32 @@ var defaultDB *gorm.DB // DatabaseConfig contains the configuration data for the database connection type DatabaseConfig struct { - Host string - Port int - User string - Password string - DBName string - SSLMode string + Host string + Port int + User string + Password string + DBName string + SSLMode string + MaxIdleConns int // Maximum number of idle connections + MaxOpenConns int // Maximum number of open connections + MaxLifetime time.Duration // Maximum lifetime of a connection + LogLevel logger.LogLevel +} + +// DefaultDatabaseConfig returns a default configuration with sensible values +func DefaultDatabaseConfig() DatabaseConfig { + return DatabaseConfig{ + Host: "localhost", + Port: 5432, + User: "timetracker", + Password: "password", + DBName: "timetracker", + SSLMode: "disable", + MaxIdleConns: 10, + MaxOpenConns: 100, + MaxLifetime: time.Hour, + LogLevel: logger.Info, + } } // InitDB initializes the database connection (once at startup) @@ -31,22 +54,151 @@ func InitDB(config DatabaseConfig) error { dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", config.Host, config.Port, config.User, config.Password, config.DBName, config.SSLMode) - // Establish database connection - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + // Configure GORM logger + gormLogger := logger.New( + log.New(log.Writer(), "\r\n", log.LstdFlags), // io writer + logger.Config{ + SlowThreshold: 200 * time.Millisecond, // Slow SQL threshold + LogLevel: config.LogLevel, // Log level + IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger + Colorful: true, // Enable color + }, + ) + + // Establish database connection with custom logger + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: gormLogger, + }) if err != nil { return fmt.Errorf("error connecting to the database: %w", err) } + // Configure connection pool + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("error getting database connection: %w", err) + } + + // Set connection pool parameters + sqlDB.SetMaxIdleConns(config.MaxIdleConns) + sqlDB.SetMaxOpenConns(config.MaxOpenConns) + sqlDB.SetConnMaxLifetime(config.MaxLifetime) + defaultDB = db return nil } +// MigrateDB performs database migrations for all models +func MigrateDB() error { + if defaultDB == nil { + return errors.New("database not initialized") + } + + log.Println("Starting database migration...") + + // Add all models that should be migrated here + err := defaultDB.AutoMigrate( + &Company{}, + &User{}, + &Customer{}, + &Project{}, + &Activity{}, + &TimeEntry{}, + ) + + if err != nil { + return fmt.Errorf("error migrating database: %w", err) + } + + log.Println("Database migration completed successfully") + 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 { + panic("database not initialized") + } // If a special transaction is in ctx, you could check it here return defaultDB.WithContext(ctx) } +// CloseDB closes the database connection +func CloseDB() error { + if defaultDB == nil { + return nil + } + + sqlDB, err := defaultDB.DB() + if err != nil { + return fmt.Errorf("error getting database connection: %w", err) + } + + if err := sqlDB.Close(); err != nil { + return fmt.Errorf("error closing database connection: %w", err) + } + + return nil +} + // UpdateModel updates a model based on the set pointer fields func UpdateModel(ctx context.Context, model any, updates any) error { updateValue := reflect.ValueOf(updates)