Compare commits

...

6 Commits

19 changed files with 12828 additions and 99 deletions

77
backend/Makefile Normal file
View 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
View 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.

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

View 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"
}
}
}

View 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"

View File

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

View 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!")
}

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

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

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

View File

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

View File

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

View File

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

View File

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

View 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.