Compare commits
	
		
			6 Commits
		
	
	
		
			8785b86bfc
			...
			78be762430
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 78be762430 | |||
| baf656c093 | |||
| 460235b832 | |||
| 9f8eab0fac | |||
| 4b98c1a9e5 | |||
| dde2017ad1 | 
							
								
								
									
										77
									
								
								backend/Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								backend/Makefile
									
									
									
									
									
										Normal file
									
								
							| @ -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" | ||||
							
								
								
									
										147
									
								
								backend/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								backend/README.md
									
									
									
									
									
										Normal file
									
								
							| @ -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. | ||||
							
								
								
									
										62
									
								
								backend/cmd/api/docs/docs.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								backend/cmd/api/docs/docs.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,62 @@ | ||||
| // Package docs Code generated by swaggo/swag. DO NOT EDIT | ||||
| package docs | ||||
| 
 | ||||
| import "github.com/swaggo/swag" | ||||
| 
 | ||||
| const docTemplate = `{ | ||||
|     "schemes": {{ marshal .Schemes }}, | ||||
|     "swagger": "2.0", | ||||
|     "info": { | ||||
|         "description": "{{escape .Description}}", | ||||
|         "title": "{{.Title}}", | ||||
|         "contact": {}, | ||||
|         "version": "{{.Version}}" | ||||
|     }, | ||||
|     "host": "{{.Host}}", | ||||
|     "basePath": "{{.BasePath}}", | ||||
|     "paths": { | ||||
|         "/": { | ||||
|             "get": { | ||||
|                 "description": "Get a hello message", | ||||
|                 "produces": [ | ||||
|                     "text/plain" | ||||
|                 ], | ||||
|                 "summary": "Say hello", | ||||
|                 "operationId": "hello", | ||||
|                 "responses": { | ||||
|                     "200": { | ||||
|                         "description": "Hello from the Time Tracker Backend!", | ||||
|                         "schema": { | ||||
|                             "type": "string" | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     "securityDefinitions": { | ||||
|         "BearerAuth": { | ||||
|             "type": "apiKey", | ||||
|             "name": "Authorization", | ||||
|             "in": "header" | ||||
|         } | ||||
|     } | ||||
| }` | ||||
| 
 | ||||
| // SwaggerInfo holds exported Swagger Info so clients can modify it | ||||
| var SwaggerInfo = &swag.Spec{ | ||||
| 	Version:          "1.0", | ||||
| 	Host:             "localhost:8080", | ||||
| 	BasePath:         "/api", | ||||
| 	Schemes:          []string{}, | ||||
| 	Title:            "Time Tracker API", | ||||
| 	Description:      "This is a simple time tracker API.", | ||||
| 	InfoInstanceName: "swagger", | ||||
| 	SwaggerTemplate:  docTemplate, | ||||
| 	LeftDelim:        "{{", | ||||
| 	RightDelim:       "}}", | ||||
| } | ||||
| 
 | ||||
| func init() { | ||||
| 	swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) | ||||
| } | ||||
							
								
								
									
										38
									
								
								backend/cmd/api/docs/swagger.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								backend/cmd/api/docs/swagger.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | ||||
| { | ||||
|     "swagger": "2.0", | ||||
|     "info": { | ||||
|         "description": "This is a simple time tracker API.", | ||||
|         "title": "Time Tracker API", | ||||
|         "contact": {}, | ||||
|         "version": "1.0" | ||||
|     }, | ||||
|     "host": "localhost:8080", | ||||
|     "basePath": "/api", | ||||
|     "paths": { | ||||
|         "/": { | ||||
|             "get": { | ||||
|                 "description": "Get a hello message", | ||||
|                 "produces": [ | ||||
|                     "text/plain" | ||||
|                 ], | ||||
|                 "summary": "Say hello", | ||||
|                 "operationId": "hello", | ||||
|                 "responses": { | ||||
|                     "200": { | ||||
|                         "description": "Hello from the Time Tracker Backend!", | ||||
|                         "schema": { | ||||
|                             "type": "string" | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     "securityDefinitions": { | ||||
|         "BearerAuth": { | ||||
|             "type": "apiKey", | ||||
|             "name": "Authorization", | ||||
|             "in": "header" | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										26
									
								
								backend/cmd/api/docs/swagger.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								backend/cmd/api/docs/swagger.yaml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,26 @@ | ||||
| basePath: /api | ||||
| host: localhost:8080 | ||||
| info: | ||||
|   contact: {} | ||||
|   description: This is a simple time tracker API. | ||||
|   title: Time Tracker API | ||||
|   version: "1.0" | ||||
| paths: | ||||
|   /: | ||||
|     get: | ||||
|       description: Get a hello message | ||||
|       operationId: hello | ||||
|       produces: | ||||
|       - text/plain | ||||
|       responses: | ||||
|         "200": | ||||
|           description: Hello from the Time Tracker Backend! | ||||
|           schema: | ||||
|             type: string | ||||
|       summary: Say hello | ||||
| securityDefinitions: | ||||
|   BearerAuth: | ||||
|     in: header | ||||
|     name: Authorization | ||||
|     type: apiKey | ||||
| swagger: "2.0" | ||||
| @ -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 | ||||
| ) | ||||
| 
 | ||||
| @ -24,39 +30,76 @@ import ( | ||||
| //	@in							header | ||||
| //	@name						Authorization | ||||
| 
 | ||||
| //	@x-extension	ulid.ULID string | ||||
| 
 | ||||
| //	@Summary		Say hello | ||||
| //	@Description	Get a hello message | ||||
| //	@ID				hello | ||||
| //	@Produce		plain | ||||
| //	@Success		200	{string}	string	"Hello from the Time Tracker Backend!" | ||||
| //	@Router			/ [get] | ||||
| // @Summary		Say hello | ||||
| // @Description	Get a hello message | ||||
| // @ID				hello | ||||
| // @Produce		plain | ||||
| // @Success		200	{string}	string	"Hello from the Time Tracker Backend!" | ||||
| // @Router			/ [get] | ||||
| func helloHandler(c *gin.Context) { | ||||
| 	c.String(http.StatusOK, "Hello from the Time Tracker Backend!") | ||||
| } | ||||
| 
 | ||||
| func main() { | ||||
| 	// Configure database | ||||
| 	dbConfig := models.DatabaseConfig{ | ||||
| 		Host:     "localhost", | ||||
| 		Port:     5432, | ||||
| 		User:     "postgres", | ||||
| 		Password: "password", | ||||
| 		DBName:   "mydatabase", | ||||
| 		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() | ||||
| 
 | ||||
| 	// Basic route for health check | ||||
| 	r.GET("/", helloHandler) | ||||
| 	r.GET("/api", helloHandler) | ||||
| 
 | ||||
| 	// Setup API routes | ||||
| 	routes.SetupRouter(r) | ||||
| @ -64,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") | ||||
| } | ||||
|  | ||||
							
								
								
									
										84
									
								
								backend/cmd/dbtest/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								backend/cmd/dbtest/main.go
									
									
									
									
									
										Normal file
									
								
							| @ -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!") | ||||
| } | ||||
							
								
								
									
										72
									
								
								backend/cmd/migrate/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								backend/cmd/migrate/main.go
									
									
									
									
									
										Normal file
									
								
							| @ -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") | ||||
| } | ||||
							
								
								
									
										207
									
								
								backend/cmd/modeltest/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										207
									
								
								backend/cmd/modeltest/main.go
									
									
									
									
									
										Normal file
									
								
							| @ -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") | ||||
| } | ||||
							
								
								
									
										89
									
								
								backend/cmd/seed/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								backend/cmd/seed/main.go
									
									
									
									
									
										Normal file
									
								
							| @ -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") | ||||
| } | ||||
							
								
								
									
										4534
									
								
								backend/docs/docs.go
									
									
									
									
									
								
							
							
						
						
									
										4534
									
								
								backend/docs/docs.go
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -26,7 +26,7 @@ func NewActivityHandler() *ActivityHandler { | ||||
| //	@Accept			json | ||||
| //	@Produce		json | ||||
| //	@Security		BearerAuth | ||||
| //	@Success		200	{object}	utils.Response{data=[]utils.ActivityResponse} | ||||
| //	@Success		200	{object}	utils.Response{data=[]dto.ActivityDto} | ||||
| //	@Failure		401	{object}	utils.Response{error=utils.ErrorInfo} | ||||
| //	@Failure		500	{object}	utils.Response{error=utils.ErrorInfo} | ||||
| //	@Router			/activities [get] | ||||
| @ -56,7 +56,7 @@ func (h *ActivityHandler) GetActivities(c *gin.Context) { | ||||
| //	@Produce		json | ||||
| //	@Security		BearerAuth | ||||
| //	@Param			id	path		string	true	"Activity ID" | ||||
| //	@Success		200	{object}	utils.Response{data=utils.ActivityResponse} | ||||
| //	@Success		200	{object}	utils.Response{data=dto.ActivityDto} | ||||
| //	@Failure		400	{object}	utils.Response{error=utils.ErrorInfo} | ||||
| //	@Failure		401	{object}	utils.Response{error=utils.ErrorInfo} | ||||
| //	@Failure		404	{object}	utils.Response{error=utils.ErrorInfo} | ||||
|  | ||||
| @ -261,6 +261,52 @@ func (h *UserHandler) Login(c *gin.Context) { | ||||
| 	utils.SuccessResponse(c, http.StatusOK, tokenDTO) | ||||
| } | ||||
| 
 | ||||
| // Register handles POST /auth/register | ||||
| // | ||||
| //	@Summary		Register | ||||
| //	@Description	Register a new user and get a JWT token | ||||
| //	@Tags			auth | ||||
| //	@Accept			json | ||||
| //	@Produce		json | ||||
| //	@Param			user	body		dto.UserCreateDto	true	"User data" | ||||
| //	@Success		201		{object}	utils.Response{data=dto.TokenDto} | ||||
| //	@Failure		400		{object}	utils.Response{error=utils.ErrorInfo} | ||||
| //	@Failure		500		{object}	utils.Response{error=utils.ErrorInfo} | ||||
| //	@Router			/auth/register [post] | ||||
| func (h *UserHandler) Register(c *gin.Context) { | ||||
| 	// Parse request body | ||||
| 	var userCreateDTO dto.UserCreateDto | ||||
| 	if err := c.ShouldBindJSON(&userCreateDTO); err != nil { | ||||
| 		utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// Convert DTO to model | ||||
| 	userCreate := convertCreateDTOToModel(userCreateDTO) | ||||
| 
 | ||||
| 	// Create user in the database | ||||
| 	user, err := models.CreateUser(c.Request.Context(), userCreate) | ||||
| 	if err != nil { | ||||
| 		utils.InternalErrorResponse(c, "Error creating user: "+err.Error()) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// Generate JWT token | ||||
| 	token, err := middleware.GenerateToken(user) | ||||
| 	if err != nil { | ||||
| 		utils.InternalErrorResponse(c, "Error generating token: "+err.Error()) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// Return token | ||||
| 	tokenDTO := dto.TokenDto{ | ||||
| 		Token: token, | ||||
| 		User:  convertUserToDTO(user), | ||||
| 	} | ||||
| 
 | ||||
| 	utils.SuccessResponse(c, http.StatusCreated, tokenDTO) | ||||
| } | ||||
| 
 | ||||
| // GetCurrentUser handles GET /auth/me | ||||
| // | ||||
| //	@Summary		Get current user | ||||
|  | ||||
| @ -16,84 +16,92 @@ func SetupRouter(r *gin.Engine) { | ||||
| 	projectHandler := handlers.NewProjectHandler() | ||||
| 	timeEntryHandler := handlers.NewTimeEntryHandler() | ||||
| 
 | ||||
| 	// Public routes | ||||
| 	r.POST("/auth/login", userHandler.Login) | ||||
| 
 | ||||
| 	// API routes (protected) | ||||
| 	// API routes | ||||
| 	api := r.Group("/api") | ||||
| 	api.Use(middleware.AuthMiddleware()) | ||||
| 	{ | ||||
| 		// Auth routes | ||||
| 		// Auth routes (public) | ||||
| 		auth := api.Group("/auth") | ||||
| 		{ | ||||
| 			auth.GET("/me", userHandler.GetCurrentUser) | ||||
| 			auth.POST("/login", userHandler.Login) | ||||
| 			auth.POST("/register", userHandler.Register) | ||||
| 		} | ||||
| 
 | ||||
| 		// User routes | ||||
| 		users := api.Group("/users") | ||||
| 		// Protected routes | ||||
| 		protected := api.Group("") | ||||
| 		protected.Use(middleware.AuthMiddleware()) | ||||
| 		{ | ||||
| 			users.GET("", userHandler.GetUsers) | ||||
| 			users.GET("/:id", userHandler.GetUserByID) | ||||
| 			users.POST("", middleware.RoleMiddleware("admin"), userHandler.CreateUser) | ||||
| 			users.PUT("/:id", middleware.RoleMiddleware("admin"), userHandler.UpdateUser) | ||||
| 			users.DELETE("/:id", middleware.RoleMiddleware("admin"), userHandler.DeleteUser) | ||||
| 		} | ||||
| 			// Auth routes (protected) | ||||
| 			protectedAuth := protected.Group("/auth") | ||||
| 			{ | ||||
| 				protectedAuth.GET("/me", userHandler.GetCurrentUser) | ||||
| 			} | ||||
| 
 | ||||
| 		// Activity routes | ||||
| 		activities := api.Group("/activities") | ||||
| 		{ | ||||
| 			activities.GET("", activityHandler.GetActivities) | ||||
| 			activities.GET("/:id", activityHandler.GetActivityByID) | ||||
| 			activities.POST("", middleware.RoleMiddleware("admin"), activityHandler.CreateActivity) | ||||
| 			activities.PUT("/:id", middleware.RoleMiddleware("admin"), activityHandler.UpdateActivity) | ||||
| 			activities.DELETE("/:id", middleware.RoleMiddleware("admin"), activityHandler.DeleteActivity) | ||||
| 		} | ||||
| 			// User routes | ||||
| 			users := protected.Group("/users") | ||||
| 			{ | ||||
| 				users.GET("", userHandler.GetUsers) | ||||
| 				users.GET("/:id", userHandler.GetUserByID) | ||||
| 				users.POST("", middleware.RoleMiddleware("admin"), userHandler.CreateUser) | ||||
| 				users.PUT("/:id", middleware.RoleMiddleware("admin"), userHandler.UpdateUser) | ||||
| 				users.DELETE("/:id", middleware.RoleMiddleware("admin"), userHandler.DeleteUser) | ||||
| 			} | ||||
| 
 | ||||
| 		// Company routes | ||||
| 		companies := api.Group("/companies") | ||||
| 		{ | ||||
| 			companies.GET("", companyHandler.GetCompanies) | ||||
| 			companies.GET("/:id", companyHandler.GetCompanyByID) | ||||
| 			companies.POST("", middleware.RoleMiddleware("admin"), companyHandler.CreateCompany) | ||||
| 			companies.PUT("/:id", middleware.RoleMiddleware("admin"), companyHandler.UpdateCompany) | ||||
| 			companies.DELETE("/:id", middleware.RoleMiddleware("admin"), companyHandler.DeleteCompany) | ||||
| 		} | ||||
| 			// Activity routes | ||||
| 			activities := protected.Group("/activities") | ||||
| 			{ | ||||
| 				activities.GET("", activityHandler.GetActivities) | ||||
| 				activities.GET("/:id", activityHandler.GetActivityByID) | ||||
| 				activities.POST("", middleware.RoleMiddleware("admin"), activityHandler.CreateActivity) | ||||
| 				activities.PUT("/:id", middleware.RoleMiddleware("admin"), activityHandler.UpdateActivity) | ||||
| 				activities.DELETE("/:id", middleware.RoleMiddleware("admin"), activityHandler.DeleteActivity) | ||||
| 			} | ||||
| 
 | ||||
| 		// Customer routes | ||||
| 		customers := api.Group("/customers") | ||||
| 		{ | ||||
| 			customers.GET("", customerHandler.GetCustomers) | ||||
| 			customers.GET("/:id", customerHandler.GetCustomerByID) | ||||
| 			customers.GET("/company/:companyId", customerHandler.GetCustomersByCompanyID) | ||||
| 			customers.POST("", middleware.RoleMiddleware("admin"), customerHandler.CreateCustomer) | ||||
| 			customers.PUT("/:id", middleware.RoleMiddleware("admin"), customerHandler.UpdateCustomer) | ||||
| 			customers.DELETE("/:id", middleware.RoleMiddleware("admin"), customerHandler.DeleteCustomer) | ||||
| 		} | ||||
| 			// Company routes | ||||
| 			companies := protected.Group("/companies") | ||||
| 			{ | ||||
| 				companies.GET("", companyHandler.GetCompanies) | ||||
| 				companies.GET("/:id", companyHandler.GetCompanyByID) | ||||
| 				companies.POST("", middleware.RoleMiddleware("admin"), companyHandler.CreateCompany) | ||||
| 				companies.PUT("/:id", middleware.RoleMiddleware("admin"), companyHandler.UpdateCompany) | ||||
| 				companies.DELETE("/:id", middleware.RoleMiddleware("admin"), companyHandler.DeleteCompany) | ||||
| 			} | ||||
| 
 | ||||
| 		// Project routes | ||||
| 		projects := api.Group("/projects") | ||||
| 		{ | ||||
| 			projects.GET("", projectHandler.GetProjects) | ||||
| 			projects.GET("/with-customers", projectHandler.GetProjectsWithCustomers) | ||||
| 			projects.GET("/:id", projectHandler.GetProjectByID) | ||||
| 			projects.GET("/customer/:customerId", projectHandler.GetProjectsByCustomerID) | ||||
| 			projects.POST("", middleware.RoleMiddleware("admin"), projectHandler.CreateProject) | ||||
| 			projects.PUT("/:id", middleware.RoleMiddleware("admin"), projectHandler.UpdateProject) | ||||
| 			projects.DELETE("/:id", middleware.RoleMiddleware("admin"), projectHandler.DeleteProject) | ||||
| 		} | ||||
| 			// Customer routes | ||||
| 			customers := protected.Group("/customers") | ||||
| 			{ | ||||
| 				customers.GET("", customerHandler.GetCustomers) | ||||
| 				customers.GET("/:id", customerHandler.GetCustomerByID) | ||||
| 				customers.GET("/company/:companyId", customerHandler.GetCustomersByCompanyID) | ||||
| 				customers.POST("", middleware.RoleMiddleware("admin"), customerHandler.CreateCustomer) | ||||
| 				customers.PUT("/:id", middleware.RoleMiddleware("admin"), customerHandler.UpdateCustomer) | ||||
| 				customers.DELETE("/:id", middleware.RoleMiddleware("admin"), customerHandler.DeleteCustomer) | ||||
| 			} | ||||
| 
 | ||||
| 		// Time Entry routes | ||||
| 		timeEntries := api.Group("/time-entries") | ||||
| 		{ | ||||
| 			timeEntries.GET("", timeEntryHandler.GetTimeEntries) | ||||
| 			timeEntries.GET("/me", timeEntryHandler.GetMyTimeEntries) | ||||
| 			timeEntries.GET("/range", timeEntryHandler.GetTimeEntriesByDateRange) | ||||
| 			timeEntries.GET("/:id", timeEntryHandler.GetTimeEntryByID) | ||||
| 			timeEntries.GET("/user/:userId", timeEntryHandler.GetTimeEntriesByUserID) | ||||
| 			timeEntries.GET("/project/:projectId", timeEntryHandler.GetTimeEntriesByProjectID) | ||||
| 			timeEntries.POST("", timeEntryHandler.CreateTimeEntry) | ||||
| 			timeEntries.PUT("/:id", timeEntryHandler.UpdateTimeEntry) | ||||
| 			timeEntries.DELETE("/:id", timeEntryHandler.DeleteTimeEntry) | ||||
| 			// Project routes | ||||
| 			projects := protected.Group("/projects") | ||||
| 			{ | ||||
| 				projects.GET("", projectHandler.GetProjects) | ||||
| 				projects.GET("/with-customers", projectHandler.GetProjectsWithCustomers) | ||||
| 				projects.GET("/:id", projectHandler.GetProjectByID) | ||||
| 				projects.GET("/customer/:customerId", projectHandler.GetProjectsByCustomerID) | ||||
| 				projects.POST("", middleware.RoleMiddleware("admin"), projectHandler.CreateProject) | ||||
| 				projects.PUT("/:id", middleware.RoleMiddleware("admin"), projectHandler.UpdateProject) | ||||
| 				projects.DELETE("/:id", middleware.RoleMiddleware("admin"), projectHandler.DeleteProject) | ||||
| 			} | ||||
| 
 | ||||
| 			// Time Entry routes | ||||
| 			timeEntries := protected.Group("/time-entries") | ||||
| 			{ | ||||
| 				timeEntries.GET("", timeEntryHandler.GetTimeEntries) | ||||
| 				timeEntries.GET("/me", timeEntryHandler.GetMyTimeEntries) | ||||
| 				timeEntries.GET("/range", timeEntryHandler.GetTimeEntriesByDateRange) | ||||
| 				timeEntries.GET("/:id", timeEntryHandler.GetTimeEntryByID) | ||||
| 				timeEntries.GET("/user/:userId", timeEntryHandler.GetTimeEntriesByUserID) | ||||
| 				timeEntries.GET("/project/:projectId", timeEntryHandler.GetTimeEntriesByProjectID) | ||||
| 				timeEntries.POST("", timeEntryHandler.CreateTimeEntry) | ||||
| 				timeEntries.PUT("/:id", timeEntryHandler.UpdateTimeEntry) | ||||
| 				timeEntries.DELETE("/:id", timeEntryHandler.DeleteTimeEntry) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -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) | ||||
|  | ||||
							
								
								
									
										16
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | ||||
| services: | ||||
|   db: | ||||
|     image: postgres:14 | ||||
|     container_name: timetracker_db | ||||
|     restart: always | ||||
|     ports: | ||||
|       - "5432:5432" | ||||
|     environment: | ||||
|       POSTGRES_USER: timetracker | ||||
|       POSTGRES_PASSWORD: password | ||||
|       POSTGRES_DB: timetracker | ||||
|     volumes: | ||||
|       - db_data:/var/lib/postgresql/data | ||||
| 
 | ||||
| volumes: | ||||
|   db_data: | ||||
							
								
								
									
										84
									
								
								docu/swagger_documentation.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								docu/swagger_documentation.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,84 @@ | ||||
| # Swagger Documentation | ||||
| 
 | ||||
| This document explains how to access and update the Swagger documentation for the Time Tracker API. | ||||
| 
 | ||||
| ## Accessing Swagger UI | ||||
| 
 | ||||
| After starting the backend server, access the Swagger UI at: | ||||
| ``` | ||||
| http://localhost:8080/swagger/index.html | ||||
| ``` | ||||
| 
 | ||||
| This interactive interface allows you to: | ||||
| - Browse all available API endpoints | ||||
| - See request parameters and response formats | ||||
| - Test API calls directly from the browser | ||||
| 
 | ||||
| ## Updating Swagger Documentation | ||||
| 
 | ||||
| To update the Swagger documentation for the Time Tracker API, follow these steps: | ||||
| 
 | ||||
| 1. **Add or update Swagger annotations in your code** | ||||
|    - Annotations should be added as comments above handler functions | ||||
|    - Use the correct types in annotations (e.g., `dto.ActivityDto` instead of `utils.ActivityResponse`) | ||||
|    - Make sure all parameters, responses, and types are properly documented | ||||
| 
 | ||||
| 2. **Run the Swagger generation command** | ||||
|    ```bash | ||||
|    cd backend && swag init -g cmd/api/main.go --output docs | ||||
|    ``` | ||||
|     | ||||
|    This command: | ||||
|    - Uses `swag` CLI tool to parse your code | ||||
|    - Looks for the main entry point in `cmd/api/main.go` | ||||
|    - Outputs the generated files to the `docs` directory | ||||
| 
 | ||||
| 3. **Verify the generated files** | ||||
|    The command will generate or update three files: | ||||
|    - `docs/docs.go` - Go code for the Swagger documentation | ||||
|    - `docs/swagger.json` - JSON representation of the API | ||||
|    - `docs/swagger.yaml` - YAML representation of the API | ||||
| 
 | ||||
| 4. **Common issues and solutions** | ||||
|    - If you encounter "cannot find type definition" errors, check that you're using the correct type names in your annotations | ||||
|    - If endpoints are missing, ensure they have proper Swagger annotations | ||||
|    - If you change the base path or other global settings, update them in the `main.go` file annotations | ||||
| 
 | ||||
| ## Swagger Annotation Examples | ||||
| 
 | ||||
| ### Main API Information | ||||
| 
 | ||||
| In `main.go`: | ||||
| ```go | ||||
| //	@title						Time Tracker API | ||||
| //	@version					1.0 | ||||
| //	@description				This is a simple time tracker API. | ||||
| //	@host						localhost:8080 | ||||
| //	@BasePath					/api | ||||
| //	@securityDefinitions.apikey	BearerAuth | ||||
| //	@in							header | ||||
| //	@name						Authorization | ||||
| ``` | ||||
| 
 | ||||
| ### Endpoint Documentation | ||||
| 
 | ||||
| Example from a handler function: | ||||
| ```go | ||||
| // GetActivities handles GET /activities | ||||
| // | ||||
| //	@Summary		Get all activities | ||||
| //	@Description	Get a list of all activities | ||||
| //	@Tags			activities | ||||
| //	@Accept			json | ||||
| //	@Produce		json | ||||
| //	@Security		BearerAuth | ||||
| //	@Success		200	{object}	utils.Response{data=[]dto.ActivityDto} | ||||
| //	@Failure		401	{object}	utils.Response{error=utils.ErrorInfo} | ||||
| //	@Failure		500	{object}	utils.Response{error=utils.ErrorInfo} | ||||
| //	@Router			/activities [get] | ||||
| func (h *ActivityHandler) GetActivities(c *gin.Context) { | ||||
|     // Function implementation | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| Remember that the Swagger documentation is generated from the annotations in your code, so keeping these annotations up-to-date is essential for accurate API documentation. | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user