Compare commits

33 Commits

Author SHA1 Message Date
jean b9c900578d refactor: remove repeating code etc 2025-03-12 13:52:34 +00:00
jean 294047a2b0 feat: Refactor activity and project handlers to use utility functions for ID parsing and response handling 2025-03-12 11:38:24 +00:00
jean 1198b326c1 refactor: moved and removed unused code 2025-03-12 11:09:00 +00:00
jean b47c29cf5a feat: Introduce Undefined function for Nullable type and refactor DTOs to use Nullable directly 2025-03-12 11:03:48 +00:00
jean 4170eb5fbd feat: Refactor DTOs to use types.ULID and update companyId fields to be optional 2025-03-12 09:32:29 +00:00
jean 233f3cdb5c feat: Introduce Nullable type for optional fields and update user DTOs accordingly 2025-03-12 08:56:44 +00:00
jean da115dc3f6 feat: Enhance user update handling and introduce NullString type for optional fields 2025-03-12 07:54:00 +00:00
jean 0379ea4ae4 feat: Add TypeScript type generation and update DTOs to use string for IDs 2025-03-12 06:38:41 +00:00
jean 016078c1c3 feat: Add example values to DTOs for improved API documentation 2025-03-12 06:29:10 +00:00
jean c3162756ad feat: Update .env file to comment out JWT configuration and add .gitignore for keys directory 2025-03-11 23:56:35 +00:00
jean 2e13d775fa feat: Implement RSA key generation and initialization for JWT authentication 2025-03-11 23:54:29 +00:00
jean b545392f27 feat: Add JWT configuration to environment and refactor JWT middleware to use new config structure 2025-03-11 23:37:15 +00:00
jean 9057adebdd feat: Update database models and DTOs to use bytea for ULIDWrapper and add JWT configuration to environment 2025-03-11 23:11:49 +00:00
jean c08da6fc92 feat: Add API key middleware and update configuration to support API key validation 2025-03-11 17:20:39 +00:00
jean 165432208c feat: Update JWT token generation to set cookie and modify authentication middleware to use cookie 2025-03-11 17:14:55 +00:00
jean 728258caa7 feat: Update project rules to enhance clarity and efficiency in development practices 2025-03-11 16:46:07 +00:00
jean e336ff3ba2 feat: Update models to use ULIDWrapper for ID handling and adjust related database operations 2025-03-11 16:37:05 +00:00
jean 2555143c0e feat: Load database configuration from a centralized config package and add pgAdmin service to Docker Compose 2025-03-11 15:11:20 +00:00
jean ec250570a6 feat: Refactor database configuration loading and seeding logic for improved clarity and maintainability 2025-03-11 12:35:04 +00:00
jean a0b0b98624 feat: Remove outdated Swagger documentation files and update database configuration handling 2025-03-11 11:21:15 +00:00
jean 09584efa39 feat: Add project rules and guidelines for architecture, coding practices, security, documentation, testing, frontend, devops, and development workflow 2025-03-11 10:36:34 +00:00
jean 78be762430 feat: Add database migration, seeding, and testing commands with Makefile integration 2025-03-11 09:10:35 +00:00
jean baf656c093 feat: Refactor API routes to separate public and protected endpoints for better organization 2025-03-10 22:54:54 +00:00
jean 460235b832 feat: Add user registration endpoint with JWT token generation and update API documentation 2025-03-10 22:44:16 +00:00
jean 9f8eab0fac feat: Update API routes and enhance Swagger documentation for activity handler 2025-03-10 22:40:10 +00:00
jean 4b98c1a9e5 feat: Add Swagger documentation for Time Tracker API with hello endpoint 2025-03-10 22:27:36 +00:00
jean dde2017ad1 feat: Add Docker Compose configuration for PostgreSQL and update database credentials in main.go 2025-03-10 22:25:03 +00:00
jean 8785b86bfc feat: Implement company management API endpoints and handler 2025-03-10 22:03:13 +00:00
jean 58173b436c refactor: Update DTOs to use string type for IDs and enhance API documentation for activity and user handlers 2025-03-10 21:43:45 +00:00
jean 558ee70c21 feat: Add authentication DTOs and setup API routes for user and activity management 2025-03-10 21:02:41 +00:00
jean aa5c7e77fc refactor: Remove deprecated, bad examples 2025-03-10 20:48:13 +00:00
jean ce39b7ba34 refactor: Update user model to use 'any' type for updates and adjust DTO path in configuration 2025-03-10 20:45:41 +00:00
jean d1720ea33d refactor: Update comments to English for consistency across models 2025-03-10 10:11:04 +00:00
68 changed files with 16440 additions and 1338 deletions
+48
View File
@@ -0,0 +1,48 @@
# TimeTracker Project Rules (v2)
0. GENERAL
DONT OVERENGINEER.
USE IN LINE REPLACEMENTS IF POSSIBLE.
SOLVE TASKS AS FAST AS POSSIBLE. EACH REQUEST COSTS THE USER MONEY.
1. ARCHITECTURE
- Multi-tenancy enforced via company_id in all DB queries
2. CODING PRACTICES
- Type safety enforced (Go 1.21+ generics, TypeScript strict mode)
- Domain types must match across backend (Go) and frontend (TypeScript)
- All database access through repository interfaces
- API handlers must use DTOs for input/output
- Use tygo to generate TypeScript types after modifying Go types
3. SECURITY
- JWT authentication required for all API endpoints
- RBAC implemented in middleware/auth.go
- Input validation using github.com/go-playground/validator
- No raw SQL - use GORM query builder
4. DOCUMENTATION
- Architecture decisions recorded in docu/ARCHITECTURE.md
- Type relationships documented in docu/domain_types.md
5. TESTING
- 80%+ test coverage for domain logic
- Integration tests for API endpoints
- Model tests in backend/cmd/modeltest
6. FRONTEND
- Next.js App Router pattern required
8. DEVELOPMENT WORKFLOW
- Makefile commands are only available in the backend folder
- Common make commands:
- make generate: Run code generation (tygo, swagger, etc.)
- make test: Run all tests
- make build: Build the application
- make run: Start the development server
9. CUSTOM RULES
- Add custom rules to .clinerules if:
- Unexpected behavior is encountered
- Specific conditions require warnings
- New patterns emerge that need documentation
- DO NOT FIX UNUSED IMPORTS - this is the job of the linter
10.Implement a REST API update handling in Go using Gin that ensures the following behavior:
- The update request is received as JSON.
- If a field is present in the JSON and set to null, the corresponding value in the database should be removed.
- If a field is missing in the JSON, it should not be modified.
- If a field is present in the JSON and not null, it should be updated.
- Use either a struct or a map to handle the JSON data.
- Ensure the update logic is robust and does not unintentionally remove or overwrite fields.
- Optional: Handle error cases like invalid JSON and return appropriate HTTP status codes.
+14
View File
@@ -0,0 +1,14 @@
DB_HOST=localhost
DB_PORT=5432
DB_USER=timetracker
DB_PASSWORD=password
DB_NAME=timetracker
DB_SSLMODE=disable
API_KEY=
# JWT Configuration
#JWT_SECRET=test
#JWT_KEY_DIR=keys
#JWT_KEY_GENERATE=true
JWT_TOKEN_DURATION=24h
ENVIRONMENT=production
+1
View File
@@ -0,0 +1 @@
keys
+104
View File
@@ -0,0 +1,104 @@
# Time Tracker Backend Makefile
.PHONY: db-start db-stop db-test model-test run build clean migrate seed swagger 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 db-drop-users - Drop the users table"
@echo " make db-reinit - Re-initialize the database"
@echo " make swagger - Generate swagger documentation"
@echo " make help - Show this help message"
@echo "" make generate-ts - Generate TypeScript types
# 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"
# Drop the users table
db-drop-users:
@echo "Dropping the users table..."
@export PG_HOST=$(DB_HOST); export PG_PORT=$(DB_PORT); export PG_USER=$(DB_USER); export PG_PASSWORD=$(DB_PASSWORD); export PG_DBNAME=$(DB_NAME); go run cmd/dbtest/main.go -drop_table=users
@echo "Users table dropped"
# Re-initialize the database
db-reinit:
@echo "Re-initializing the database..."
@PG_HOST=$(DB_HOST) PG_PORT=$(DB_PORT) PG_USER=$(DB_USER) PG_PASSWORD=$(DB_PASSWORD) PG_DBNAME=$(DB_NAME) go run cmd/migrate/main.go -create_db -drop_db
# Generate swagger documentation
swagger:
@echo "Generating swagger documentation..."
@swag init -g cmd/api/main.go
@echo "Swagger documentation generated"
# Generate TypeScript types
generate-ts:
@echo "Generating TypeScript types..."
@go run scripts/fix_tygo.go
@echo "TypeScript types generated"
+147
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.
+83 -27
View File
@@ -1,55 +1,111 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files" swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger" ginSwagger "github.com/swaggo/gin-swagger"
_ "github.com/timetracker/backend/docs" // This line is important for swag to work _ "github.com/timetracker/backend/docs"
"github.com/timetracker/backend/internal/api/middleware"
"github.com/timetracker/backend/internal/api/routes"
"github.com/timetracker/backend/internal/config"
"github.com/timetracker/backend/internal/models" "github.com/timetracker/backend/internal/models"
_ "gorm.io/driver/postgres" _ "gorm.io/driver/postgres"
// GORM IMPORTS MARKER
) )
// @title Time Tracker API // @title Time Tracker API
// @version 1.0 // @version 1.0
// @description This is a simple time tracker API. // @description This is a simple time tracker API.
// @host localhost:8080 // @host localhost:8080
// @BasePath / // @BasePath /api
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @Summary Say hello // @Summary Say hello
// @Description Get a hello message // @Description Get a hello message
// @ID hello // @ID hello
// @Produce plain // @Produce plain
// @Success 200 {string} string "Hello from the Time Tracker Backend!" // @Success 200 {string} string "Hello from the Time Tracker Backend!"
// @Router / [get] // @Router / [get]
func helloHandler(c *gin.Context) { func helloHandler(c *gin.Context) {
c.String(http.StatusOK, "Hello from the Time Tracker Backend!") c.String(http.StatusOK, "Hello from the Time Tracker Backend!")
} }
func main() { func main() {
cfg, err := config.LoadConfig()
dbConfig := models.DatabaseConfig{ if err != nil {
Host: "localhost", log.Fatalf("Failed to load config: %v", err)
Port: 5432,
User: "postgres",
Password: "password",
DBName: "mydatabase",
SSLMode: "disable", // Für Entwicklungsumgebung
} }
// Datenbank initialisieren // Initialize database
if err := models.InitDB(dbConfig); err != nil { if err := models.InitDB(cfg.Database); err != nil {
log.Fatalf("Fehler bei der DB-Initialisierung: %v", err) 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)
}
// Initialize JWT keys
if err := middleware.InitJWTKeys(); err != nil {
log.Fatalf("Error initializing JWT keys: %v", err)
}
// Create Gin router
r := gin.Default() r := gin.Default()
r.GET("/", helloHandler) // Basic route for health check
r.GET("/api", helloHandler)
// Setup API routes
routes.SetupRouter(r, cfg)
// Swagger documentation
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
fmt.Println("Server listening on port 8080") // Create a server with graceful shutdown
r.Run(":8080") // Use Gin's Run method 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")
} }
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"time"
"github.com/timetracker/backend/internal/models"
)
func main() {
dropTable := flag.String("drop_table", "", "Drop the specified table")
flag.Parse()
// 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
var err error
if *dropTable != "" {
fmt.Printf("Dropping table %s...\n", *dropTable)
dropErr := db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", *dropTable)).Error
if dropErr != nil {
log.Fatalf("Error dropping table %s: %v", *dropTable, dropErr)
}
fmt.Printf("✓ Table %s dropped successfully\n", *dropTable)
return
}
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!")
}
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"github.com/timetracker/backend/internal/models"
"gorm.io/gorm/logger"
)
func main() {
// Parse command line flags
verbose := false
dropDB := flag.Bool("drop_db", false, "Drop the database before migrating")
createDB := flag.Bool("create_db", false, "Create the database if it doesn't exist")
flag.Parse()
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...")
var err error
gormDB, err := models.GetGormDB(dbConfig, "postgres")
if err != nil {
log.Fatalf("Error getting gorm DB: %v", err)
}
sqlDB, err := gormDB.DB()
if err != nil {
log.Fatalf("Error getting sql DB: %v", err)
}
if *dropDB {
fmt.Printf("Dropping database %s...\n", dbConfig.DBName)
_, err = sqlDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbConfig.DBName))
if err != nil {
log.Fatalf("Error dropping database %s: %v", dbConfig.DBName, err)
}
fmt.Printf("✓ Database %s dropped successfully\n", dbConfig.DBName)
}
if *createDB {
fmt.Printf("Creating database %s...\n", dbConfig.DBName)
_, err = sqlDB.Exec(fmt.Sprintf("CREATE DATABASE %s", dbConfig.DBName))
if err != nil {
log.Fatalf("Error creating database %s: %v", dbConfig.DBName, err)
}
fmt.Printf("✓ Database %s created successfully\n", dbConfig.DBName)
}
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")
}
+208
View File
@@ -0,0 +1,208 @@
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/oklog/ulid/v2"
"github.com/timetracker/backend/internal/models"
"github.com/timetracker/backend/internal/types"
)
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, types.FromULID(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")
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"github.com/timetracker/backend/internal/config"
"github.com/timetracker/backend/internal/models"
"gorm.io/gorm"
)
func main() {
// Parse CLI flags
_ = flag.String("config", "", "Path to .env config file")
flag.Parse()
// Load configuration
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Initialize database
if err := models.InitDB(cfg.Database); 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)
}
}()
// Execute seed operation
if err := seedDatabase(context.Background()); err != nil {
log.Fatalf("Error seeding database: %v", err)
}
log.Println("Database seeding completed successfully")
}
// seedDatabase performs the database seeding operation
func seedDatabase(ctx context.Context) error {
// Check if seeding is needed
var count int64
if err := models.GetEngine(ctx).Model(&models.Company{}).Count(&count).Error; err != nil {
return fmt.Errorf("error checking if seeding is needed: %w", err)
}
// If data exists, skip seeding
if count > 0 {
log.Println("Database already contains data, skipping seeding")
return nil
}
log.Println("Seeding database with initial data...")
// Start transaction
return models.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
// Create default company
defaultCompany := models.Company{
Name: "Default Company",
}
if err := tx.Create(&defaultCompany).Error; err != nil {
return fmt.Errorf("error creating default company: %w", err)
}
// Create admin user
adminUser := models.User{
Email: "admin@example.com",
Role: models.RoleAdmin,
CompanyID: &defaultCompany.ID,
HourlyRate: 100.0,
}
// Hash password
pwData, err := models.HashPassword("Admin@123456")
if err != nil {
return fmt.Errorf("error hashing password: %w", err)
}
adminUser.Salt = pwData.Salt
adminUser.Hash = pwData.Hash
if err := tx.Create(&adminUser).Error; err != nil {
return fmt.Errorf("error creating admin user: %w", err)
}
return nil
})
}
+4640 -1
View File
File diff suppressed because it is too large Load Diff
+4640 -1
View File
File diff suppressed because it is too large Load Diff
+2595 -1
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -4,6 +4,7 @@ go 1.23.6
require ( require (
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/joho/godotenv v1.5.1
github.com/oklog/ulid/v2 v2.1.0 github.com/oklog/ulid/v2 v2.1.0
github.com/swaggo/files v1.0.1 github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/gin-swagger v1.6.0
@@ -12,6 +13,7 @@ require (
) )
require ( require (
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect
+4
View File
@@ -37,6 +37,8 @@ github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -52,6 +54,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+28
View File
@@ -0,0 +1,28 @@
package dto
import (
"time"
)
type ActivityDto struct {
ID string `json:"id" example:"a1b2c3d4e5f6"`
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
LastEditorID string `json:"lastEditorID" example:"u1v2w3x4y5z6"`
Name string `json:"name" example:"Development"`
BillingRate float64 `json:"billingRate" example:"100.00"`
}
type ActivityCreateDto struct {
Name string `json:"name" example:"Development"`
BillingRate float64 `json:"billingRate" example:"100.00"`
}
type ActivityUpdateDto struct {
ID string `json:"id" example:"a1b2c3d4e5f6"`
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
LastEditorID *string `json:"lastEditorID" example:"u1v2w3x4y5z6"`
Name *string `json:"name" example:"Development"`
BillingRate *float64 `json:"billingRate" example:"100.00"`
}
+13
View File
@@ -0,0 +1,13 @@
package dto
// LoginDto represents the login request
type LoginDto struct {
Email string `json:"email" example:"admin@example.com"`
Password string `json:"password" example:"Admin@123456"`
}
// TokenDto represents the response after successful authentication
type TokenDto struct {
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"`
User UserDto `json:"user"`
}
+23
View File
@@ -0,0 +1,23 @@
package dto
import (
"time"
)
type CompanyDto struct {
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
Name string `json:"name" example:"Acme Corp"`
}
type CompanyCreateDto struct {
Name string `json:"name" example:"Acme Corp"`
}
type CompanyUpdateDto struct {
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
Name *string `json:"name" example:"Acme Corp"`
}
+32
View File
@@ -0,0 +1,32 @@
package dto
import (
"time"
"github.com/timetracker/backend/internal/types"
)
type CustomerDto struct {
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
Name string `json:"name" example:"John Doe"`
CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"`
OwnerUserID *string `json:"owningUserID" example:"01HGW2BBG0000000000000000"`
}
type CustomerCreateDto struct {
Name string `json:"name" example:"John Doe"`
CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"`
}
type CustomerUpdateDto struct {
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
LastEditorID *string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
Name *string `json:"name" example:"John Doe"`
CompanyID types.Nullable[string] `json:"companyId" example:"01HGW2BBG0000000000000000"`
OwnerUserID types.Nullable[string] `json:"owningUserID" example:"01HGW2BBG0000000000000000"`
}
+28
View File
@@ -0,0 +1,28 @@
package dto
import (
"time"
"github.com/timetracker/backend/internal/types"
)
type ProjectDto struct {
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
Name string `json:"name" example:"Time Tracking App"`
CustomerID *string `json:"customerId" example:"01HGW2BBG0000000000000000"`
}
type ProjectCreateDto struct {
Name string `json:"name" example:"Time Tracking App"`
CustomerID *string `json:"customerId" example:"01HGW2BBG0000000000000000"`
}
type ProjectUpdateDto struct {
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
Name *string `json:"name" example:"Time Tracking App"`
CustomerID types.Nullable[string] `json:"customerId" example:"01HGW2BBG0000000000000000"`
}
+41
View File
@@ -0,0 +1,41 @@
package dto
import (
"time"
)
type TimeEntryDto struct {
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
UserID string `json:"userId" example:"01HGW2BBG0000000000000000"`
ProjectID string `json:"projectId" example:"01HGW2BBG0000000000000000"`
ActivityID string `json:"activityId" example:"01HGW2BBG0000000000000000"`
Start time.Time `json:"start" example:"2024-01-01T08:00:00Z"`
End time.Time `json:"end" example:"2024-01-01T17:00:00Z"`
Description string `json:"description" example:"Working on the Time Tracking App"`
Billable int `json:"billable" example:"100"` // Percentage (0-100)
}
type TimeEntryCreateDto struct {
UserID string `json:"userId" example:"01HGW2BBG0000000000000000"`
ProjectID string `json:"projectId" example:"01HGW2BBG0000000000000000"`
ActivityID string `json:"activityId" example:"01HGW2BBG0000000000000000"`
Start time.Time `json:"start" example:"2024-01-01T08:00:00Z"`
End time.Time `json:"end" example:"2024-01-01T17:00:00Z"`
Description string `json:"description" example:"Working on the Time Tracking App"`
Billable int `json:"billable" example:"100"` // Percentage (0-100)
}
type TimeEntryUpdateDto struct {
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
UserID *string `json:"userId" example:"01HGW2BBG0000000000000000"`
ProjectID *string `json:"projectId" example:"01HGW2BBG0000000000000000"`
ActivityID *string `json:"activityId" example:"01HGW2BBG0000000000000000"`
Start *time.Time `json:"start" example:"2024-01-01T08:00:00Z"`
End *time.Time `json:"end" example:"2024-01-01T17:00:00Z"`
Description *string `json:"description" example:"Working on the Time Tracking App"`
Billable *int `json:"billable" example:"100"` // Percentage (0-100)
}
+37
View File
@@ -0,0 +1,37 @@
package dto
import (
"time"
"github.com/timetracker/backend/internal/types"
)
type UserDto struct {
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
Email string `json:"email" example:"test@example.com"`
Role string `json:"role" example:"admin"`
CompanyID *string `json:"companyId,omitempty" example:"01HGW2BBG0000000000000000"`
HourlyRate float64 `json:"hourlyRate" example:"50.00"`
}
type UserCreateDto struct {
Email string `json:"email" example:"test@example.com"`
Password string `json:"password" example:"password123"`
Role string `json:"role" example:"admin"`
CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"`
HourlyRate float64 `json:"hourlyRate" example:"50.00"`
}
type UserUpdateDto struct {
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
LastEditorID *string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
Email *string `json:"email" example:"test@example.com"`
Password *string `json:"password" example:"password123"`
Role *string `json:"role" example:"admin"`
CompanyID types.Nullable[string] `json:"companyId" example:"01HGW2BBG0000000000000000"`
HourlyRate *float64 `json:"hourlyRate" example:"50.00"`
}
@@ -0,0 +1,180 @@
package handlers
import (
"context"
"github.com/gin-gonic/gin"
"github.com/oklog/ulid/v2"
"github.com/timetracker/backend/internal/api/dto"
"github.com/timetracker/backend/internal/api/responses"
"github.com/timetracker/backend/internal/api/utils"
"github.com/timetracker/backend/internal/models"
"github.com/timetracker/backend/internal/types"
)
// ActivityHandler handles activity-related API endpoints
type ActivityHandler struct{}
// NewActivityHandler creates a new ActivityHandler
func NewActivityHandler() *ActivityHandler {
return &ActivityHandler{}
}
// 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) {
utils.HandleGetAll(c, models.GetAllActivities, convertActivityToDTO, "activities")
}
// GetActivityByID handles GET /activities/:id
//
// @Summary Get activity by ID
// @Description Get an activity by its ID
// @Tags activities
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Activity ID"
// @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}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /activities/{id} [get]
func (h *ActivityHandler) GetActivityByID(c *gin.Context) {
utils.HandleGetByID(c, models.GetActivityByID, convertActivityToDTO, "activity")
}
// CreateActivity handles POST /activities
//
// @Summary Create a new activity
// @Description Create a new activity
// @Tags activities
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param activity body dto.ActivityCreateDto true "Activity data"
// @Success 201 {object} utils.Response{data=dto.ActivityDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /activities [post]
func (h *ActivityHandler) CreateActivity(c *gin.Context) {
utils.HandleCreate(c, createActivityWrapper, convertActivityToDTO, "activity")
}
// UpdateActivity handles PUT /activities/:id
//
// @Summary Update an activity
// @Description Update an existing activity
// @Tags activities
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Activity ID"
// @Param activity body dto.ActivityUpdateDto true "Activity data"
// @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}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /activities/{id} [put]
func (h *ActivityHandler) UpdateActivity(c *gin.Context) {
utils.HandleUpdate(c, models.UpdateActivity, convertActivityToDTO, prepareActivityUpdate, "activity")
}
// DeleteActivity handles DELETE /activities/:id
//
// @Summary Delete an activity
// @Description Delete an activity by its ID
// @Tags activities
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Activity ID"
// @Success 204 {object} utils.Response
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /activities/{id} [delete]
func (h *ActivityHandler) DeleteActivity(c *gin.Context) {
utils.HandleDelete(c, models.DeleteActivity, "activity")
}
// Helper functions for DTO conversion
func convertActivityToDTO(activity *models.Activity) dto.ActivityDto {
return dto.ActivityDto{
ID: activity.ID.String(),
CreatedAt: activity.CreatedAt,
UpdatedAt: activity.UpdatedAt,
Name: activity.Name,
BillingRate: activity.BillingRate,
}
}
func convertCreateActivityDTOToModel(dto dto.ActivityCreateDto) models.ActivityCreate {
return models.ActivityCreate{
Name: dto.Name,
BillingRate: dto.BillingRate,
}
}
func convertUpdateActivityDTOToModel(dto dto.ActivityUpdateDto) models.ActivityUpdate {
id, _ := ulid.Parse(dto.ID)
update := models.ActivityUpdate{
ID: types.FromULID(id),
}
if dto.Name != nil {
update.Name = dto.Name
}
if dto.BillingRate != nil {
update.BillingRate = dto.BillingRate
}
return update
}
// prepareActivityUpdate prepares the activity update object by parsing the ID, binding the JSON, and converting the DTO to a model
func prepareActivityUpdate(c *gin.Context) (models.ActivityUpdate, error) {
// Parse ID from URL
id, err := utils.ParseID(c, "id")
if err != nil {
responses.BadRequestResponse(c, "Invalid activity ID format")
return models.ActivityUpdate{}, err
}
// Parse request body
var activityUpdateDTO dto.ActivityUpdateDto
if err := utils.BindJSON(c, &activityUpdateDTO); err != nil {
responses.BadRequestResponse(c, err.Error())
return models.ActivityUpdate{}, err
}
// Set ID from URL
activityUpdateDTO.ID = id.String()
// Convert DTO to model
return convertUpdateActivityDTOToModel(activityUpdateDTO), nil
}
// createActivityWrapper is a wrapper function for models.CreateActivity that takes a DTO as input
func createActivityWrapper(ctx context.Context, createDTO dto.ActivityCreateDto) (*models.Activity, error) {
// Convert DTO to model
activityCreate := convertCreateActivityDTOToModel(createDTO)
// Call the original function
return models.CreateActivity(ctx, activityCreate)
}
@@ -0,0 +1,169 @@
package handlers
import (
"context"
"github.com/gin-gonic/gin"
"github.com/timetracker/backend/internal/api/dto"
"github.com/timetracker/backend/internal/api/responses"
"github.com/timetracker/backend/internal/api/utils"
"github.com/timetracker/backend/internal/models"
"github.com/timetracker/backend/internal/types"
)
// CompanyHandler handles company-related API endpoints
type CompanyHandler struct{}
// NewCompanyHandler creates a new CompanyHandler
func NewCompanyHandler() *CompanyHandler {
return &CompanyHandler{}
}
// GetCompanies handles GET /companies
//
// @Summary Get all companies
// @Description Get a list of all companies
// @Tags companies
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} utils.Response{data=[]dto.CompanyDto}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /companies [get]
func (h *CompanyHandler) GetCompanies(c *gin.Context) {
utils.HandleGetAll(c, models.GetAllCompanies, convertCompanyToDTO, "companies")
}
// GetCompanyByID handles GET /companies/:id
//
// @Summary Get company by ID
// @Description Get a company by its ID
// @Tags companies
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Company ID"
// @Success 200 {object} utils.Response{data=dto.CompanyDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 404 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /companies/{id} [get]
func (h *CompanyHandler) GetCompanyByID(c *gin.Context) {
utils.HandleGetByID(c, models.GetCompanyByID, convertCompanyToDTO, "company")
}
// CreateCompany handles POST /companies
//
// @Summary Create a new company
// @Description Create a new company
// @Tags companies
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param company body dto.CompanyCreateDto true "Company data"
// @Success 201 {object} utils.Response{data=dto.CompanyDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /companies [post]
func (h *CompanyHandler) CreateCompany(c *gin.Context) {
utils.HandleCreate(c, createCompanyWrapper, convertCompanyToDTO, "company")
}
// UpdateCompany handles PUT /companies/:id
//
// @Summary Update a company
// @Description Update an existing company
// @Tags companies
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Company ID"
// @Param company body dto.CompanyUpdateDto true "Company data"
// @Success 200 {object} utils.Response{data=dto.CompanyDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 404 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /companies/{id} [put]
func (h *CompanyHandler) UpdateCompany(c *gin.Context) {
utils.HandleUpdate(c, models.UpdateCompany, convertCompanyToDTO, prepareCompanyUpdate, "company")
}
// DeleteCompany handles DELETE /companies/:id
//
// @Summary Delete a company
// @Description Delete a company by its ID
// @Tags companies
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Company ID"
// @Success 204 {object} utils.Response
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /companies/{id} [delete]
func (h *CompanyHandler) DeleteCompany(c *gin.Context) {
utils.HandleDelete(c, models.DeleteCompany, "company")
}
// Helper functions for DTO conversion
func convertCompanyToDTO(company *models.Company) dto.CompanyDto {
return dto.CompanyDto{
ID: company.ID.String(),
CreatedAt: company.CreatedAt,
UpdatedAt: company.UpdatedAt,
Name: company.Name,
}
}
func convertCreateCompanyDTOToModel(dto dto.CompanyCreateDto) models.CompanyCreate {
return models.CompanyCreate{
Name: dto.Name,
}
}
func convertUpdateCompanyDTOToModel(dto dto.CompanyUpdateDto, id types.ULID) models.CompanyUpdate {
update := models.CompanyUpdate{
ID: id,
}
if dto.Name != nil {
update.Name = dto.Name
}
return update
}
// prepareCompanyUpdate prepares the company update object by parsing the ID, binding the JSON, and converting the DTO to a model
func prepareCompanyUpdate(c *gin.Context) (models.CompanyUpdate, error) {
// Parse ID from URL
id, err := utils.ParseID(c, "id")
if err != nil {
responses.BadRequestResponse(c, "Invalid company ID format")
return models.CompanyUpdate{}, err
}
// Parse request body
var companyUpdateDTO dto.CompanyUpdateDto
if err := utils.BindJSON(c, &companyUpdateDTO); err != nil {
responses.BadRequestResponse(c, err.Error())
return models.CompanyUpdate{}, err
}
// Convert DTO to model
return convertUpdateCompanyDTOToModel(companyUpdateDTO, id), nil
}
// createCompanyWrapper is a wrapper function for models.CreateCompany that takes a DTO as input
func createCompanyWrapper(ctx context.Context, createDTO dto.CompanyCreateDto) (*models.Company, error) {
// Convert DTO to model
companyCreate := convertCreateCompanyDTOToModel(createDTO)
// Call the original function
return models.CreateCompany(ctx, companyCreate)
}
@@ -0,0 +1,278 @@
package handlers
import (
"context"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/timetracker/backend/internal/api/dto"
"github.com/timetracker/backend/internal/api/middleware"
"github.com/timetracker/backend/internal/api/responses"
"github.com/timetracker/backend/internal/api/utils"
"github.com/timetracker/backend/internal/models"
"github.com/timetracker/backend/internal/types"
)
// CustomerHandler handles customer-related API endpoints
type CustomerHandler struct{}
// NewCustomerHandler creates a new CustomerHandler
func NewCustomerHandler() *CustomerHandler {
return &CustomerHandler{}
}
// GetCustomers handles GET /customers
//
// @Summary Get all customers
// @Description Get a list of all customers
// @Tags customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} utils.Response{data=[]dto.CustomerDto}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /customers [get]
func (h *CustomerHandler) GetCustomers(c *gin.Context) {
utils.HandleGetAll(c, models.GetAllCustomers, convertCustomerToDTO, "customers")
}
// GetCustomerByID handles GET /customers/:id
//
// @Summary Get customer by ID
// @Description Get a customer by its ID
// @Tags customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Customer ID"
// @Success 200 {object} utils.Response{data=dto.CustomerDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 404 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /customers/{id} [get]
func (h *CustomerHandler) GetCustomerByID(c *gin.Context) {
utils.HandleGetByID(c, models.GetCustomerByID, convertCustomerToDTO, "customer")
}
// GetCustomersByCompanyID handles GET /customers/company/:companyId
//
// @Summary Get customers by company ID
// @Description Get a list of customers for a specific company
// @Tags customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param companyId path int true "Company ID"
// @Success 200 {object} utils.Response{data=[]dto.CustomerDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /customers/company/{companyId} [get]
func (h *CustomerHandler) GetCustomersByCompanyID(c *gin.Context) {
// Parse company ID from URL
companyIDStr := c.Param("companyId")
companyID, err := parseCompanyID(companyIDStr)
if err != nil {
responses.BadRequestResponse(c, "Invalid company ID format")
return
}
// Create a wrapper function that takes a ULID but calls the original function with an int
getByCompanyIDFn := func(ctx context.Context, _ types.ULID) ([]models.Customer, error) {
return models.GetCustomersByCompanyID(ctx, companyID)
}
// Get customers from the database and convert to DTOs
customers, err := getByCompanyIDFn(c.Request.Context(), types.ULID{})
if err != nil {
responses.InternalErrorResponse(c, "Error retrieving customers: "+err.Error())
return
}
// Convert to DTOs
customerDTOs := utils.ConvertToDTO(customers, convertCustomerToDTO)
responses.SuccessResponse(c, http.StatusOK, customerDTOs)
}
// CreateCustomer handles POST /customers
//
// @Summary Create a new customer
// @Description Create a new customer
// @Tags customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param customer body dto.CustomerCreateDto true "Customer data"
// @Success 201 {object} utils.Response{data=dto.CustomerDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /customers [post]
func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
// We need to use a custom wrapper for CreateCustomer because we need to get the user ID from the context
userID, err := middleware.GetUserIDFromContext(c)
if err != nil {
responses.UnauthorizedResponse(c, "User not authenticated")
return
}
// Use a closure to capture the userID
createFn := func(ctx context.Context, createDTO dto.CustomerCreateDto) (*models.Customer, error) {
return createCustomerWrapper(ctx, createDTO, userID)
}
utils.HandleCreate(c, createFn, convertCustomerToDTO, "customer")
}
// UpdateCustomer handles PUT /customers/:id
//
// @Summary Update a customer
// @Description Update an existing customer
// @Tags customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Customer ID"
// @Param customer body dto.CustomerUpdateDto true "Customer data"
// @Success 200 {object} utils.Response{data=dto.CustomerDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 404 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /customers/{id} [put]
func (h *CustomerHandler) UpdateCustomer(c *gin.Context) {
utils.HandleUpdate(c, models.UpdateCustomer, convertCustomerToDTO, prepareCustomerUpdate, "customer")
}
// DeleteCustomer handles DELETE /customers/:id
//
// @Summary Delete a customer
// @Description Delete a customer by its ID
// @Tags customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Customer ID"
// @Success 204 {object} utils.Response
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /customers/{id} [delete]
func (h *CustomerHandler) DeleteCustomer(c *gin.Context) {
utils.HandleDelete(c, models.DeleteCustomer, "customer")
}
// Helper functions for DTO conversion
func convertCustomerToDTO(customer *models.Customer) dto.CustomerDto {
var companyID *string
if customer.CompanyID != nil {
s := customer.CompanyID.String()
companyID = &s
}
return dto.CustomerDto{
ID: customer.ID.String(),
CreatedAt: customer.CreatedAt,
UpdatedAt: customer.UpdatedAt,
Name: customer.Name,
CompanyID: companyID,
}
}
func convertCreateCustomerDTOToModel(dto dto.CustomerCreateDto) (models.CustomerCreate, error) {
var companyID *types.ULID
if dto.CompanyID != nil {
wrapper, err := types.ULIDFromString(*dto.CompanyID) // Ignoring error, validation happens in the model
if err != nil {
return models.CustomerCreate{}, fmt.Errorf("invalid company ID: %w", err)
}
companyID = &wrapper
}
create := models.CustomerCreate{
Name: dto.Name,
CompanyID: companyID,
}
return create, nil
}
func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) (models.CustomerUpdate, error) {
id, err := types.ULIDFromString(dto.ID)
if err != nil {
return models.CustomerUpdate{}, fmt.Errorf("invalid customer ID: %w", err)
}
update := models.CustomerUpdate{
ID: id,
}
if dto.Name != nil {
update.Name = dto.Name
}
if dto.CompanyID.Valid {
companyID, err := types.ULIDFromString(*dto.CompanyID.Value)
if err != nil {
return models.CustomerUpdate{}, fmt.Errorf("invalid company ID: %w", err)
}
update.CompanyID = &companyID
} else {
update.CompanyID = nil
}
return update, nil
}
// Helper function to parse company ID from string
func parseCompanyID(idStr string) (int, error) {
var id int
_, err := fmt.Sscanf(idStr, "%d", &id)
return id, err
}
// prepareCustomerUpdate prepares the customer update object by parsing the ID, binding the JSON, and converting the DTO to a model
func prepareCustomerUpdate(c *gin.Context) (models.CustomerUpdate, error) {
// Parse ID from URL
id, err := utils.ParseID(c, "id")
if err != nil {
responses.BadRequestResponse(c, "Invalid customer ID format")
return models.CustomerUpdate{}, err
}
// Parse request body
var customerUpdateDTO dto.CustomerUpdateDto
if err := utils.BindJSON(c, &customerUpdateDTO); err != nil {
responses.BadRequestResponse(c, err.Error())
return models.CustomerUpdate{}, err
}
// Set ID from URL
customerUpdateDTO.ID = id.String()
// Convert DTO to model
customerUpdate, err := convertUpdateCustomerDTOToModel(customerUpdateDTO)
if err != nil {
responses.BadRequestResponse(c, "Invalid request body: "+err.Error())
return models.CustomerUpdate{}, err
}
return customerUpdate, nil
}
// createCustomerWrapper is a wrapper function for models.CreateCustomer that takes a DTO as input
func createCustomerWrapper(ctx context.Context, createDTO dto.CustomerCreateDto, userID types.ULID) (*models.Customer, error) {
// Convert DTO to model
customerCreate, err := convertCreateCustomerDTOToModel(createDTO)
if err != nil {
return nil, err
}
// Set the owner user ID
customerCreate.OwnerUserID = &userID
// Call the original function
return models.CreateCustomer(ctx, customerCreate)
}
@@ -0,0 +1,234 @@
package handlers
import (
"context"
"fmt"
"github.com/gin-gonic/gin"
"github.com/timetracker/backend/internal/api/dto"
"github.com/timetracker/backend/internal/api/responses"
"github.com/timetracker/backend/internal/api/utils"
"github.com/timetracker/backend/internal/models"
"github.com/timetracker/backend/internal/types"
)
// ProjectHandler handles project-related API endpoints
type ProjectHandler struct{}
// NewProjectHandler creates a new ProjectHandler
func NewProjectHandler() *ProjectHandler {
return &ProjectHandler{}
}
// GetProjects handles GET /projects
//
// @Summary Get all projects
// @Description Get a list of all projects
// @Tags projects
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} utils.Response{data=[]dto.ProjectDto}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /projects [get]
func (h *ProjectHandler) GetProjects(c *gin.Context) {
utils.HandleGetAll(c, models.GetAllProjects, convertProjectToDTO, "projects")
}
// GetProjectsWithCustomers handles GET /projects/with-customers
//
// @Summary Get all projects with customer information
// @Description Get a list of all projects with their associated customer information
// @Tags projects
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} utils.Response{data=[]dto.ProjectDto}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /projects/with-customers [get]
func (h *ProjectHandler) GetProjectsWithCustomers(c *gin.Context) {
utils.HandleGetAll(c, models.GetAllProjectsWithCustomers, convertProjectToDTO, "projects with customers")
}
// GetProjectByID handles GET /projects/:id
//
// @Summary Get project by ID
// @Description Get a project by its ID
// @Tags projects
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Project ID"
// @Success 200 {object} utils.Response{data=dto.ProjectDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 404 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /projects/{id} [get]
func (h *ProjectHandler) GetProjectByID(c *gin.Context) {
utils.HandleGetByID(c, models.GetProjectByID, convertProjectToDTO, "project")
}
// GetProjectsByCustomerID handles GET /projects/customer/:customerId
//
// @Summary Get projects by customer ID
// @Description Get a list of projects for a specific customer
// @Tags projects
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param customerId path string true "Customer ID"
// @Success 200 {object} utils.Response{data=[]dto.ProjectDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /projects/customer/{customerId} [get]
func (h *ProjectHandler) GetProjectsByCustomerID(c *gin.Context) {
utils.HandleGetByFilter(c, models.GetProjectsByCustomerID, convertProjectToDTO, "projects", "customerId")
}
// CreateProject handles POST /projects
//
// @Summary Create a new project
// @Description Create a new project
// @Tags projects
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param project body dto.ProjectCreateDto true "Project data"
// @Success 201 {object} utils.Response{data=dto.ProjectDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /projects [post]
func (h *ProjectHandler) CreateProject(c *gin.Context) {
utils.HandleCreate(c, createProjectWrapper, convertProjectToDTO, "project")
}
// UpdateProject handles PUT /projects/:id
//
// @Summary Update a project
// @Description Update an existing project
// @Tags projects
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Project ID"
// @Param project body dto.ProjectUpdateDto true "Project data"
// @Success 200 {object} utils.Response{data=dto.ProjectDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 404 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /projects/{id} [put]
func (h *ProjectHandler) UpdateProject(c *gin.Context) {
utils.HandleUpdate(c, models.UpdateProject, convertProjectToDTO, prepareProjectUpdate, "project")
}
// DeleteProject handles DELETE /projects/:id
//
// @Summary Delete a project
// @Description Delete a project by its ID
// @Tags projects
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Project ID"
// @Success 204 {object} utils.Response
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /projects/{id} [delete]
func (h *ProjectHandler) DeleteProject(c *gin.Context) {
utils.HandleDelete(c, models.DeleteProject, "project")
}
// Helper functions for DTO conversion
func convertProjectToDTO(project *models.Project) dto.ProjectDto {
customerId := project.CustomerID.String()
return dto.ProjectDto{
ID: project.ID.String(),
CreatedAt: project.CreatedAt,
UpdatedAt: project.UpdatedAt,
Name: project.Name,
CustomerID: &customerId,
}
}
func convertCreateProjectDTOToModel(dto dto.ProjectCreateDto) (models.ProjectCreate, error) {
create := models.ProjectCreate{Name: dto.Name}
// Convert CustomerID from int to ULID (this is a simplification, adjust as needed)
if dto.CustomerID != nil {
customerID, err := types.ULIDFromString(*dto.CustomerID)
if err != nil {
return models.ProjectCreate{}, fmt.Errorf("invalid customer ID: %w", err)
}
create.CustomerID = &customerID
}
return create, nil
}
func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto, id types.ULID) (models.ProjectUpdate, error) {
update := models.ProjectUpdate{
ID: id,
}
if dto.Name != nil {
update.Name = dto.Name
}
if dto.CustomerID.Valid {
if dto.CustomerID.Value == nil {
update.CustomerID = nil
} else {
customerID, err := types.ULIDFromString(*dto.CustomerID.Value)
if err != nil {
return models.ProjectUpdate{}, fmt.Errorf("invalid customer ID: %w", err)
}
update.CustomerID = &customerID
}
}
return update, nil
}
// prepareProjectUpdate prepares the project update object by parsing the ID, binding the JSON, and converting the DTO to a model
func prepareProjectUpdate(c *gin.Context) (models.ProjectUpdate, error) {
// Parse ID from URL
id, err := utils.ParseID(c, "id")
if err != nil {
responses.BadRequestResponse(c, "Invalid project ID format")
return models.ProjectUpdate{}, err
}
// Parse request body
var projectUpdateDTO dto.ProjectUpdateDto
if err := utils.BindJSON(c, &projectUpdateDTO); err != nil {
responses.BadRequestResponse(c, err.Error())
return models.ProjectUpdate{}, err
}
// Convert DTO to model
projectUpdate, err := convertUpdateProjectDTOToModel(projectUpdateDTO, id)
if err != nil {
responses.BadRequestResponse(c, err.Error())
return models.ProjectUpdate{}, err
}
return projectUpdate, nil
}
// createProjectWrapper is a wrapper function for models.CreateProject that takes a DTO as input
func createProjectWrapper(ctx context.Context, createDTO dto.ProjectCreateDto) (*models.Project, error) {
// Convert DTO to model
projectCreate, err := convertCreateProjectDTOToModel(createDTO)
if err != nil {
return nil, err
}
// Call the original function
return models.CreateProject(ctx, projectCreate)
}
@@ -0,0 +1,316 @@
package handlers
import (
"context"
"fmt"
"github.com/gin-gonic/gin"
"github.com/timetracker/backend/internal/api/dto"
"github.com/timetracker/backend/internal/api/responses"
"github.com/timetracker/backend/internal/api/utils"
"github.com/timetracker/backend/internal/models"
"github.com/timetracker/backend/internal/types"
)
// TimeEntryHandler handles time entry-related API endpoints
type TimeEntryHandler struct{}
// NewTimeEntryHandler creates a new TimeEntryHandler
func NewTimeEntryHandler() *TimeEntryHandler {
return &TimeEntryHandler{}
}
// GetTimeEntries handles GET /time-entries
//
// @Summary Get all time entries
// @Description Get a list of all time entries
// @Tags time-entries
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} utils.Response{data=[]dto.TimeEntryDto}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /time-entries [get]
func (h *TimeEntryHandler) GetTimeEntries(c *gin.Context) {
utils.HandleGetAll(c, models.GetAllTimeEntries, convertTimeEntryToDTO, "time entries")
}
// GetTimeEntryByID handles GET /time-entries/:id
//
// @Summary Get time entry by ID
// @Description Get a time entry by its ID
// @Tags time-entries
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Time Entry ID"
// @Success 200 {object} utils.Response{data=dto.TimeEntryDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 404 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /time-entries/{id} [get]
func (h *TimeEntryHandler) GetTimeEntryByID(c *gin.Context) {
utils.HandleGetByID(c, models.GetTimeEntryByID, convertTimeEntryToDTO, "time entry")
}
// GetTimeEntriesByUserID handles GET /time-entries/user/:userId
//
// @Summary Get time entries by user ID
// @Description Get a list of time entries for a specific user
// @Tags time-entries
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param userId path string true "User ID"
// @Success 200 {object} utils.Response{data=[]dto.TimeEntryDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /time-entries/user/{userId} [get]
func (h *TimeEntryHandler) GetTimeEntriesByUserID(c *gin.Context) {
utils.HandleGetByFilter(c, models.GetTimeEntriesByUserID, convertTimeEntryToDTO, "time entries", "userId")
}
// GetMyTimeEntries handles GET /time-entries/me
//
// @Summary Get current user's time entries
// @Description Get a list of time entries for the currently authenticated user
// @Tags time-entries
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} utils.Response{data=[]dto.TimeEntryDto}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /time-entries/me [get]
func (h *TimeEntryHandler) GetMyTimeEntries(c *gin.Context) {
utils.HandleGetByUserID(c, models.GetTimeEntriesByUserID, convertTimeEntryToDTO, "time entries")
}
// GetTimeEntriesByProjectID handles GET /time-entries/project/:projectId
//
// @Summary Get time entries by project ID
// @Description Get a list of time entries for a specific project
// @Tags time-entries
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param projectId path string true "Project ID"
// @Success 200 {object} utils.Response{data=[]dto.TimeEntryDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /time-entries/project/{projectId} [get]
func (h *TimeEntryHandler) GetTimeEntriesByProjectID(c *gin.Context) {
utils.HandleGetByFilter(c, models.GetTimeEntriesByProjectID, convertTimeEntryToDTO, "time entries", "projectId")
}
// GetTimeEntriesByDateRange handles GET /time-entries/range
//
// @Summary Get time entries by date range
// @Description Get a list of time entries within a specific date range
// @Tags time-entries
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param start query string true "Start date (ISO 8601 format)"
// @Param end query string true "End date (ISO 8601 format)"
// @Success 200 {object} utils.Response{data=[]dto.TimeEntryDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /time-entries/range [get]
func (h *TimeEntryHandler) GetTimeEntriesByDateRange(c *gin.Context) {
utils.HandleGetByDateRange(c, models.GetTimeEntriesByDateRange, convertTimeEntryToDTO, "time entries")
}
// CreateTimeEntry handles POST /time-entries
//
// @Summary Create a new time entry
// @Description Create a new time entry
// @Tags time-entries
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param timeEntry body dto.TimeEntryCreateDto true "Time Entry data"
// @Success 201 {object} utils.Response{data=dto.TimeEntryDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /time-entries [post]
func (h *TimeEntryHandler) CreateTimeEntry(c *gin.Context) {
utils.HandleCreate(c, createTimeEntryWrapper, convertTimeEntryToDTO, "time entry")
}
// UpdateTimeEntry handles PUT /time-entries/:id
//
// @Summary Update a time entry
// @Description Update an existing time entry
// @Tags time-entries
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Time Entry ID"
// @Param timeEntry body dto.TimeEntryUpdateDto true "Time Entry data"
// @Success 200 {object} utils.Response{data=dto.TimeEntryDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 404 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /time-entries/{id} [put]
func (h *TimeEntryHandler) UpdateTimeEntry(c *gin.Context) {
utils.HandleUpdate(c, models.UpdateTimeEntry, convertTimeEntryToDTO, prepareTimeEntryUpdate, "time entry")
}
// DeleteTimeEntry handles DELETE /time-entries/:id
//
// @Summary Delete a time entry
// @Description Delete a time entry by its ID
// @Tags time-entries
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Time Entry ID"
// @Success 204 {object} utils.Response
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /time-entries/{id} [delete]
func (h *TimeEntryHandler) DeleteTimeEntry(c *gin.Context) {
utils.HandleDelete(c, models.DeleteTimeEntry, "time entry")
}
// Helper functions for DTO conversion
func convertTimeEntryToDTO(timeEntry *models.TimeEntry) dto.TimeEntryDto {
return dto.TimeEntryDto{
ID: timeEntry.ID.String(),
CreatedAt: timeEntry.CreatedAt,
UpdatedAt: timeEntry.UpdatedAt,
UserID: timeEntry.UserID.String(), // Simplified conversion
ProjectID: timeEntry.ProjectID.String(), // Simplified conversion
ActivityID: timeEntry.ActivityID.String(), // Simplified conversion
Start: timeEntry.Start,
End: timeEntry.End,
Description: timeEntry.Description,
Billable: timeEntry.Billable,
}
}
func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEntryCreate, error) {
// Convert IDs from int to ULID (this is a simplification, adjust as needed)
userID, err := types.ULIDFromString(dto.UserID)
if err != nil {
return models.TimeEntryCreate{}, fmt.Errorf("invalid user ID: %w", err)
}
projectID, err := types.ULIDFromString(dto.ProjectID)
if err != nil {
return models.TimeEntryCreate{}, fmt.Errorf("invalid project ID: %w", err)
}
activityID, err := types.ULIDFromString(dto.ActivityID)
if err != nil {
return models.TimeEntryCreate{}, fmt.Errorf("invalid activity ID: %w", err)
}
return models.TimeEntryCreate{
UserID: userID,
ProjectID: projectID,
ActivityID: activityID,
Start: dto.Start,
End: dto.End,
Description: dto.Description,
Billable: dto.Billable,
}, nil
}
func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto, id types.ULID) (models.TimeEntryUpdate, error) {
update := models.TimeEntryUpdate{
ID: id,
}
if dto.UserID != nil {
userID, err := types.ULIDFromString(*dto.UserID)
if err != nil {
return models.TimeEntryUpdate{}, fmt.Errorf("invalid user ID: %w", err)
}
update.UserID = &userID
}
if dto.ProjectID != nil {
projectID, err := types.ULIDFromString(*dto.ProjectID)
if err != nil {
return models.TimeEntryUpdate{}, fmt.Errorf("invalid project ID: %w", err)
}
update.ProjectID = &projectID
}
if dto.ActivityID != nil {
activityID, err := types.ULIDFromString(*dto.ActivityID)
if err != nil {
return models.TimeEntryUpdate{}, fmt.Errorf("invalid activity ID: %w", err)
}
update.ActivityID = &activityID
}
if dto.Start != nil {
update.Start = dto.Start
}
if dto.End != nil {
update.End = dto.End
}
if dto.Description != nil {
update.Description = dto.Description
}
if dto.Billable != nil {
update.Billable = dto.Billable
}
return update, nil
}
// prepareTimeEntryUpdate prepares the time entry update object by parsing the ID, binding the JSON, and converting the DTO to a model
func prepareTimeEntryUpdate(c *gin.Context) (models.TimeEntryUpdate, error) {
// Parse ID from URL
idStr := c.Param("id")
id, err := types.ULIDFromString(idStr)
if err != nil {
responses.BadRequestResponse(c, "Invalid time entry ID format")
return models.TimeEntryUpdate{}, err
}
// Parse request body
var timeEntryUpdateDTO dto.TimeEntryUpdateDto
if err := utils.BindJSON(c, &timeEntryUpdateDTO); err != nil {
responses.BadRequestResponse(c, err.Error())
return models.TimeEntryUpdate{}, err
}
// Convert DTO to model
timeEntryUpdate, err := convertUpdateTimeEntryDTOToModel(timeEntryUpdateDTO, id)
if err != nil {
responses.BadRequestResponse(c, err.Error())
return models.TimeEntryUpdate{}, err
}
return timeEntryUpdate, nil
}
// createTimeEntryWrapper is a wrapper function for models.CreateTimeEntry that takes a DTO as input
func createTimeEntryWrapper(ctx context.Context, createDTO dto.TimeEntryCreateDto) (*models.TimeEntry, error) {
// Convert DTO to model
timeEntryCreate, err := convertCreateTimeEntryDTOToModel(createDTO)
if err != nil {
return nil, err
}
// Call the original function
return models.CreateTimeEntry(ctx, timeEntryCreate)
}
@@ -0,0 +1,359 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/timetracker/backend/internal/api/dto"
"github.com/timetracker/backend/internal/api/middleware"
"github.com/timetracker/backend/internal/api/responses"
"github.com/timetracker/backend/internal/api/utils"
"github.com/timetracker/backend/internal/models"
"github.com/timetracker/backend/internal/types"
)
// UserHandler handles user-related API endpoints
type UserHandler struct{}
// NewUserHandler creates a new UserHandler
func NewUserHandler() *UserHandler {
return &UserHandler{}
}
// GetUsers handles GET /users
//
// @Summary Get all users
// @Description Get a list of all users
// @Tags users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} utils.Response{data=[]dto.UserDto}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /users [get]
func (h *UserHandler) GetUsers(c *gin.Context) {
utils.HandleGetAll(c, models.GetAllUsers, convertUserToDTO, "users")
}
// GetUserByID handles GET /users/:id
//
// @Summary Get user by ID
// @Description Get a user by their ID
// @Tags users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "User ID"
// @Success 200 {object} utils.Response{data=dto.UserDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 404 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /users/{id} [get]
func (h *UserHandler) GetUserByID(c *gin.Context) {
// We need a custom wrapper for GetUserByID because the ID parameter is parsed differently
id, err := utils.ParseID(c, "id")
if err != nil {
responses.BadRequestResponse(c, "Invalid user ID format")
return
}
// Get user from the database
user, err := models.GetUserByID(c.Request.Context(), id)
if err != nil {
responses.InternalErrorResponse(c, "Error retrieving user: "+err.Error())
return
}
if user == nil {
responses.NotFoundResponse(c, "User not found")
return
}
// Convert to DTO
userDTO := convertUserToDTO(user)
responses.SuccessResponse(c, http.StatusOK, userDTO)
}
// CreateUser handles POST /users
//
// @Summary Create a new user
// @Description Create a new user
// @Tags users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param user body dto.UserCreateDto true "User data"
// @Success 201 {object} utils.Response{data=dto.UserDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /users [post]
func (h *UserHandler) CreateUser(c *gin.Context) {
utils.HandleCreate(c, createUserWrapper, convertUserToDTO, "user")
}
// UpdateUser handles PUT /users/:id
//
// @Summary Update a user
// @Description Update an existing user
// @Tags users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "User ID"
// @Param user body dto.UserUpdateDto true "User data"
// @Success 200 {object} utils.Response{data=dto.UserDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 404 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /users/{id} [put]
func (h *UserHandler) UpdateUser(c *gin.Context) {
utils.HandleUpdate(c, models.UpdateUser, convertUserToDTO, prepareUserUpdate, "user")
}
// DeleteUser handles DELETE /users/:id
//
// @Summary Delete a user
// @Description Delete a user by their ID
// @Tags users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "User ID"
// @Success 204 {object} utils.Response
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /users/{id} [delete]
func (h *UserHandler) DeleteUser(c *gin.Context) {
utils.HandleDelete(c, models.DeleteUser, "user")
}
// Login handles POST /auth/login
//
// @Summary Login
// @Description Authenticate a user and get a JWT token
// @Tags auth
// @Accept json
// @Produce json
// @Param credentials body dto.LoginDto true "Login credentials"
// @Success 200 {object} utils.Response{data=dto.TokenDto}
// @Failure 400 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /auth/login [post]
func (h *UserHandler) Login(c *gin.Context) {
// Parse request body
var loginDTO dto.LoginDto
if err := c.ShouldBindJSON(&loginDTO); err != nil {
responses.BadRequestResponse(c, "Invalid request body: "+err.Error())
return
}
// Authenticate user
user, err := models.AuthenticateUser(c.Request.Context(), loginDTO.Email, loginDTO.Password)
if err != nil {
responses.UnauthorizedResponse(c, "Invalid login credentials")
return
}
// Generate JWT token
token, err := middleware.GenerateToken(user, c)
if err != nil {
responses.InternalErrorResponse(c, "Error generating token: "+err.Error())
return
}
// Return token
tokenDTO := dto.TokenDto{
Token: token,
User: convertUserToDTO(user),
}
responses.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 {
responses.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 {
responses.InternalErrorResponse(c, "Error creating user: "+err.Error())
return
}
// Generate JWT token
token, err := middleware.GenerateToken(user, c)
if err != nil {
responses.InternalErrorResponse(c, "Error generating token: "+err.Error())
return
}
// Return token
tokenDTO := dto.TokenDto{
Token: token,
User: convertUserToDTO(user),
}
responses.SuccessResponse(c, http.StatusCreated, tokenDTO)
}
// GetCurrentUser handles GET /auth/me
//
// @Summary Get current user
// @Description Get the currently authenticated user
// @Tags auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} utils.Response{data=dto.UserDto}
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
// @Router /auth/me [get]
func (h *UserHandler) GetCurrentUser(c *gin.Context) {
// Get user ID from context (set by AuthMiddleware)
userID, err := middleware.GetUserIDFromContext(c)
if err != nil {
responses.UnauthorizedResponse(c, "User not authenticated")
return
}
// Get user from the database
user, err := models.GetUserByID(c.Request.Context(), userID)
if err != nil {
responses.InternalErrorResponse(c, "Error retrieving user: "+err.Error())
return
}
if user == nil {
responses.NotFoundResponse(c, "User not found")
return
}
// Convert to DTO
userDTO := convertUserToDTO(user)
responses.SuccessResponse(c, http.StatusOK, userDTO)
}
// Helper functions for DTO conversion
func convertUserToDTO(user *models.User) dto.UserDto {
var companyID *string
if user.CompanyID != nil {
s := user.CompanyID.String()
companyID = &s
}
return dto.UserDto{
ID: user.ID.String(),
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
Email: user.Email,
Role: user.Role,
CompanyID: companyID,
HourlyRate: user.HourlyRate,
}
}
// prepareUserUpdate prepares the user update object by parsing the ID, binding the JSON, and converting the DTO to a model
func prepareUserUpdate(c *gin.Context) (models.UserUpdate, error) {
// Parse ID from URL
idStr := c.Param("id")
id, err := types.ULIDFromString(idStr)
if err != nil {
responses.BadRequestResponse(c, "Invalid user ID format")
return models.UserUpdate{}, err
}
// Parse request body
var userUpdateDTO dto.UserUpdateDto
if err := utils.BindJSON(c, &userUpdateDTO); err != nil {
responses.BadRequestResponse(c, err.Error())
return models.UserUpdate{}, err
}
// Convert DTO to Model
update := models.UserUpdate{
ID: id,
}
if userUpdateDTO.Email != nil {
update.Email = userUpdateDTO.Email
}
if userUpdateDTO.Password != nil {
update.Password = userUpdateDTO.Password
}
if userUpdateDTO.Role != nil {
update.Role = userUpdateDTO.Role
}
if userUpdateDTO.CompanyID.Valid {
if userUpdateDTO.CompanyID.Value != nil {
companyID, err := types.ULIDFromString(*userUpdateDTO.CompanyID.Value)
if err != nil {
responses.BadRequestResponse(c, "Invalid company ID format")
return models.UserUpdate{}, err
}
update.CompanyID = types.NewNullable(companyID)
} else {
update.CompanyID = types.Null[types.ULID]()
}
}
if userUpdateDTO.HourlyRate != nil {
update.HourlyRate = userUpdateDTO.HourlyRate
}
return update, nil
}
func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate {
var companyID *types.ULID
if dto.CompanyID != nil {
wrapper, _ := types.ULIDFromString(*dto.CompanyID) // Ignoring error, validation happens in the model
companyID = &wrapper
}
return models.UserCreate{
Email: dto.Email,
Password: dto.Password,
Role: dto.Role,
CompanyID: companyID,
HourlyRate: dto.HourlyRate,
}
}
// createUserWrapper is a wrapper function for models.CreateUser that takes a DTO as input
func createUserWrapper(ctx context.Context, createDTO dto.UserCreateDto) (*models.User, error) {
// Convert DTO to model
userCreate := convertCreateDTOToModel(createDTO)
// Call the original function
return models.CreateUser(ctx, userCreate)
}
@@ -0,0 +1,35 @@
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/timetracker/backend/internal/api/responses"
"github.com/timetracker/backend/internal/config"
)
// APIKeyMiddleware checks for a valid API key if configured
func APIKeyMiddleware(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip if no API key is configured
if cfg.APIKey == "" {
c.Next()
return
}
// Get API key from header
apiKey := c.GetHeader("X-API-Key")
if apiKey == "" {
responses.UnauthorizedResponse(c, "API key is required")
c.Abort()
return
}
// Validate API key
if apiKey != cfg.APIKey {
responses.UnauthorizedResponse(c, "Invalid API key")
c.Abort()
return
}
c.Next()
}
}
+364
View File
@@ -0,0 +1,364 @@
package middleware
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/oklog/ulid/v2"
"github.com/timetracker/backend/internal/api/responses"
"github.com/timetracker/backend/internal/config"
"github.com/timetracker/backend/internal/models"
"github.com/timetracker/backend/internal/types"
)
var (
signKey *rsa.PrivateKey
verifyKey *rsa.PublicKey
)
// InitJWTKeys initializes the JWT keys
func InitJWTKeys() error {
cfg := config.MustLoadConfig()
// If a secret is provided, we'll use HMAC-SHA256, so no need for certificates
if cfg.JWTConfig.Secret != "" {
println("Using HMAC-SHA256 for JWT")
return nil
}
// Check if keys exist
privKeyPath := filepath.Join(cfg.JWTConfig.KeyDir, cfg.JWTConfig.PrivKeyFile)
pubKeyPath := filepath.Join(cfg.JWTConfig.KeyDir, cfg.JWTConfig.PubKeyFile)
keysExist := fileExists(privKeyPath) && fileExists(pubKeyPath)
// Generate keys if they don't exist and KeyGenerate is true
if !keysExist && cfg.JWTConfig.KeyGenerate {
println("Generating RSA keys")
if err := generateRSAKeys(cfg.JWTConfig); err != nil {
return fmt.Errorf("failed to generate RSA keys: %w", err)
}
} else if !keysExist {
return errors.New("JWT keys not found and key generation is disabled")
}
// Load keys
var err error
signKey, err = loadPrivateKey(privKeyPath)
if err != nil {
return fmt.Errorf("failed to load private key: %w", err)
}
verifyKey, err = loadPublicKey(pubKeyPath)
if err != nil {
return fmt.Errorf("failed to load public key: %w", err)
}
return nil
}
// fileExists checks if a file exists
func fileExists(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err)
}
// generateRSAKeys generates RSA keys and saves them to disk
func generateRSAKeys(cfg models.JWTConfig) error {
// Create key directory if it doesn't exist
if err := os.MkdirAll(cfg.KeyDir, 0700); err != nil {
return fmt.Errorf("failed to create key directory: %w", err)
}
// Generate private key
privateKey, err := rsa.GenerateKey(rand.Reader, cfg.KeyBits)
if err != nil {
return fmt.Errorf("failed to generate private key: %w", err)
}
// Save private key
privKeyPath := filepath.Join(cfg.KeyDir, cfg.PrivKeyFile)
privKeyFile, err := os.OpenFile(privKeyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create private key file: %w", err)
}
defer privKeyFile.Close()
privKeyPEM := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}
if err := pem.Encode(privKeyFile, privKeyPEM); err != nil {
return fmt.Errorf("failed to encode private key: %w", err)
}
// Save public key
pubKeyPath := filepath.Join(cfg.KeyDir, cfg.PubKeyFile)
pubKeyFile, err := os.OpenFile(pubKeyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("failed to create public key file: %w", err)
}
defer pubKeyFile.Close()
pubKeyPEM := &pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: x509.MarshalPKCS1PublicKey(&privateKey.PublicKey),
}
if err := pem.Encode(pubKeyFile, pubKeyPEM); err != nil {
return fmt.Errorf("failed to encode public key: %w", err)
}
return nil
}
// loadPrivateKey loads a private key from a file
func loadPrivateKey(path string) (*rsa.PrivateKey, error) {
keyData, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read private key file: %w", err)
}
block, _ := pem.Decode(keyData)
if block == nil {
return nil, errors.New("failed to parse PEM block containing the private key")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
return privateKey, nil
}
// loadPublicKey loads a public key from a file
func loadPublicKey(path string) (*rsa.PublicKey, error) {
keyData, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read public key file: %w", err)
}
block, _ := pem.Decode(keyData)
if block == nil {
return nil, errors.New("failed to parse PEM block containing the public key")
}
publicKey, err := x509.ParsePKCS1PublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}
return publicKey, nil
}
// Claims represents the JWT claims
type Claims struct {
UserID string `json:"userId"`
Email string `json:"email"`
Role string `json:"role"`
CompanyID *string `json:"companyId"`
jwt.RegisteredClaims
}
// AuthMiddleware checks if the user is authenticated
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Get the token from cookie
tokenString, err := c.Cookie("jwt")
if err != nil {
responses.UnauthorizedResponse(c, "Authentication cookie is required")
c.Abort()
return
}
claims, err := validateToken(tokenString)
if err != nil {
responses.UnauthorizedResponse(c, "Invalid or expired token")
c.Abort()
return
}
// Store user information in the context
c.Set("userID", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Set("companyID", claims.CompanyID)
c.Next()
}
}
// RoleMiddleware checks if the user has the required role
func RoleMiddleware(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
userRole, exists := c.Get("role")
if !exists {
responses.UnauthorizedResponse(c, "User role not found in context")
c.Abort()
return
}
// Check if the user's role is in the allowed roles
roleStr, ok := userRole.(string)
if !ok {
responses.InternalErrorResponse(c, "Invalid role type in context")
c.Abort()
return
}
allowed := false
for _, role := range roles {
if roleStr == role {
allowed = true
break
}
}
if !allowed {
responses.ForbiddenResponse(c, "Insufficient permissions")
c.Abort()
return
}
c.Next()
}
}
// GenerateToken creates a new JWT token for a user
func GenerateToken(user *models.User, c *gin.Context) (string, error) {
// Create the claims
var companyId *string
if user.CompanyID != nil {
wrapper := user.CompanyID.String()
companyId = &wrapper
}
claims := Claims{
UserID: user.ID.String(),
Email: user.Email,
Role: user.Role,
CompanyID: companyId,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(config.MustLoadConfig().JWTConfig.TokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
cfg := config.MustLoadConfig()
var token *jwt.Token
var tokenString string
var err error
// Choose signing method based on configuration
if cfg.JWTConfig.Secret != "" {
// Use HMAC-SHA256 if a secret is provided
token = jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err = token.SignedString([]byte(cfg.JWTConfig.Secret))
} else {
// Use RSA if no secret is provided
token = jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tokenString, err = token.SignedString(signKey)
}
if err != nil {
return "", err
}
// Set the cookie
c.SetCookie("jwt", tokenString, int(cfg.JWTConfig.TokenDuration.Seconds()), "/", "", true, true)
return tokenString, nil
}
// validateToken validates a JWT token and returns the claims
func validateToken(tokenString string) (*Claims, error) {
cfg := config.MustLoadConfig()
// Parse the token
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
// Check which signing method was used
if _, ok := token.Method.(*jwt.SigningMethodHMAC); ok {
// HMAC method was used, validate with secret
if cfg.JWTConfig.Secret == "" {
return nil, errors.New("HMAC signing method used but no secret configured")
}
return []byte(cfg.JWTConfig.Secret), nil
} else if _, ok := token.Method.(*jwt.SigningMethodRSA); ok {
// RSA method was used, validate with public key
if verifyKey == nil {
return nil, errors.New("RSA signing method used but no public key loaded")
}
return verifyKey, nil
}
return nil, errors.New("unexpected signing method")
})
if err != nil {
return nil, err
}
// Check if the token is valid
if !token.Valid {
return nil, errors.New("invalid token")
}
// Get the claims
claims, ok := token.Claims.(*Claims)
if !ok {
return nil, errors.New("invalid claims")
}
return claims, nil
}
// GetUserIDFromContext extracts the user ID from the context
func GetUserIDFromContext(c *gin.Context) (types.ULID, error) {
userID, exists := c.Get("userID")
if !exists {
return types.ULID{}, errors.New("user ID not found in context")
}
userIDStr, ok := userID.(string)
if !ok {
return types.ULID{}, errors.New("invalid user ID type in context")
}
id, err := ulid.Parse(userIDStr)
if err != nil {
return types.ULID{}, err
}
return types.FromULID(id), nil
}
// GetCompanyIDFromContext extracts the company ID from the context
func GetCompanyIDFromContext(c *gin.Context) (types.ULID, error) {
companyID, exists := c.Get("companyID")
if !exists {
return types.ULID{}, errors.New("company ID not found in context")
}
companyIDStr, ok := companyID.(string)
if !ok {
return types.ULID{}, errors.New("invalid company ID type in context")
}
id, err := ulid.Parse(companyIDStr)
if err != nil {
return types.ULID{}, err
}
return types.FromULID(id), nil
}
@@ -0,0 +1,85 @@
package responses
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Response is a standardized API response structure
type Response struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error *ErrorInfo `json:"error,omitempty"`
}
// ErrorInfo contains detailed error information
type ErrorInfo struct {
Code string `json:"code"`
Message string `json:"message"`
}
// ErrorResponse codes
const (
ErrorCodeValidation = "VALIDATION_ERROR"
ErrorCodeNotFound = "NOT_FOUND"
ErrorCodeUnauthorized = "UNAUTHORIZED"
ErrorCodeForbidden = "FORBIDDEN"
ErrorCodeInternal = "INTERNAL_ERROR"
ErrorCodeBadRequest = "BAD_REQUEST"
ErrorCodeConflict = "CONFLICT"
)
// SuccessResponse sends a successful response with data
func SuccessResponse(c *gin.Context, statusCode int, data interface{}) {
c.JSON(statusCode, Response{
Success: true,
Data: data,
})
}
// ErrorResponse sends an error response
func ErrorResponse(c *gin.Context, statusCode int, errorCode string, message string) {
c.JSON(statusCode, Response{
Success: false,
Error: &ErrorInfo{
Code: errorCode,
Message: message,
},
})
}
// BadRequestResponse sends a 400 Bad Request response
func BadRequestResponse(c *gin.Context, message string) {
ErrorResponse(c, http.StatusBadRequest, ErrorCodeBadRequest, message)
}
// ValidationErrorResponse sends a 400 Bad Request response for validation errors
func ValidationErrorResponse(c *gin.Context, message string) {
ErrorResponse(c, http.StatusBadRequest, ErrorCodeValidation, message)
}
// NotFoundResponse sends a 404 Not Found response
func NotFoundResponse(c *gin.Context, message string) {
ErrorResponse(c, http.StatusNotFound, ErrorCodeNotFound, message)
}
// UnauthorizedResponse sends a 401 Unauthorized response
func UnauthorizedResponse(c *gin.Context, message string) {
ErrorResponse(c, http.StatusUnauthorized, ErrorCodeUnauthorized, message)
}
// ForbiddenResponse sends a 403 Forbidden response
func ForbiddenResponse(c *gin.Context, message string) {
ErrorResponse(c, http.StatusForbidden, ErrorCodeForbidden, message)
}
// InternalErrorResponse sends a 500 Internal Server Error response
func InternalErrorResponse(c *gin.Context, message string) {
ErrorResponse(c, http.StatusInternalServerError, ErrorCodeInternal, message)
}
// ConflictResponse sends a 409 Conflict response
func ConflictResponse(c *gin.Context, message string) {
ErrorResponse(c, http.StatusConflict, ErrorCodeConflict, message)
}
+110
View File
@@ -0,0 +1,110 @@
package routes
import (
"github.com/gin-gonic/gin"
"github.com/timetracker/backend/internal/api/handlers"
"github.com/timetracker/backend/internal/api/middleware"
"github.com/timetracker/backend/internal/config"
)
// SetupRouter configures all the routes for the API
func SetupRouter(r *gin.Engine, cfg *config.Config) {
// Create handlers
// Apply API key middleware to all API routes
r.Use(middleware.APIKeyMiddleware(cfg))
userHandler := handlers.NewUserHandler()
activityHandler := handlers.NewActivityHandler()
companyHandler := handlers.NewCompanyHandler()
customerHandler := handlers.NewCustomerHandler()
projectHandler := handlers.NewProjectHandler()
timeEntryHandler := handlers.NewTimeEntryHandler()
// API routes
api := r.Group("/api")
{
// Auth routes (public)
auth := api.Group("/auth")
{
auth.POST("/login", userHandler.Login)
auth.POST("/register", userHandler.Register)
}
// Protected routes
protected := api.Group("")
protected.Use(middleware.AuthMiddleware())
{
// Auth routes (protected)
protectedAuth := protected.Group("/auth")
{
protectedAuth.GET("/me", userHandler.GetCurrentUser)
}
// 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)
}
// 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)
}
// 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)
}
// 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)
}
// 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)
}
}
}
}
+286
View File
@@ -0,0 +1,286 @@
package utils
import (
"context"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/timetracker/backend/internal/api/responses"
"github.com/timetracker/backend/internal/types"
)
// ParseID parses an ID from the URL parameter and converts it to a types.ULID
func ParseID(c *gin.Context, paramName string) (types.ULID, error) {
idStr := c.Param(paramName)
return types.ULIDFromString(idStr)
}
// BindJSON binds the request body to the provided struct
func BindJSON(c *gin.Context, obj interface{}) error {
if err := c.ShouldBindJSON(obj); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
return nil
}
// ConvertToDTO converts a slice of models to a slice of DTOs using the provided conversion function
func ConvertToDTO[M any, D any](models []M, convertFn func(*M) D) []D {
dtos := make([]D, len(models))
for i, model := range models {
// Create a copy of the model to avoid issues with loop variable capture
modelCopy := model
dtos[i] = convertFn(&modelCopy)
}
return dtos
}
// HandleGetAll is a generic function to handle GET all entities endpoints
func HandleGetAll[M any, D any](
c *gin.Context,
getAllFn func(ctx context.Context) ([]M, error),
convertFn func(*M) D,
entityName string,
) {
// Get entities from the database
entities, err := getAllFn(c.Request.Context())
if err != nil {
responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error()))
return
}
// Convert to DTOs
dtos := ConvertToDTO(entities, convertFn)
responses.SuccessResponse(c, 200, dtos)
}
// HandleGetByID is a generic function to handle GET entity by ID endpoints
func HandleGetByID[M any, D any](
c *gin.Context,
getByIDFn func(ctx context.Context, id types.ULID) (*M, error),
convertFn func(*M) D,
entityName string,
) {
// Parse ID from URL
id, err := ParseID(c, "id")
if err != nil {
responses.BadRequestResponse(c, fmt.Sprintf("Invalid %s ID format", entityName))
return
}
// Get entity from the database
entity, err := getByIDFn(c.Request.Context(), id)
if err != nil {
responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error()))
return
}
if entity == nil {
responses.NotFoundResponse(c, fmt.Sprintf("%s not found", entityName))
return
}
// Convert to DTO
dto := convertFn(entity)
responses.SuccessResponse(c, 200, dto)
}
// HandleCreate is a generic function to handle POST entity endpoints
func HandleCreate[C any, M any, D any](
c *gin.Context,
createFn func(ctx context.Context, create C) (*M, error),
convertFn func(*M) D,
entityName string,
) {
// Parse request body
var createDTO C
if err := BindJSON(c, &createDTO); err != nil {
responses.BadRequestResponse(c, err.Error())
return
}
// Create entity in the database
entity, err := createFn(c.Request.Context(), createDTO)
if err != nil {
responses.InternalErrorResponse(c, fmt.Sprintf("Error creating %s: %s", entityName, err.Error()))
return
}
// Convert to DTO
dto := convertFn(entity)
responses.SuccessResponse(c, 201, dto)
}
// HandleDelete is a generic function to handle DELETE entity endpoints
func HandleDelete(
c *gin.Context,
deleteFn func(ctx context.Context, id types.ULID) error,
entityName string,
) {
// Parse ID from URL
id, err := ParseID(c, "id")
if err != nil {
responses.BadRequestResponse(c, fmt.Sprintf("Invalid %s ID format", entityName))
return
}
// Delete entity from the database
err = deleteFn(c.Request.Context(), id)
if err != nil {
responses.InternalErrorResponse(c, fmt.Sprintf("Error deleting %s: %s", entityName, err.Error()))
return
}
responses.SuccessResponse(c, 204, nil)
}
// HandleUpdate is a generic function to handle PUT entity endpoints
// It takes a prepareUpdateFn that handles parsing the ID, binding the JSON, and converting the DTO to a model update object
func HandleUpdate[U any, M any, D any](
c *gin.Context,
updateFn func(ctx context.Context, update U) (*M, error),
convertFn func(*M) D,
prepareUpdateFn func(*gin.Context) (U, error),
entityName string,
) {
// Prepare the update object (parse ID, bind JSON, convert DTO to model)
update, err := prepareUpdateFn(c)
if err != nil {
// The prepareUpdateFn should handle setting the appropriate error response
return
}
// Update entity in the database
entity, err := updateFn(c.Request.Context(), update)
if err != nil {
responses.InternalErrorResponse(c, fmt.Sprintf("Error updating %s: %s", entityName, err.Error()))
return
}
if entity == nil {
responses.NotFoundResponse(c, fmt.Sprintf("%s not found", entityName))
return
}
// Convert to DTO
dto := convertFn(entity)
responses.SuccessResponse(c, http.StatusOK, dto)
}
// HandleGetByFilter is a generic function to handle GET entities by a filter parameter
func HandleGetByFilter[M any, D any](
c *gin.Context,
getByFilterFn func(ctx context.Context, filterID types.ULID) ([]M, error),
convertFn func(*M) D,
entityName string,
paramName string,
) {
// Parse filter ID from URL
filterID, err := ParseID(c, paramName)
if err != nil {
responses.BadRequestResponse(c, fmt.Sprintf("Invalid %s ID format", paramName))
return
}
// Get entities from the database
entities, err := getByFilterFn(c.Request.Context(), filterID)
if err != nil {
responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error()))
return
}
// Convert to DTOs
dtos := ConvertToDTO(entities, convertFn)
responses.SuccessResponse(c, http.StatusOK, dtos)
}
// HandleGetByUserID is a specialized function to handle GET entities by user ID
func HandleGetByUserID[M any, D any](
c *gin.Context,
getByUserIDFn func(ctx context.Context, userID types.ULID) ([]M, error),
convertFn func(*M) D,
entityName string,
) {
// Get user ID from context (set by AuthMiddleware)
userID, exists := c.Get("userID")
if !exists {
responses.UnauthorizedResponse(c, "User not authenticated")
return
}
userIDStr, ok := userID.(string)
if !ok {
responses.InternalErrorResponse(c, "Invalid user ID type in context")
return
}
parsedUserID, err := types.ULIDFromString(userIDStr)
if err != nil {
responses.InternalErrorResponse(c, fmt.Sprintf("Error parsing user ID: %s", err.Error()))
return
}
// Get entities from the database
entities, err := getByUserIDFn(c.Request.Context(), parsedUserID)
if err != nil {
responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error()))
return
}
// Convert to DTOs
dtos := ConvertToDTO(entities, convertFn)
responses.SuccessResponse(c, http.StatusOK, dtos)
}
// HandleGetByDateRange is a specialized function to handle GET entities by date range
func HandleGetByDateRange[M any, D any](
c *gin.Context,
getByDateRangeFn func(ctx context.Context, start, end time.Time) ([]M, error),
convertFn func(*M) D,
entityName string,
) {
// Parse date range from query parameters
startStr := c.Query("start")
endStr := c.Query("end")
if startStr == "" || endStr == "" {
responses.BadRequestResponse(c, "Start and end dates are required")
return
}
start, err := time.Parse(time.RFC3339, startStr)
if err != nil {
responses.BadRequestResponse(c, "Invalid start date format. Use ISO 8601 format (e.g., 2023-01-01T00:00:00Z)")
return
}
end, err := time.Parse(time.RFC3339, endStr)
if err != nil {
responses.BadRequestResponse(c, "Invalid end date format. Use ISO 8601 format (e.g., 2023-01-01T00:00:00Z)")
return
}
if end.Before(start) {
responses.BadRequestResponse(c, "End date cannot be before start date")
return
}
// Get entities from the database
entities, err := getByDateRangeFn(c.Request.Context(), start, end)
if err != nil {
responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error()))
return
}
// Convert to DTOs
dtos := ConvertToDTO(entities, convertFn)
responses.SuccessResponse(c, http.StatusOK, dtos)
}
+131
View File
@@ -0,0 +1,131 @@
package config
import (
"errors"
"fmt"
"log"
"os"
"strconv"
"time"
"github.com/joho/godotenv"
"github.com/timetracker/backend/internal/models"
"gorm.io/gorm/logger"
)
// Config represents the application configuration
type Config struct {
Database models.DatabaseConfig
JWTConfig models.JWTConfig
APIKey string
}
// LoadConfig loads configuration from environment variables and .env file
func LoadConfig() (*Config, error) {
// Try loading .env file, but don't fail if it doesn't exist
_ = godotenv.Load()
cfg := &Config{
Database: models.DefaultDatabaseConfig(),
JWTConfig: models.JWTConfig{},
}
// Load database configuration
if err := loadDatabaseConfig(cfg); err != nil {
return nil, fmt.Errorf("failed to load database config: %w", err)
}
// Load JWT configuration
if err := loadJWTConfig(cfg); err != nil {
return nil, fmt.Errorf("failed to load JWT config: %w", err)
}
// Load API key
cfg.APIKey = getEnv("API_KEY", "")
return cfg, nil
}
// loadJWTConfig loads JWT configuration from environment
func loadJWTConfig(cfg *Config) error {
cfg.JWTConfig.Secret = getEnv("JWT_SECRET", "")
defaultDuration := 24 * time.Hour
durationStr := getEnv("JWT_TOKEN_DURATION", defaultDuration.String())
duration, err := time.ParseDuration(durationStr)
if err != nil {
return fmt.Errorf("invalid JWT_TOKEN_DURATION: %w", err)
}
cfg.JWTConfig.TokenDuration = duration
keyGenerateStr := getEnv("JWT_KEY_GENERATE", "true")
keyGenerate, err := strconv.ParseBool(keyGenerateStr)
if err != nil {
return fmt.Errorf("invalid JWT_KEY_GENERATE: %w", err)
}
cfg.JWTConfig.KeyGenerate = keyGenerate
cfg.JWTConfig.KeyDir = getEnv("JWT_KEY_DIR", "./keys")
cfg.JWTConfig.PrivKeyFile = getEnv("JWT_PRIV_KEY_FILE", "jwt.key")
cfg.JWTConfig.PubKeyFile = getEnv("JWT_PUB_KEY_FILE", "jwt.key.pub")
keyBitsStr := getEnv("JWT_KEY_BITS", "2048")
keyBits, err := strconv.Atoi(keyBitsStr)
if err != nil {
return fmt.Errorf("invalid JWT_KEY_BITS: %w", err)
}
cfg.JWTConfig.KeyBits = keyBits
return nil
}
// loadDatabaseConfig loads database configuration from environment
func loadDatabaseConfig(cfg *Config) error {
// Required fields
cfg.Database.Host = getEnv("DB_HOST", cfg.Database.Host)
cfg.Database.User = getEnv("DB_USER", cfg.Database.User)
cfg.Database.Password = getEnv("DB_PASSWORD", cfg.Database.Password)
cfg.Database.DBName = getEnv("DB_NAME", cfg.Database.DBName)
cfg.Database.SSLMode = getEnv("DB_SSLMODE", cfg.Database.SSLMode)
// Optional fields with parsing
if port := getEnv("DB_PORT", ""); port != "" {
portInt, err := strconv.Atoi(port)
if err != nil || portInt <= 0 {
return errors.New("invalid DB_PORT value")
}
cfg.Database.Port = portInt
}
// Log level based on environment
if os.Getenv("ENVIRONMENT") == "production" {
cfg.Database.LogLevel = logger.Error
} else {
cfg.Database.LogLevel = logger.Info
}
// Validate required fields
if cfg.Database.Host == "" || cfg.Database.User == "" ||
cfg.Database.Password == "" || cfg.Database.DBName == "" {
return errors.New("missing required database configuration")
}
return nil
}
// getEnv gets an environment variable with fallback
func getEnv(key, fallback string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return fallback
}
// MustLoadConfig loads configuration or panics on failure
func MustLoadConfig() *Config {
cfg, err := LoadConfig()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
return cfg
}
-30
View File
@@ -1,30 +0,0 @@
package dto
import (
"time"
"github.com/oklog/ulid/v2"
)
type ActivityDto struct {
ID ulid.ULID `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastEditorID ulid.ULID `json:"lastEditorID"`
Name string `json:"name"`
BillingRate float64 `json:"billingRate"`
}
type ActivityCreateDto struct {
Name string `json:"name"`
BillingRate float64 `json:"billingRate"`
}
type ActivityUpdateDto struct {
ID ulid.ULID `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
LastEditorID *ulid.ULID `json:"lastEditorID"`
Name *string `json:"name"`
BillingRate *float64 `json:"billingRate"`
}
-6
View File
@@ -1,6 +0,0 @@
package dto
type AuthDto struct {
Email string `json:"email"`
Password string `json:"password"`
}
-27
View File
@@ -1,27 +0,0 @@
package dto
import (
"time"
"github.com/oklog/ulid/v2"
)
type CompanyDto struct {
ID ulid.ULID `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastEditorID ulid.ULID `json:"lastEditorID"`
Name string `json:"name"`
}
type CompanyCreateDto struct {
Name string `json:"name"`
}
type CompanyUpdateDto struct {
ID ulid.ULID `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
LastEditorID *ulid.ULID `json:"lastEditorID"`
Name *string `json:"name"`
}
-30
View File
@@ -1,30 +0,0 @@
package dto
import (
"time"
"github.com/oklog/ulid/v2"
)
type CustomerDto struct {
ID ulid.ULID `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastEditorID ulid.ULID `json:"lastEditorID"`
Name string `json:"name"`
CompanyID int `json:"companyId"`
}
type CustomerCreateDto struct {
Name string `json:"name"`
CompanyID int `json:"companyId"`
}
type CustomerUpdateDto struct {
ID ulid.ULID `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
LastEditorID *ulid.ULID `json:"lastEditorID"`
Name *string `json:"name"`
CompanyID *int `json:"companyId"`
}
-30
View File
@@ -1,30 +0,0 @@
package dto
import (
"time"
"github.com/oklog/ulid/v2"
)
type ProjectDto struct {
ID ulid.ULID `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastEditorID ulid.ULID `json:"lastEditorID"`
Name string `json:"name"`
CustomerID int `json:"customerId"`
}
type ProjectCreateDto struct {
Name string `json:"name"`
CustomerID int `json:"customerId"`
}
type ProjectUpdateDto struct {
ID ulid.ULID `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
LastEditorID *ulid.ULID `json:"lastEditorID"`
Name *string `json:"name"`
CustomerID *int `json:"customerId"`
}
-45
View File
@@ -1,45 +0,0 @@
package dto
import (
"time"
"github.com/oklog/ulid/v2"
)
type TimeEntryDto struct {
ID ulid.ULID `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastEditorID ulid.ULID `json:"lastEditorID"`
UserID int `json:"userId"`
ProjectID int `json:"projectId"`
ActivityID int `json:"activityId"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Description string `json:"description"`
Billable int `json:"billable"` // Percentage (0-100)
}
type TimeEntryCreateDto struct {
UserID int `json:"userId"`
ProjectID int `json:"projectId"`
ActivityID int `json:"activityId"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Description string `json:"description"`
Billable int `json:"billable"` // Percentage (0-100)
}
type TimeEntryUpdateDto struct {
ID ulid.ULID `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
LastEditorID *ulid.ULID `json:"lastEditorID"`
UserID *int `json:"userId"`
ProjectID *int `json:"projectId"`
ActivityID *int `json:"activityId"`
Start *time.Time `json:"start"`
End *time.Time `json:"end"`
Description *string `json:"description"`
Billable *int `json:"billable"` // Percentage (0-100)
}
-38
View File
@@ -1,38 +0,0 @@
package dto
import (
"time"
"github.com/oklog/ulid/v2"
)
type UserDto struct {
ID ulid.ULID `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastEditorID ulid.ULID `json:"lastEditorID"`
Email string `json:"email"`
Role string `json:"role"`
CompanyID int `json:"companyId"`
HourlyRate float64 `json:"hourlyRate"`
}
type UserCreateDto struct {
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
CompanyID int `json:"companyId"`
HourlyRate float64 `json:"hourlyRate"`
}
type UserUpdateDto struct {
ID ulid.ULID `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
LastEditorID *ulid.ULID `json:"lastEditorID"`
Email *string `json:"email"`
Password *string `json:"password"`
Role *string `json:"role"`
CompanyID *int `json:"companyId"`
HourlyRate *float64 `json:"hourlyRate"`
}
+18 -18
View File
@@ -4,37 +4,37 @@ import (
"context" "context"
"errors" "errors"
"github.com/oklog/ulid/v2" "github.com/timetracker/backend/internal/types"
"gorm.io/gorm" "gorm.io/gorm"
) )
// Activity repräsentiert eine Aktivität im System // Activity represents an activity in the system
type Activity struct { type Activity struct {
EntityBase EntityBase
Name string `gorm:"column:name"` Name string `gorm:"column:name"`
BillingRate float64 `gorm:"column:billing_rate"` BillingRate float64 `gorm:"column:billing_rate"`
} }
// TableName gibt den Tabellennamen für GORM an // TableName specifies the table name for GORM
func (Activity) TableName() string { func (Activity) TableName() string {
return "activities" return "activities"
} }
// ActivityUpdate enthält die aktualisierbaren Felder einer Activity // ActivityUpdate contains the updatable fields of an Activity
type ActivityUpdate struct { type ActivityUpdate struct {
ID ulid.ULID `gorm:"-"` // Verwenden Sie "-" um anzuzeigen, dass dieses Feld ignoriert werden soll ID types.ULID `gorm:"-"` // Use "-" to indicate that this field should be ignored
Name *string `gorm:"column:name"` Name *string `gorm:"column:name"`
BillingRate *float64 `gorm:"column:billing_rate"` BillingRate *float64 `gorm:"column:billing_rate"`
} }
// ActivityCreate enthält die Felder zum Erstellen einer neuen Activity // ActivityCreate contains the fields for creating a new Activity
type ActivityCreate struct { type ActivityCreate struct {
Name string `gorm:"column:name"` Name string `gorm:"column:name"`
BillingRate float64 `gorm:"column:billing_rate"` BillingRate float64 `gorm:"column:billing_rate"`
} }
// GetActivityByID sucht eine Activity anhand ihrer ID // GetActivityByID finds an Activity by its ID
func GetActivityByID(ctx context.Context, id ulid.ULID) (*Activity, error) { func GetActivityByID(ctx context.Context, id types.ULID) (*Activity, error) {
var activity Activity var activity Activity
result := GetEngine(ctx).Where("id = ?", id).First(&activity) result := GetEngine(ctx).Where("id = ?", id).First(&activity)
if result.Error != nil { if result.Error != nil {
@@ -46,7 +46,7 @@ func GetActivityByID(ctx context.Context, id ulid.ULID) (*Activity, error) {
return &activity, nil return &activity, nil
} }
// GetAllActivities gibt alle Activities zurück // GetAllActivities returns all Activities
func GetAllActivities(ctx context.Context) ([]Activity, error) { func GetAllActivities(ctx context.Context) ([]Activity, error) {
var activities []Activity var activities []Activity
result := GetEngine(ctx).Find(&activities) result := GetEngine(ctx).Find(&activities)
@@ -56,7 +56,7 @@ func GetAllActivities(ctx context.Context) ([]Activity, error) {
return activities, nil return activities, nil
} }
// CreateActivity erstellt eine neue Activity // CreateActivity creates a new Activity
func CreateActivity(ctx context.Context, create ActivityCreate) (*Activity, error) { func CreateActivity(ctx context.Context, create ActivityCreate) (*Activity, error) {
activity := Activity{ activity := Activity{
Name: create.Name, Name: create.Name,
@@ -70,27 +70,27 @@ func CreateActivity(ctx context.Context, create ActivityCreate) (*Activity, erro
return &activity, nil return &activity, nil
} }
// UpdateActivity aktualisiert eine bestehende Activity // UpdateActivity updates an existing Activity
func UpdateActivity(ctx context.Context, update ActivityUpdate) (*Activity, error) { func UpdateActivity(ctx context.Context, update ActivityUpdate) (*Activity, error) {
activity, err := GetActivityByID(ctx, update.ID) activity, err := GetActivityByID(ctx, update.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if activity == nil { if activity == nil {
return nil, errors.New("activity nicht gefunden") return nil, errors.New("activity not found")
} }
// Generische Update-Funktion verwenden // Use generic update function
if err := UpdateModel(ctx, activity, update); err != nil { if err := UpdateModel(ctx, activity, update); err != nil {
return nil, err return nil, err
} }
// Aktualisierte Daten aus der Datenbank laden // Load updated data from the database
return GetActivityByID(ctx, update.ID) return GetActivityByID(ctx, update.ID)
} }
// DeleteActivity löscht eine Activity anhand ihrer ID // DeleteActivity deletes an Activity by its ID
func DeleteActivity(ctx context.Context, id ulid.ULID) error { func DeleteActivity(ctx context.Context, id types.ULID) error {
result := GetEngine(ctx).Delete(&Activity{}, id) result := GetEngine(ctx).Delete(&Activity{}, id)
return result.Error return result.Error
} }
+7 -5
View File
@@ -5,22 +5,24 @@ import (
"time" "time"
"github.com/oklog/ulid/v2" "github.com/oklog/ulid/v2"
"github.com/timetracker/backend/internal/types"
"gorm.io/gorm" "gorm.io/gorm"
) )
type EntityBase struct { type EntityBase struct {
ID ulid.ULID `gorm:"type:uuid;primaryKey"` ID types.ULID `gorm:"type:bytea;primaryKey"`
CreatedAt time.Time `gorm:"index"` CreatedAt time.Time `gorm:"index"`
UpdatedAt time.Time `gorm:"index"` UpdatedAt time.Time `gorm:"index"`
DeletedAt gorm.DeletedAt `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"`
} }
// BeforeCreate wird von GORM vor dem Erstellen eines Datensatzes aufgerufen // BeforeCreate is called by GORM before creating a record
func (eb *EntityBase) BeforeCreate(tx *gorm.DB) error { func (eb *EntityBase) BeforeCreate(tx *gorm.DB) error {
if eb.ID.Compare(ulid.ULID{}) == 0 { // Wenn ID leer ist if eb.ID.Compare(types.ULID{}) == 0 { // If ID is empty
// Generiere eine neue ULID // Generate a new types.ULID
entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0) entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0)
eb.ID = ulid.MustNew(ulid.Timestamp(time.Now()), entropy) newID := ulid.MustNew(ulid.Timestamp(time.Now()), entropy)
eb.ID = types.ULID{ULID: newID}
} }
return nil return nil
} }
+17 -17
View File
@@ -4,34 +4,34 @@ import (
"context" "context"
"errors" "errors"
"github.com/oklog/ulid/v2" "github.com/timetracker/backend/internal/types"
"gorm.io/gorm" "gorm.io/gorm"
) )
// Company repräsentiert ein Unternehmen im System // Company represents a company in the system
type Company struct { type Company struct {
EntityBase EntityBase
Name string `gorm:"column:name"` Name string `gorm:"column:name"`
} }
// TableName gibt den Tabellennamen für GORM an // TableName specifies the table name for GORM
func (Company) TableName() string { func (Company) TableName() string {
return "companies" return "companies"
} }
// CompanyCreate enthält die Felder zum Erstellen eines neuen Unternehmens // CompanyCreate contains the fields for creating a new company
type CompanyCreate struct { type CompanyCreate struct {
Name string Name string
} }
// CompanyUpdate enthält die aktualisierbaren Felder eines Unternehmens // CompanyUpdate contains the updatable fields of a company
type CompanyUpdate struct { type CompanyUpdate struct {
ID ulid.ULID `gorm:"-"` // Ausschließen von Updates ID types.ULID `gorm:"-"` // Exclude from updates
Name *string `gorm:"column:name"` Name *string `gorm:"column:name"`
} }
// GetCompanyByID sucht ein Unternehmen anhand seiner ID // GetCompanyByID finds a company by its ID
func GetCompanyByID(ctx context.Context, id ulid.ULID) (*Company, error) { func GetCompanyByID(ctx context.Context, id types.ULID) (*Company, error) {
var company Company var company Company
result := GetEngine(ctx).Where("id = ?", id).First(&company) result := GetEngine(ctx).Where("id = ?", id).First(&company)
if result.Error != nil { if result.Error != nil {
@@ -43,7 +43,7 @@ func GetCompanyByID(ctx context.Context, id ulid.ULID) (*Company, error) {
return &company, nil return &company, nil
} }
// GetAllCompanies gibt alle Unternehmen zurück // GetAllCompanies returns all companies
func GetAllCompanies(ctx context.Context) ([]Company, error) { func GetAllCompanies(ctx context.Context) ([]Company, error) {
var companies []Company var companies []Company
result := GetEngine(ctx).Find(&companies) result := GetEngine(ctx).Find(&companies)
@@ -62,7 +62,7 @@ func GetCustomersByCompanyID(ctx context.Context, companyID int) ([]Customer, er
return customers, nil return customers, nil
} }
// CreateCompany erstellt ein neues Unternehmen // CreateCompany creates a new company
func CreateCompany(ctx context.Context, create CompanyCreate) (*Company, error) { func CreateCompany(ctx context.Context, create CompanyCreate) (*Company, error) {
company := Company{ company := Company{
Name: create.Name, Name: create.Name,
@@ -75,27 +75,27 @@ func CreateCompany(ctx context.Context, create CompanyCreate) (*Company, error)
return &company, nil return &company, nil
} }
// UpdateCompany aktualisiert ein bestehendes Unternehmen // UpdateCompany updates an existing company
func UpdateCompany(ctx context.Context, update CompanyUpdate) (*Company, error) { func UpdateCompany(ctx context.Context, update CompanyUpdate) (*Company, error) {
company, err := GetCompanyByID(ctx, update.ID) company, err := GetCompanyByID(ctx, update.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if company == nil { if company == nil {
return nil, errors.New("company nicht gefunden") return nil, errors.New("company not found")
} }
// Generische Update-Funktion verwenden // Use generic update function
if err := UpdateModel(ctx, company, update); err != nil { if err := UpdateModel(ctx, company, update); err != nil {
return nil, err return nil, err
} }
// Aktualisierte Daten aus der Datenbank laden // Load updated data from the database
return GetCompanyByID(ctx, update.ID) return GetCompanyByID(ctx, update.ID)
} }
// DeleteCompany löscht ein Unternehmen anhand seiner ID // DeleteCompany deletes a company by its ID
func DeleteCompany(ctx context.Context, id ulid.ULID) error { func DeleteCompany(ctx context.Context, id types.ULID) error {
result := GetEngine(ctx).Delete(&Company{}, id) result := GetEngine(ctx).Delete(&Company{}, id)
return result.Error return result.Error
} }
+25 -22
View File
@@ -4,37 +4,40 @@ import (
"context" "context"
"errors" "errors"
"github.com/oklog/ulid/v2" "github.com/timetracker/backend/internal/types"
"gorm.io/gorm" "gorm.io/gorm"
) )
// Customer repräsentiert einen Kunden im System // Customer represents a customer in the system
type Customer struct { type Customer struct {
EntityBase EntityBase
Name string `gorm:"column:name"` Name string `gorm:"column:name"`
CompanyID int `gorm:"column:company_id"` CompanyID *types.ULID `gorm:"type:bytea;column:company_id"`
OwnerUserID *types.ULID `gorm:"type:bytea;column:owner_user_id"`
} }
// TableName gibt den Tabellennamen für GORM an // TableName specifies the table name for GORM
func (Customer) TableName() string { func (Customer) TableName() string {
return "customers" return "customers"
} }
// CustomerCreate enthält die Felder zum Erstellen eines neuen Kunden // CustomerCreate contains the fields for creating a new customer
type CustomerCreate struct { type CustomerCreate struct {
Name string Name string
CompanyID int CompanyID *types.ULID
OwnerUserID *types.ULID
} }
// CustomerUpdate enthält die aktualisierbaren Felder eines Kunden // CustomerUpdate contains the updatable fields of a customer
type CustomerUpdate struct { type CustomerUpdate struct {
ID ulid.ULID `gorm:"-"` // Ausschließen von Updates ID types.ULID `gorm:"-"` // Exclude from updates
Name *string `gorm:"column:name"` Name *string `gorm:"column:name"`
CompanyID *int `gorm:"column:company_id"` CompanyID *types.ULID `gorm:"column:company_id"`
OwnerUserID *types.ULID `gorm:"column:owner_user_id"`
} }
// GetCustomerByID sucht einen Kunden anhand seiner ID // GetCustomerByID finds a customer by its ID
func GetCustomerByID(ctx context.Context, id ulid.ULID) (*Customer, error) { func GetCustomerByID(ctx context.Context, id types.ULID) (*Customer, error) {
var customer Customer var customer Customer
result := GetEngine(ctx).Where("id = ?", id).First(&customer) result := GetEngine(ctx).Where("id = ?", id).First(&customer)
if result.Error != nil { if result.Error != nil {
@@ -46,7 +49,7 @@ func GetCustomerByID(ctx context.Context, id ulid.ULID) (*Customer, error) {
return &customer, nil return &customer, nil
} }
// GetAllCustomers gibt alle Kunden zurück // GetAllCustomers returns all customers
func GetAllCustomers(ctx context.Context) ([]Customer, error) { func GetAllCustomers(ctx context.Context) ([]Customer, error) {
var customers []Customer var customers []Customer
result := GetEngine(ctx).Find(&customers) result := GetEngine(ctx).Find(&customers)
@@ -56,7 +59,7 @@ func GetAllCustomers(ctx context.Context) ([]Customer, error) {
return customers, nil return customers, nil
} }
// CreateCustomer erstellt einen neuen Kunden // CreateCustomer creates a new customer
func CreateCustomer(ctx context.Context, create CustomerCreate) (*Customer, error) { func CreateCustomer(ctx context.Context, create CustomerCreate) (*Customer, error) {
customer := Customer{ customer := Customer{
Name: create.Name, Name: create.Name,
@@ -70,27 +73,27 @@ func CreateCustomer(ctx context.Context, create CustomerCreate) (*Customer, erro
return &customer, nil return &customer, nil
} }
// UpdateCustomer aktualisiert einen bestehenden Kunden // UpdateCustomer updates an existing customer
func UpdateCustomer(ctx context.Context, update CustomerUpdate) (*Customer, error) { func UpdateCustomer(ctx context.Context, update CustomerUpdate) (*Customer, error) {
customer, err := GetCustomerByID(ctx, update.ID) customer, err := GetCustomerByID(ctx, update.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if customer == nil { if customer == nil {
return nil, errors.New("customer nicht gefunden") return nil, errors.New("customer not found")
} }
// Generische Update-Funktion verwenden // Use generic update function
if err := UpdateModel(ctx, customer, update); err != nil { if err := UpdateModel(ctx, customer, update); err != nil {
return nil, err return nil, err
} }
// Aktualisierte Daten aus der Datenbank laden // Load updated data from the database
return GetCustomerByID(ctx, update.ID) return GetCustomerByID(ctx, update.ID)
} }
// DeleteCustomer löscht einen Kunden anhand seiner ID // DeleteCustomer deletes a customer by its ID
func DeleteCustomer(ctx context.Context, id ulid.ULID) error { func DeleteCustomer(ctx context.Context, id types.ULID) error {
result := GetEngine(ctx).Delete(&Customer{}, id) result := GetEngine(ctx).Delete(&Customer{}, id)
return result.Error return result.Error
} }
+148 -29
View File
@@ -4,88 +4,207 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log"
"reflect" "reflect"
"strings" "strings"
"time"
"gorm.io/driver/postgres" // Für PostgreSQL "gorm.io/driver/postgres" // For PostgreSQL
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger"
) )
// Globale Variable für die DB-Verbindung // Global variable for the DB connection
var defaultDB *gorm.DB var defaultDB *gorm.DB
// DatabaseConfig enthält die Konfigurationsdaten für die Datenbankverbindung // DatabaseConfig contains the configuration data for the database connection
type DatabaseConfig struct { type DatabaseConfig struct {
Host string Host string
Port int Port int
User string User string
Password string Password string
DBName string DBName string
SSLMode 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
} }
// InitDB initialisiert die Datenbankverbindung (einmalig beim Start) // DefaultDatabaseConfig returns a default configuration with sensible values
// mit der übergebenen Konfiguration 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)
// with the provided configuration
func InitDB(config DatabaseConfig) error { func InitDB(config DatabaseConfig) error {
// DSN (Data Source Name) erstellen // Create DSN (Data Source Name)
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", 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) config.Host, config.Port, config.User, config.Password, config.DBName, config.SSLMode)
// Datenbankverbindung herstellen // Configure GORM logger
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) 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 { if err != nil {
return fmt.Errorf("fehler beim Verbinden zur Datenbank: %w", err) 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 defaultDB = db
return nil return nil
} }
// GetEngine gibt die DB-Instanz zurück, ggf. mit context // 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
}
// GetEngine returns the DB instance, possibly with context
func GetEngine(ctx context.Context) *gorm.DB { func GetEngine(ctx context.Context) *gorm.DB {
// Falls in ctx eine spezielle Transaktion steckt, könnte man das hier prüfen if defaultDB == nil {
panic("database not initialized")
}
// If a special transaction is in ctx, you could check it here
return defaultDB.WithContext(ctx) return defaultDB.WithContext(ctx)
} }
// UpdateModel aktualisiert ein Modell anhand der gesetzten Pointer-Felder // 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
}
func GetGormDB(dbConfig DatabaseConfig, dbName string) (*gorm.DB, error) {
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
dbConfig.Host, dbConfig.Port, dbConfig.User, dbConfig.Password, dbName, dbConfig.SSLMode)
// 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: dbConfig.LogLevel, // Log level
IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
Colorful: true, // Enable color
},
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: gormLogger,
})
if err != nil {
return nil, fmt.Errorf("error connecting to the database: %w", err)
}
return db, nil
}
// UpdateModel updates a model based on the set pointer fields
func UpdateModel(ctx context.Context, model any, updates any) error { func UpdateModel(ctx context.Context, model any, updates any) error {
updateValue := reflect.ValueOf(updates) updateValue := reflect.ValueOf(updates)
// Wenn updates ein Pointer ist, den Wert dahinter verwenden // If updates is a pointer, use the value behind it
if updateValue.Kind() == reflect.Ptr { if updateValue.Kind() == reflect.Ptr {
updateValue = updateValue.Elem() updateValue = updateValue.Elem()
} }
// Stelle sicher, dass updates eine Struktur ist // Make sure updates is a struct
if updateValue.Kind() != reflect.Struct { if updateValue.Kind() != reflect.Struct {
return errors.New("updates muss eine Struktur sein") return errors.New("updates must be a struct")
} }
updateType := updateValue.Type() updateType := updateValue.Type()
updateMap := make(map[string]any) updateMap := make(map[string]any)
// Durch alle Felder iterieren // Iterate through all fields
for i := 0; i < updateValue.NumField(); i++ { for i := 0; i < updateValue.NumField(); i++ {
field := updateValue.Field(i) field := updateValue.Field(i)
fieldType := updateType.Field(i) fieldType := updateType.Field(i)
// Überspringen von unexportierten Feldern // Skip unexported fields
if !fieldType.IsExported() { if !fieldType.IsExported() {
continue continue
} }
// Spezialfall: ID-Feld überspringen (nur für Updates verwenden) // Special case: Skip ID field (use only for updates)
if fieldType.Name == "ID" { if fieldType.Name == "ID" {
continue continue
} }
// Für Pointer-Typen prüfen, ob sie nicht nil sind // For pointer types, check if they are not nil
if field.Kind() == reflect.Ptr && !field.IsNil() { if field.Kind() == reflect.Ptr && !field.IsNil() {
// Feldname aus GORM-Tag extrahieren oder Standard-Feldnamen verwenden // Extract field name from GORM tag or use default field name
fieldName := fieldType.Name fieldName := fieldType.Name
if tag, ok := fieldType.Tag.Lookup("gorm"); ok { if tag, ok := fieldType.Tag.Lookup("gorm"); ok {
// Tag-Optionen trennen // Separate tag options
options := strings.Split(tag, ";") options := strings.Split(tag, ";")
for _, option := range options { for _, option := range options {
if strings.HasPrefix(option, "column:") { if strings.HasPrefix(option, "column:") {
@@ -95,13 +214,13 @@ func UpdateModel(ctx context.Context, model any, updates any) error {
} }
} }
// Den Wert hinter dem Pointer verwenden // Use the value behind the pointer
updateMap[fieldName] = field.Elem().Interface() updateMap[fieldName] = field.Elem().Interface()
} }
} }
if len(updateMap) == 0 { if len(updateMap) == 0 {
return nil // Nichts zu aktualisieren return nil // Nothing to update
} }
return GetEngine(ctx).Model(model).Updates(updateMap).Error return GetEngine(ctx).Model(model).Updates(updateMap).Error
+13
View File
@@ -0,0 +1,13 @@
package models
import "time"
type JWTConfig struct {
Secret string `env:"JWT_SECRET" default:""`
TokenDuration time.Duration `env:"JWT_TOKEN_DURATION" default:"24h"`
KeyGenerate bool `env:"JWT_KEY_GENERATE" default:"true"`
KeyDir string `env:"JWT_KEY_DIR" default:"./keys"`
PrivKeyFile string `env:"JWT_PRIV_KEY_FILE" default:"jwt.key"`
PubKeyFile string `env:"JWT_PUB_KEY_FILE" default:"jwt.key.pub"`
KeyBits int `env:"JWT_KEY_BITS" default:"2048"`
}
+64 -62
View File
@@ -5,60 +5,60 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/oklog/ulid/v2" "github.com/timetracker/backend/internal/types"
"gorm.io/gorm" "gorm.io/gorm"
) )
// Project repräsentiert ein Projekt im System // Project represents a project in the system
type Project struct { type Project struct {
EntityBase EntityBase
Name string `gorm:"column:name;not null"` Name string `gorm:"column:name;not null"`
CustomerID ulid.ULID `gorm:"column:customer_id;type:uuid;not null"` CustomerID *types.ULID `gorm:"column:customer_id;type:bytea;not null"`
// Beziehungen (für Eager Loading) // Relationships (for Eager Loading)
Customer *Customer `gorm:"foreignKey:CustomerID"` Customer *Customer `gorm:"foreignKey:CustomerID"`
} }
// TableName gibt den Tabellennamen für GORM an // TableName specifies the table name for GORM
func (Project) TableName() string { func (Project) TableName() string {
return "projects" return "projects"
} }
// ProjectCreate enthält die Felder zum Erstellen eines neuen Projekts // ProjectCreate contains the fields for creating a new project
type ProjectCreate struct { type ProjectCreate struct {
Name string Name string
CustomerID ulid.ULID CustomerID *types.ULID
} }
// ProjectUpdate enthält die aktualisierbaren Felder eines Projekts // ProjectUpdate contains the updatable fields of a project
type ProjectUpdate struct { type ProjectUpdate struct {
ID ulid.ULID `gorm:"-"` // Ausschließen von Updates ID types.ULID `gorm:"-"` // Exclude from updates
Name *string `gorm:"column:name"` Name *string `gorm:"column:name"`
CustomerID *ulid.ULID `gorm:"column:customer_id"` CustomerID *types.ULID `gorm:"column:customer_id"`
} }
// Validate prüft, ob die Create-Struktur gültige Daten enthält // Validate checks if the Create struct contains valid data
func (pc *ProjectCreate) Validate() error { func (pc *ProjectCreate) Validate() error {
if pc.Name == "" { if pc.Name == "" {
return errors.New("project name darf nicht leer sein") return errors.New("project name cannot be empty")
} }
// Prüfung auf gültige CustomerID // Check for valid CustomerID
if pc.CustomerID.Compare(ulid.ULID{}) == 0 { if pc.CustomerID.Compare(types.ULID{}) == 0 {
return errors.New("customerID darf nicht leer sein") return errors.New("customerID cannot be empty")
} }
return nil return nil
} }
// Validate prüft, ob die Update-Struktur gültige Daten enthält // Validate checks if the Update struct contains valid data
func (pu *ProjectUpdate) Validate() error { func (pu *ProjectUpdate) Validate() error {
if pu.Name != nil && *pu.Name == "" { if pu.Name != nil && *pu.Name == "" {
return errors.New("project name darf nicht leer sein") return errors.New("project name cannot be empty")
} }
return nil return nil
} }
// GetProjectByID sucht ein Projekt anhand seiner ID // GetProjectByID finds a project by its ID
func GetProjectByID(ctx context.Context, id ulid.ULID) (*Project, error) { func GetProjectByID(ctx context.Context, id types.ULID) (*Project, error) {
var project Project var project Project
result := GetEngine(ctx).Where("id = ?", id).First(&project) result := GetEngine(ctx).Where("id = ?", id).First(&project)
if result.Error != nil { if result.Error != nil {
@@ -70,8 +70,8 @@ func GetProjectByID(ctx context.Context, id ulid.ULID) (*Project, error) {
return &project, nil return &project, nil
} }
// GetProjectWithCustomer lädt ein Projekt mit den zugehörigen Kundeninformationen // GetProjectWithCustomer loads a project with the associated customer information
func GetProjectWithCustomer(ctx context.Context, id ulid.ULID) (*Project, error) { func GetProjectWithCustomer(ctx context.Context, id types.ULID) (*Project, error) {
var project Project var project Project
result := GetEngine(ctx).Preload("Customer").Where("id = ?", id).First(&project) result := GetEngine(ctx).Preload("Customer").Where("id = ?", id).First(&project)
if result.Error != nil { if result.Error != nil {
@@ -83,7 +83,7 @@ func GetProjectWithCustomer(ctx context.Context, id ulid.ULID) (*Project, error)
return &project, nil return &project, nil
} }
// GetAllProjects gibt alle Projekte zurück // GetAllProjects returns all projects
func GetAllProjects(ctx context.Context) ([]Project, error) { func GetAllProjects(ctx context.Context) ([]Project, error) {
var projects []Project var projects []Project
result := GetEngine(ctx).Find(&projects) result := GetEngine(ctx).Find(&projects)
@@ -93,7 +93,7 @@ func GetAllProjects(ctx context.Context) ([]Project, error) {
return projects, nil return projects, nil
} }
// GetAllProjectsWithCustomers gibt alle Projekte mit Kundeninformationen zurück // GetAllProjectsWithCustomers returns all projects with customer information
func GetAllProjectsWithCustomers(ctx context.Context) ([]Project, error) { func GetAllProjectsWithCustomers(ctx context.Context) ([]Project, error) {
var projects []Project var projects []Project
result := GetEngine(ctx).Preload("Customer").Find(&projects) result := GetEngine(ctx).Preload("Customer").Find(&projects)
@@ -103,30 +103,32 @@ func GetAllProjectsWithCustomers(ctx context.Context) ([]Project, error) {
return projects, nil return projects, nil
} }
// GetProjectsByCustomerID gibt alle Projekte eines bestimmten Kunden zurück // GetProjectsByCustomerID returns all projects of a specific customer
func GetProjectsByCustomerID(ctx context.Context, customerID ulid.ULID) ([]Project, error) { func GetProjectsByCustomerID(ctx context.Context, customerId types.ULID) ([]Project, error) {
var projects []Project var projects []Project
result := GetEngine(ctx).Where("customer_id = ?", customerID).Find(&projects) result := GetEngine(ctx).Where("customer_id = ?", customerId.ULID).Find(&projects)
if result.Error != nil { if result.Error != nil {
return nil, result.Error return nil, result.Error
} }
return projects, nil return projects, nil
} }
// CreateProject erstellt ein neues Projekt mit Validierung // CreateProject creates a new project with validation
func CreateProject(ctx context.Context, create ProjectCreate) (*Project, error) { func CreateProject(ctx context.Context, create ProjectCreate) (*Project, error) {
// Validierung // Validation
if err := create.Validate(); err != nil { if err := create.Validate(); err != nil {
return nil, fmt.Errorf("validierungsfehler: %w", err) return nil, fmt.Errorf("validation error: %w", err)
} }
// Prüfen, ob der Kunde existiert // Check if the customer exists
customer, err := GetCustomerByID(ctx, create.CustomerID) if create.CustomerID == nil {
if err != nil { customer, err := GetCustomerByID(ctx, *create.CustomerID)
return nil, fmt.Errorf("fehler beim Prüfen des Kunden: %w", err) if err != nil {
} return nil, fmt.Errorf("error checking the customer: %w", err)
if customer == nil { }
return nil, errors.New("der angegebene Kunde existiert nicht") if customer == nil {
return nil, errors.New("the specified customer does not exist")
}
} }
project := Project{ project := Project{
@@ -136,16 +138,16 @@ func CreateProject(ctx context.Context, create ProjectCreate) (*Project, error)
result := GetEngine(ctx).Create(&project) result := GetEngine(ctx).Create(&project)
if result.Error != nil { if result.Error != nil {
return nil, fmt.Errorf("fehler beim Erstellen des Projekts: %w", result.Error) return nil, fmt.Errorf("error creating the project: %w", result.Error)
} }
return &project, nil return &project, nil
} }
// UpdateProject aktualisiert ein bestehendes Projekt mit Validierung // UpdateProject updates an existing project with validation
func UpdateProject(ctx context.Context, update ProjectUpdate) (*Project, error) { func UpdateProject(ctx context.Context, update ProjectUpdate) (*Project, error) {
// Validierung // Validation
if err := update.Validate(); err != nil { if err := update.Validate(); err != nil {
return nil, fmt.Errorf("validierungsfehler: %w", err) return nil, fmt.Errorf("validation error: %w", err)
} }
project, err := GetProjectByID(ctx, update.ID) project, err := GetProjectByID(ctx, update.ID)
@@ -153,60 +155,60 @@ func UpdateProject(ctx context.Context, update ProjectUpdate) (*Project, error)
return nil, err return nil, err
} }
if project == nil { if project == nil {
return nil, errors.New("project nicht gefunden") return nil, errors.New("project not found")
} }
// Wenn CustomerID aktualisiert wird, prüfen ob der Kunde existiert // If CustomerID is updated, check if the customer exists
if update.CustomerID != nil { if update.CustomerID != nil {
customer, err := GetCustomerByID(ctx, *update.CustomerID) customer, err := GetCustomerByID(ctx, *update.CustomerID)
if err != nil { if err != nil {
return nil, fmt.Errorf("fehler beim Prüfen des Kunden: %w", err) return nil, fmt.Errorf("error checking the customer: %w", err)
} }
if customer == nil { if customer == nil {
return nil, errors.New("der angegebene Kunde existiert nicht") return nil, errors.New("the specified customer does not exist")
} }
} }
// Generische Update-Funktion verwenden // Use generic update function
if err := UpdateModel(ctx, project, update); err != nil { if err := UpdateModel(ctx, project, update); err != nil {
return nil, fmt.Errorf("fehler beim Aktualisieren des Projekts: %w", err) return nil, fmt.Errorf("error updating the project: %w", err)
} }
// Aktualisierte Daten aus der Datenbank laden // Load updated data from the database
return GetProjectByID(ctx, update.ID) return GetProjectByID(ctx, update.ID)
} }
// DeleteProject löscht ein Projekt anhand seiner ID // DeleteProject deletes a project by its ID
func DeleteProject(ctx context.Context, id ulid.ULID) error { func DeleteProject(ctx context.Context, id types.ULID) error {
// Hier könnte man prüfen, ob abhängige Entitäten existieren // Here you could check if dependent entities exist
result := GetEngine(ctx).Delete(&Project{}, id) result := GetEngine(ctx).Delete(&Project{}, id)
if result.Error != nil { if result.Error != nil {
return fmt.Errorf("fehler beim Löschen des Projekts: %w", result.Error) return fmt.Errorf("error deleting the project: %w", result.Error)
} }
return nil return nil
} }
// CreateProjectWithTransaction erstellt ein Projekt innerhalb einer Transaktion // CreateProjectWithTransaction creates a project within a transaction
func CreateProjectWithTransaction(ctx context.Context, create ProjectCreate) (*Project, error) { func CreateProjectWithTransaction(ctx context.Context, create ProjectCreate) (*Project, error) {
// Validierung // Validation
if err := create.Validate(); err != nil { if err := create.Validate(); err != nil {
return nil, fmt.Errorf("validierungsfehler: %w", err) return nil, fmt.Errorf("validation error: %w", err)
} }
var project *Project var project *Project
// Transaktion starten // Start transaction
err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error { err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
// Kundenprüfung innerhalb der Transaktion // Customer check within the transaction
var customer Customer var customer Customer
if err := tx.Where("id = ?", create.CustomerID).First(&customer).Error; err != nil { if err := tx.Where("id = ?", create.CustomerID).First(&customer).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("der angegebene Kunde existiert nicht") return errors.New("the specified customer does not exist")
} }
return err return err
} }
// Projekt erstellen // Create project
newProject := Project{ newProject := Project{
Name: create.Name, Name: create.Name,
CustomerID: create.CustomerID, CustomerID: create.CustomerID,
@@ -216,14 +218,14 @@ func CreateProjectWithTransaction(ctx context.Context, create ProjectCreate) (*P
return err return err
} }
// Projekt für die Rückgabe speichern // Save project for return
project = &newProject project = &newProject
return nil return nil
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("transaktionsfehler: %w", err) return nil, fmt.Errorf("transaction error: %w", err)
} }
return project, nil return project, nil
+92 -92
View File
@@ -6,104 +6,104 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/oklog/ulid/v2" "github.com/timetracker/backend/internal/types"
"gorm.io/gorm" "gorm.io/gorm"
) )
// TimeEntry repräsentiert einen Zeiteintrag im System // TimeEntry represents a time entry in the system
type TimeEntry struct { type TimeEntry struct {
EntityBase EntityBase
UserID ulid.ULID `gorm:"column:user_id;type:uuid;not null;index"` UserID types.ULID `gorm:"column:user_id;type:bytea;not null;index"`
ProjectID ulid.ULID `gorm:"column:project_id;type:uuid;not null;index"` ProjectID types.ULID `gorm:"column:project_id;type:bytea;not null;index"`
ActivityID ulid.ULID `gorm:"column:activity_id;type:uuid;not null;index"` ActivityID types.ULID `gorm:"column:activity_id;type:bytea;not null;index"`
Start time.Time `gorm:"column:start;not null"` Start time.Time `gorm:"column:start;not null"`
End time.Time `gorm:"column:end;not null"` End time.Time `gorm:"column:end;not null"`
Description string `gorm:"column:description"` Description string `gorm:"column:description"`
Billable int `gorm:"column:billable"` // Percentage (0-100) Billable int `gorm:"column:billable"` // Percentage (0-100)
// Beziehungen für Eager Loading // Relationships for Eager Loading
User *User `gorm:"foreignKey:UserID"` User *User `gorm:"foreignKey:UserID"`
Project *Project `gorm:"foreignKey:ProjectID"` Project *Project `gorm:"foreignKey:ProjectID"`
Activity *Activity `gorm:"foreignKey:ActivityID"` Activity *Activity `gorm:"foreignKey:ActivityID"`
} }
// TableName gibt den Tabellennamen für GORM an // TableName specifies the table name for GORM
func (TimeEntry) TableName() string { func (TimeEntry) TableName() string {
return "time_entries" return "time_entries"
} }
// TimeEntryCreate enthält die Felder zum Erstellen eines neuen Zeiteintrags // TimeEntryCreate contains the fields for creating a new time entry
type TimeEntryCreate struct { type TimeEntryCreate struct {
UserID ulid.ULID UserID types.ULID
ProjectID ulid.ULID ProjectID types.ULID
ActivityID ulid.ULID ActivityID types.ULID
Start time.Time Start time.Time
End time.Time End time.Time
Description string Description string
Billable int // Percentage (0-100) Billable int // Percentage (0-100)
} }
// TimeEntryUpdate enthält die aktualisierbaren Felder eines Zeiteintrags // TimeEntryUpdate contains the updatable fields of a time entry
type TimeEntryUpdate struct { type TimeEntryUpdate struct {
ID ulid.ULID `gorm:"-"` // Ausschließen von Updates ID types.ULID `gorm:"-"` // Exclude from updates
UserID *ulid.ULID `gorm:"column:user_id"` UserID *types.ULID `gorm:"column:user_id"`
ProjectID *ulid.ULID `gorm:"column:project_id"` ProjectID *types.ULID `gorm:"column:project_id"`
ActivityID *ulid.ULID `gorm:"column:activity_id"` ActivityID *types.ULID `gorm:"column:activity_id"`
Start *time.Time `gorm:"column:start"` Start *time.Time `gorm:"column:start"`
End *time.Time `gorm:"column:end"` End *time.Time `gorm:"column:end"`
Description *string `gorm:"column:description"` Description *string `gorm:"column:description"`
Billable *int `gorm:"column:billable"` Billable *int `gorm:"column:billable"`
} }
// Validate prüft, ob die Create-Struktur gültige Daten enthält // Validate checks if the Create struct contains valid data
func (tc *TimeEntryCreate) Validate() error { func (tc *TimeEntryCreate) Validate() error {
// Prüfung auf leere IDs // Check for empty IDs
if tc.UserID.Compare(ulid.ULID{}) == 0 { if tc.UserID.Compare(types.ULID{}) == 0 {
return errors.New("userID darf nicht leer sein") return errors.New("userID cannot be empty")
} }
if tc.ProjectID.Compare(ulid.ULID{}) == 0 { if tc.ProjectID.Compare(types.ULID{}) == 0 {
return errors.New("projectID darf nicht leer sein") return errors.New("projectID cannot be empty")
} }
if tc.ActivityID.Compare(ulid.ULID{}) == 0 { if tc.ActivityID.Compare(types.ULID{}) == 0 {
return errors.New("activityID darf nicht leer sein") return errors.New("activityID cannot be empty")
} }
// Zeitprüfungen // Time checks
if tc.Start.IsZero() { if tc.Start.IsZero() {
return errors.New("startzeit darf nicht leer sein") return errors.New("start time cannot be empty")
} }
if tc.End.IsZero() { if tc.End.IsZero() {
return errors.New("endzeit darf nicht leer sein") return errors.New("end time cannot be empty")
} }
if tc.End.Before(tc.Start) { if tc.End.Before(tc.Start) {
return errors.New("endzeit kann nicht vor der startzeit liegen") return errors.New("end time cannot be before start time")
} }
// Billable-Prozent Prüfung // Billable percentage check
if tc.Billable < 0 || tc.Billable > 100 { if tc.Billable < 0 || tc.Billable > 100 {
return errors.New("billable muss zwischen 0 und 100 liegen") return errors.New("billable must be between 0 and 100")
} }
return nil return nil
} }
// Validate prüft, ob die Update-Struktur gültige Daten enthält // Validate checks if the Update struct contains valid data
func (tu *TimeEntryUpdate) Validate() error { func (tu *TimeEntryUpdate) Validate() error {
// Billable-Prozent Prüfung // Billable percentage check
if tu.Billable != nil && (*tu.Billable < 0 || *tu.Billable > 100) { if tu.Billable != nil && (*tu.Billable < 0 || *tu.Billable > 100) {
return errors.New("billable muss zwischen 0 und 100 liegen") return errors.New("billable must be between 0 and 100")
} }
// Zeitprüfungen // Time checks
if tu.Start != nil && tu.End != nil && tu.End.Before(*tu.Start) { if tu.Start != nil && tu.End != nil && tu.End.Before(*tu.Start) {
return errors.New("endzeit kann nicht vor der startzeit liegen") return errors.New("end time cannot be before start time")
} }
return nil return nil
} }
// GetTimeEntryByID sucht einen Zeiteintrag anhand seiner ID // GetTimeEntryByID finds a time entry by its ID
func GetTimeEntryByID(ctx context.Context, id ulid.ULID) (*TimeEntry, error) { func GetTimeEntryByID(ctx context.Context, id types.ULID) (*TimeEntry, error) {
var timeEntry TimeEntry var timeEntry TimeEntry
result := GetEngine(ctx).Where("id = ?", id).First(&timeEntry) result := GetEngine(ctx).Where("id = ?", id).First(&timeEntry)
if result.Error != nil { if result.Error != nil {
@@ -115,13 +115,13 @@ func GetTimeEntryByID(ctx context.Context, id ulid.ULID) (*TimeEntry, error) {
return &timeEntry, nil return &timeEntry, nil
} }
// GetTimeEntryWithRelations lädt einen Zeiteintrag mit allen zugehörigen Daten // GetTimeEntryWithRelations loads a time entry with all associated data
func GetTimeEntryWithRelations(ctx context.Context, id ulid.ULID) (*TimeEntry, error) { func GetTimeEntryWithRelations(ctx context.Context, id types.ULID) (*TimeEntry, error) {
var timeEntry TimeEntry var timeEntry TimeEntry
result := GetEngine(ctx). result := GetEngine(ctx).
Preload("User"). Preload("User").
Preload("Project"). Preload("Project").
Preload("Project.Customer"). // Verschachtelte Beziehung Preload("Project.Customer"). // Nested relationship
Preload("Activity"). Preload("Activity").
Where("id = ?", id). Where("id = ?", id).
First(&timeEntry) First(&timeEntry)
@@ -135,7 +135,7 @@ func GetTimeEntryWithRelations(ctx context.Context, id ulid.ULID) (*TimeEntry, e
return &timeEntry, nil return &timeEntry, nil
} }
// GetAllTimeEntries gibt alle Zeiteinträge zurück // GetAllTimeEntries returns all time entries
func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) { func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) {
var timeEntries []TimeEntry var timeEntries []TimeEntry
result := GetEngine(ctx).Find(&timeEntries) result := GetEngine(ctx).Find(&timeEntries)
@@ -145,8 +145,8 @@ func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) {
return timeEntries, nil return timeEntries, nil
} }
// GetTimeEntriesByUserID gibt alle Zeiteinträge eines Benutzers zurück // GetTimeEntriesByUserID returns all time entries of a user
func GetTimeEntriesByUserID(ctx context.Context, userID ulid.ULID) ([]TimeEntry, error) { func GetTimeEntriesByUserID(ctx context.Context, userID types.ULID) ([]TimeEntry, error) {
var timeEntries []TimeEntry var timeEntries []TimeEntry
result := GetEngine(ctx).Where("user_id = ?", userID).Find(&timeEntries) result := GetEngine(ctx).Where("user_id = ?", userID).Find(&timeEntries)
if result.Error != nil { if result.Error != nil {
@@ -155,8 +155,8 @@ func GetTimeEntriesByUserID(ctx context.Context, userID ulid.ULID) ([]TimeEntry,
return timeEntries, nil return timeEntries, nil
} }
// GetTimeEntriesByProjectID gibt alle Zeiteinträge eines Projekts zurück // GetTimeEntriesByProjectID returns all time entries of a project
func GetTimeEntriesByProjectID(ctx context.Context, projectID ulid.ULID) ([]TimeEntry, error) { func GetTimeEntriesByProjectID(ctx context.Context, projectID types.ULID) ([]TimeEntry, error) {
var timeEntries []TimeEntry var timeEntries []TimeEntry
result := GetEngine(ctx).Where("project_id = ?", projectID).Find(&timeEntries) result := GetEngine(ctx).Where("project_id = ?", projectID).Find(&timeEntries)
if result.Error != nil { if result.Error != nil {
@@ -165,10 +165,10 @@ func GetTimeEntriesByProjectID(ctx context.Context, projectID ulid.ULID) ([]Time
return timeEntries, nil return timeEntries, nil
} }
// GetTimeEntriesByDateRange gibt alle Zeiteinträge in einem Zeitraum zurück // GetTimeEntriesByDateRange returns all time entries within a time range
func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]TimeEntry, error) { func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]TimeEntry, error) {
var timeEntries []TimeEntry var timeEntries []TimeEntry
// Suche nach Überschneidungen im Zeitraum // Search for overlaps in the time range
result := GetEngine(ctx). result := GetEngine(ctx).
Where("(start BETWEEN ? AND ?) OR (end BETWEEN ? AND ?)", Where("(start BETWEEN ? AND ?) OR (end BETWEEN ? AND ?)",
start, end, start, end). start, end, start, end).
@@ -180,15 +180,15 @@ func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]Tim
return timeEntries, nil return timeEntries, nil
} }
// SumBillableHoursByProject berechnet die abrechenbaren Stunden pro Projekt // SumBillableHoursByProject calculates the billable hours per project
func SumBillableHoursByProject(ctx context.Context, projectID ulid.ULID) (float64, error) { func SumBillableHoursByProject(ctx context.Context, projectID types.ULID) (float64, error) {
type Result struct { type Result struct {
TotalHours float64 TotalHours float64
} }
var result Result var result Result
// SQL-Berechnung der gewichteten Stunden // SQL calculation of weighted hours
err := GetEngine(ctx).Raw(` err := GetEngine(ctx).Raw(`
SELECT SUM( SELECT SUM(
EXTRACT(EPOCH FROM (end - start)) / 3600 * (billable / 100.0) EXTRACT(EPOCH FROM (end - start)) / 3600 * (billable / 100.0)
@@ -204,23 +204,23 @@ func SumBillableHoursByProject(ctx context.Context, projectID ulid.ULID) (float6
return result.TotalHours, nil return result.TotalHours, nil
} }
// CreateTimeEntry erstellt einen neuen Zeiteintrag mit Validierung // CreateTimeEntry creates a new time entry with validation
func CreateTimeEntry(ctx context.Context, create TimeEntryCreate) (*TimeEntry, error) { func CreateTimeEntry(ctx context.Context, create TimeEntryCreate) (*TimeEntry, error) {
// Validierung // Validation
if err := create.Validate(); err != nil { if err := create.Validate(); err != nil {
return nil, fmt.Errorf("validierungsfehler: %w", err) return nil, fmt.Errorf("validation error: %w", err)
} }
// Starten einer Transaktion // Start a transaction
var timeEntry *TimeEntry var timeEntry *TimeEntry
err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error { err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
// Verweise prüfen // Check references
if err := validateReferences(tx, create.UserID, create.ProjectID, create.ActivityID); err != nil { if err := validateReferences(tx, create.UserID, create.ProjectID, create.ActivityID); err != nil {
return err return err
} }
// Zeiteintrag erstellen // Create time entry
newTimeEntry := TimeEntry{ newTimeEntry := TimeEntry{
UserID: create.UserID, UserID: create.UserID,
ProjectID: create.ProjectID, ProjectID: create.ProjectID,
@@ -232,7 +232,7 @@ func CreateTimeEntry(ctx context.Context, create TimeEntryCreate) (*TimeEntry, e
} }
if err := tx.Create(&newTimeEntry).Error; err != nil { if err := tx.Create(&newTimeEntry).Error; err != nil {
return fmt.Errorf("fehler beim Erstellen des Zeiteintrags: %w", err) return fmt.Errorf("error creating the time entry: %w", err)
} }
timeEntry = &newTimeEntry timeEntry = &newTimeEntry
@@ -246,59 +246,59 @@ func CreateTimeEntry(ctx context.Context, create TimeEntryCreate) (*TimeEntry, e
return timeEntry, nil return timeEntry, nil
} }
// validateReferences prüft, ob alle referenzierten Entitäten existieren // validateReferences checks if all referenced entities exist
func validateReferences(tx *gorm.DB, userID, projectID, activityID ulid.ULID) error { func validateReferences(tx *gorm.DB, userID, projectID, activityID types.ULID) error {
// Benutzer prüfen // Check user
var userCount int64 var userCount int64
if err := tx.Model(&User{}).Where("id = ?", userID).Count(&userCount).Error; err != nil { if err := tx.Model(&User{}).Where("id = ?", userID).Count(&userCount).Error; err != nil {
return fmt.Errorf("fehler beim Prüfen des Benutzers: %w", err) return fmt.Errorf("error checking the user: %w", err)
} }
if userCount == 0 { if userCount == 0 {
return errors.New("der angegebene Benutzer existiert nicht") return errors.New("the specified user does not exist")
} }
// Projekt prüfen // Check project
var projectCount int64 var projectCount int64
if err := tx.Model(&Project{}).Where("id = ?", projectID).Count(&projectCount).Error; err != nil { if err := tx.Model(&Project{}).Where("id = ?", projectID).Count(&projectCount).Error; err != nil {
return fmt.Errorf("fehler beim Prüfen des Projekts: %w", err) return fmt.Errorf("error checking the project: %w", err)
} }
if projectCount == 0 { if projectCount == 0 {
return errors.New("das angegebene Projekt existiert nicht") return errors.New("the specified project does not exist")
} }
// Aktivität prüfen // Check activity
var activityCount int64 var activityCount int64
if err := tx.Model(&Activity{}).Where("id = ?", activityID).Count(&activityCount).Error; err != nil { if err := tx.Model(&Activity{}).Where("id = ?", activityID).Count(&activityCount).Error; err != nil {
return fmt.Errorf("fehler beim Prüfen der Aktivität: %w", err) return fmt.Errorf("error checking the activity: %w", err)
} }
if activityCount == 0 { if activityCount == 0 {
return errors.New("die angegebene Aktivität existiert nicht") return errors.New("the specified activity does not exist")
} }
return nil return nil
} }
// UpdateTimeEntry aktualisiert einen bestehenden Zeiteintrag mit Validierung // UpdateTimeEntry updates an existing time entry with validation
func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, error) { func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, error) {
// Validierung // Validation
if err := update.Validate(); err != nil { if err := update.Validate(); err != nil {
return nil, fmt.Errorf("validierungsfehler: %w", err) return nil, fmt.Errorf("validation error: %w", err)
} }
// Zeiteintrag suchen // Find time entry
timeEntry, err := GetTimeEntryByID(ctx, update.ID) timeEntry, err := GetTimeEntryByID(ctx, update.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if timeEntry == nil { if timeEntry == nil {
return nil, errors.New("zeiteintrag nicht gefunden") return nil, errors.New("time entry not found")
} }
// Starten einer Transaktion für das Update // Start a transaction for the update
err = GetEngine(ctx).Transaction(func(tx *gorm.DB) error { err = GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
// Referenzen prüfen, falls sie aktualisiert werden // Check references if they are updated
if update.UserID != nil || update.ProjectID != nil || update.ActivityID != nil { if update.UserID != nil || update.ProjectID != nil || update.ActivityID != nil {
// Aktuelle Werte verwenden, wenn nicht aktualisiert // Use current values if not updated
userID := timeEntry.UserID userID := timeEntry.UserID
if update.UserID != nil { if update.UserID != nil {
userID = *update.UserID userID = *update.UserID
@@ -319,7 +319,7 @@ func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, e
} }
} }
// Zeitkonsistenz prüfen // Check time consistency
start := timeEntry.Start start := timeEntry.Start
if update.Start != nil { if update.Start != nil {
start = *update.Start start = *update.Start
@@ -331,12 +331,12 @@ func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, e
} }
if end.Before(start) { if end.Before(start) {
return errors.New("endzeit kann nicht vor der startzeit liegen") return errors.New("end time cannot be before start time")
} }
// Generisches Update verwenden // Use generic update
if err := UpdateModel(ctx, timeEntry, update); err != nil { if err := UpdateModel(ctx, timeEntry, update); err != nil {
return fmt.Errorf("fehler beim Aktualisieren des Zeiteintrags: %w", err) return fmt.Errorf("error updating the time entry: %w", err)
} }
return nil return nil
@@ -346,15 +346,15 @@ func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, e
return nil, err return nil, err
} }
// Aktualisierte Daten aus der Datenbank laden // Load updated data from the database
return GetTimeEntryByID(ctx, update.ID) return GetTimeEntryByID(ctx, update.ID)
} }
// DeleteTimeEntry löscht einen Zeiteintrag anhand seiner ID // DeleteTimeEntry deletes a time entry by its ID
func DeleteTimeEntry(ctx context.Context, id ulid.ULID) error { func DeleteTimeEntry(ctx context.Context, id types.ULID) error {
result := GetEngine(ctx).Delete(&TimeEntry{}, id) result := GetEngine(ctx).Delete(&TimeEntry{}, id)
if result.Error != nil { if result.Error != nil {
return fmt.Errorf("fehler beim Löschen des Zeiteintrags: %w", result.Error) return fmt.Errorf("error deleting the time entry: %w", result.Error)
} }
return nil return nil
} }
+145 -126
View File
@@ -11,14 +11,14 @@ import (
"slices" "slices"
"github.com/oklog/ulid/v2" "github.com/timetracker/backend/internal/types"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"gorm.io/gorm" "gorm.io/gorm"
) )
// Argon2 Parameter // Argon2 Parameters
const ( const (
// Empfohlene Werte für Argon2id // Recommended values for Argon2id
ArgonTime = 1 ArgonTime = 1
ArgonMemory = 64 * 1024 // 64MB ArgonMemory = 64 * 1024 // 64MB
ArgonThreads = 4 ArgonThreads = 4
@@ -26,58 +26,58 @@ const (
SaltLength = 16 SaltLength = 16
) )
// Rollen-Konstanten // Role Constants
const ( const (
RoleAdmin = "admin" RoleAdmin = "admin"
RoleUser = "user" RoleUser = "user"
RoleViewer = "viewer" RoleViewer = "viewer"
) )
// User repräsentiert einen Benutzer im System // User represents a user in the system
type User struct { type User struct {
EntityBase EntityBase
Email string `gorm:"column:email;unique;not null"` Email string `gorm:"column:email;unique;not null"`
Salt string `gorm:"column:salt;not null;type:varchar(64)"` // Basis64-codierter Salt Salt string `gorm:"column:salt;not null;type:varchar(64)"` // Base64-encoded Salt
Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Basis64-codierter Hash Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Base64-encoded Hash
Role string `gorm:"column:role;not null;default:'user'"` Role string `gorm:"column:role;not null;default:'user'"`
CompanyID ulid.ULID `gorm:"column:company_id;type:uuid;not null;index"` CompanyID *types.ULID `gorm:"column:company_id;type:bytea;index"`
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"` HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
// Beziehung für Eager Loading // Relationship for Eager Loading
Company *Company `gorm:"foreignKey:CompanyID"` Company *Company `gorm:"foreignKey:CompanyID"`
} }
// TableName gibt den Tabellennamen für GORM an // TableName provides the table name for GORM
func (User) TableName() string { func (User) TableName() string {
return "users" return "users"
} }
// UserCreate enthält die Felder zum Erstellen eines neuen Benutzers // UserCreate contains the fields for creating a new user
type UserCreate struct { type UserCreate struct {
Email string Email string
Password string Password string
Role string Role string
CompanyID ulid.ULID CompanyID *types.ULID
HourlyRate float64 HourlyRate float64
} }
// UserUpdate enthält die aktualisierbaren Felder eines Benutzers // UserUpdate contains the updatable fields of a user
type UserUpdate struct { type UserUpdate struct {
ID ulid.ULID `gorm:"-"` // Ausschließen von Updates ID types.ULID `gorm:"-"` // Exclude from updates
Email *string `gorm:"column:email"` Email *string `gorm:"column:email"`
Password *string `gorm:"-"` // Nicht direkt in DB speichern Password *string `gorm:"-"` // Not stored directly in DB
Role *string `gorm:"column:role"` Role *string `gorm:"column:role"`
CompanyID *ulid.ULID `gorm:"column:company_id"` CompanyID types.Nullable[types.ULID] `gorm:"column:company_id"`
HourlyRate *float64 `gorm:"column:hourly_rate"` HourlyRate *float64 `gorm:"column:hourly_rate"`
} }
// PasswordData enthält die Daten für Passwort-Hash und Salt // PasswordData contains the data for password hash and salt
type PasswordData struct { type PasswordData struct {
Salt string Salt string
Hash string Hash string
} }
// GenerateSalt erzeugt einen kryptografisch sicheren Salt // GenerateSalt generates a cryptographically secure salt
func GenerateSalt() (string, error) { func GenerateSalt() (string, error) {
salt := make([]byte, SaltLength) salt := make([]byte, SaltLength)
_, err := rand.Read(salt) _, err := rand.Read(salt)
@@ -87,20 +87,20 @@ func GenerateSalt() (string, error) {
return base64.StdEncoding.EncodeToString(salt), nil return base64.StdEncoding.EncodeToString(salt), nil
} }
// HashPassword erstellt einen sicheren Passwort-Hash mit Argon2id und einem zufälligen Salt // HashPassword creates a secure password hash with Argon2id and a random salt
func HashPassword(password string) (PasswordData, error) { func HashPassword(password string) (PasswordData, error) {
// Erzeugen eines kryptografisch sicheren Salts // Generate a cryptographically secure salt
saltStr, err := GenerateSalt() saltStr, err := GenerateSalt()
if err != nil { if err != nil {
return PasswordData{}, fmt.Errorf("fehler beim Generieren des Salt: %w", err) return PasswordData{}, fmt.Errorf("error generating salt: %w", err)
} }
salt, err := base64.StdEncoding.DecodeString(saltStr) salt, err := base64.StdEncoding.DecodeString(saltStr)
if err != nil { if err != nil {
return PasswordData{}, fmt.Errorf("fehler beim Dekodieren des Salt: %w", err) return PasswordData{}, fmt.Errorf("error decoding salt: %w", err)
} }
// Hash mit Argon2id erstellen (moderne, sichere Hash-Funktion) // Create hash with Argon2id (modern, secure hash function)
hash := argon2.IDKey([]byte(password), salt, ArgonTime, ArgonMemory, ArgonThreads, ArgonKeyLen) hash := argon2.IDKey([]byte(password), salt, ArgonTime, ArgonMemory, ArgonThreads, ArgonKeyLen)
hashStr := base64.StdEncoding.EncodeToString(hash) hashStr := base64.StdEncoding.EncodeToString(hash)
@@ -110,26 +110,26 @@ func HashPassword(password string) (PasswordData, error) {
}, nil }, nil
} }
// VerifyPassword prüft, ob ein Passwort mit dem Hash übereinstimmt // VerifyPassword checks if a password matches the hash
func VerifyPassword(password, saltStr, hashStr string) (bool, error) { func VerifyPassword(password, saltStr, hashStr string) (bool, error) {
salt, err := base64.StdEncoding.DecodeString(saltStr) salt, err := base64.StdEncoding.DecodeString(saltStr)
if err != nil { if err != nil {
return false, fmt.Errorf("fehler beim Dekodieren des Salt: %w", err) return false, fmt.Errorf("error decoding salt: %w", err)
} }
hash, err := base64.StdEncoding.DecodeString(hashStr) hash, err := base64.StdEncoding.DecodeString(hashStr)
if err != nil { if err != nil {
return false, fmt.Errorf("fehler beim Dekodieren des Hash: %w", err) return false, fmt.Errorf("error decoding hash: %w", err)
} }
// Hash mit gleichem Salt berechnen // Calculate hash with the same salt
computedHash := argon2.IDKey([]byte(password), salt, ArgonTime, ArgonMemory, ArgonThreads, ArgonKeyLen) computedHash := argon2.IDKey([]byte(password), salt, ArgonTime, ArgonMemory, ArgonThreads, ArgonKeyLen)
// Konstante Zeit-Vergleich, um Timing-Angriffe zu vermeiden // Constant time comparison to prevent timing attacks
return hmacEqual(hash, computedHash), nil return hmacEqual(hash, computedHash), nil
} }
// hmacEqual führt einen konstante-Zeit Vergleich durch (verhindert Timing-Attacken) // hmacEqual performs a constant-time comparison (prevents timing attacks)
func hmacEqual(a, b []byte) bool { func hmacEqual(a, b []byte) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false return false
@@ -143,28 +143,28 @@ func hmacEqual(a, b []byte) bool {
return result == 0 return result == 0
} }
// Validate prüft, ob die Create-Struktur gültige Daten enthält // Validate checks if the Create structure contains valid data
func (uc *UserCreate) Validate() error { func (uc *UserCreate) Validate() error {
if uc.Email == "" { if uc.Email == "" {
return errors.New("email darf nicht leer sein") return errors.New("email cannot be empty")
} }
// Email-Format prüfen // Check email format
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
if !emailRegex.MatchString(uc.Email) { if !emailRegex.MatchString(uc.Email) {
return errors.New("ungültiges email-format") return errors.New("invalid email format")
} }
if uc.Password == "" { if uc.Password == "" {
return errors.New("passwort darf nicht leer sein") return errors.New("password cannot be empty")
} }
// Passwort-Komplexität prüfen // Check password complexity
if len(uc.Password) < 10 { if len(uc.Password) < 10 {
return errors.New("passwort muss mindestens 10 Zeichen lang sein") return errors.New("password must be at least 10 characters long")
} }
// Komplexere Passwortvalidierung // More complex password validation
var ( var (
hasUpper = false hasUpper = false
hasLower = false hasLower = false
@@ -187,57 +187,57 @@ func (uc *UserCreate) Validate() error {
} }
if !hasUpper || !hasLower || !hasNumber || !hasSpecial { if !hasUpper || !hasLower || !hasNumber || !hasSpecial {
return errors.New("passwort muss Großbuchstaben, Kleinbuchstaben, Zahlen und Sonderzeichen enthalten") return errors.New("password must contain uppercase letters, lowercase letters, numbers, and special characters")
} }
// Rolle prüfen // Check role
if uc.Role == "" { if uc.Role == "" {
uc.Role = RoleUser // Standardrolle setzen uc.Role = RoleUser // Set default role
} else { } else {
validRoles := []string{RoleAdmin, RoleUser, RoleViewer} validRoles := []string{RoleAdmin, RoleUser, RoleViewer}
isValid := slices.Contains(validRoles, uc.Role) isValid := slices.Contains(validRoles, uc.Role)
if !isValid { if !isValid {
return fmt.Errorf("ungültige rolle: %s, erlaubt sind: %s", return fmt.Errorf("invalid role: %s, allowed are: %s",
uc.Role, strings.Join(validRoles, ", ")) uc.Role, strings.Join(validRoles, ", "))
} }
} }
if uc.CompanyID.Compare(ulid.ULID{}) == 0 { if uc.CompanyID.Compare(types.ULID{}) == 0 {
return errors.New("companyID darf nicht leer sein") return errors.New("companyID cannot be empty")
} }
if uc.HourlyRate < 0 { if uc.HourlyRate < 0 {
return errors.New("stundensatz darf nicht negativ sein") return errors.New("hourly rate cannot be negative")
} }
return nil return nil
} }
// Validate prüft, ob die Update-Struktur gültige Daten enthält // Validate checks if the Update structure contains valid data
func (uu *UserUpdate) Validate() error { func (uu *UserUpdate) Validate() error {
if uu.Email != nil && *uu.Email == "" { if uu.Email != nil && *uu.Email == "" {
return errors.New("email darf nicht leer sein") return errors.New("email cannot be empty")
} }
// Email-Format prüfen // Check email format
if uu.Email != nil { if uu.Email != nil {
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
if !emailRegex.MatchString(*uu.Email) { if !emailRegex.MatchString(*uu.Email) {
return errors.New("ungültiges email-format") return errors.New("invalid email format")
} }
} }
if uu.Password != nil { if uu.Password != nil {
if *uu.Password == "" { if *uu.Password == "" {
return errors.New("passwort darf nicht leer sein") return errors.New("password cannot be empty")
} }
// Passwort-Komplexität prüfen // Check password complexity
if len(*uu.Password) < 10 { if len(*uu.Password) < 10 {
return errors.New("passwort muss mindestens 10 Zeichen lang sein") return errors.New("password must be at least 10 characters long")
} }
// Komplexere Passwortvalidierung // More complex password validation
var ( var (
hasUpper = false hasUpper = false
hasLower = false hasLower = false
@@ -260,11 +260,11 @@ func (uu *UserUpdate) Validate() error {
} }
if !hasUpper || !hasLower || !hasNumber || !hasSpecial { if !hasUpper || !hasLower || !hasNumber || !hasSpecial {
return errors.New("passwort muss Großbuchstaben, Kleinbuchstaben, Zahlen und Sonderzeichen enthalten") return errors.New("password must contain uppercase letters, lowercase letters, numbers, and special characters")
} }
} }
// Rolle prüfen // Check role
if uu.Role != nil { if uu.Role != nil {
validRoles := []string{RoleAdmin, RoleUser, RoleViewer} validRoles := []string{RoleAdmin, RoleUser, RoleViewer}
isValid := false isValid := false
@@ -275,20 +275,20 @@ func (uu *UserUpdate) Validate() error {
} }
} }
if !isValid { if !isValid {
return fmt.Errorf("ungültige rolle: %s, erlaubt sind: %s", return fmt.Errorf("invalid role: %s, allowed are: %s",
*uu.Role, strings.Join(validRoles, ", ")) *uu.Role, strings.Join(validRoles, ", "))
} }
} }
if uu.HourlyRate != nil && *uu.HourlyRate < 0 { if uu.HourlyRate != nil && *uu.HourlyRate < 0 {
return errors.New("stundensatz darf nicht negativ sein") return errors.New("hourly rate cannot be negative")
} }
return nil return nil
} }
// GetUserByID sucht einen Benutzer anhand seiner ID // GetUserByID finds a user by their ID
func GetUserByID(ctx context.Context, id ulid.ULID) (*User, error) { func GetUserByID(ctx context.Context, id types.ULID) (*User, error) {
var user User var user User
result := GetEngine(ctx).Where("id = ?", id).First(&user) result := GetEngine(ctx).Where("id = ?", id).First(&user)
if result.Error != nil { if result.Error != nil {
@@ -300,7 +300,7 @@ func GetUserByID(ctx context.Context, id ulid.ULID) (*User, error) {
return &user, nil return &user, nil
} }
// GetUserByEmail sucht einen Benutzer anhand seiner Email // GetUserByEmail finds a user by their email
func GetUserByEmail(ctx context.Context, email string) (*User, error) { func GetUserByEmail(ctx context.Context, email string) (*User, error) {
var user User var user User
result := GetEngine(ctx).Where("email = ?", email).First(&user) result := GetEngine(ctx).Where("email = ?", email).First(&user)
@@ -313,8 +313,8 @@ func GetUserByEmail(ctx context.Context, email string) (*User, error) {
return &user, nil return &user, nil
} }
// GetUserWithCompany lädt einen Benutzer mit seiner Firma // GetUserWithCompany loads a user with their company
func GetUserWithCompany(ctx context.Context, id ulid.ULID) (*User, error) { func GetUserWithCompany(ctx context.Context, id types.ULID) (*User, error) {
var user User var user User
result := GetEngine(ctx).Preload("Company").Where("id = ?", id).First(&user) result := GetEngine(ctx).Preload("Company").Where("id = ?", id).First(&user)
if result.Error != nil { if result.Error != nil {
@@ -326,7 +326,7 @@ func GetUserWithCompany(ctx context.Context, id ulid.ULID) (*User, error) {
return &user, nil return &user, nil
} }
// GetAllUsers gibt alle Benutzer zurück // GetAllUsers returns all users
func GetAllUsers(ctx context.Context) ([]User, error) { func GetAllUsers(ctx context.Context) ([]User, error) {
var users []User var users []User
result := GetEngine(ctx).Find(&users) result := GetEngine(ctx).Find(&users)
@@ -336,52 +336,64 @@ func GetAllUsers(ctx context.Context) ([]User, error) {
return users, nil return users, nil
} }
// GetUsersByCompanyID gibt alle Benutzer einer Firma zurück // getCompanyCondition builds the company condition for queries
func GetUsersByCompanyID(ctx context.Context, companyID ulid.ULID) ([]User, error) { func getCompanyCondition(companyID *types.ULID) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if companyID == nil {
return db.Where("company_id IS NULL")
}
return db.Where("company_id = ?", *companyID)
}
}
// GetUsersByCompanyID returns all users of a company
func GetUsersByCompanyID(ctx context.Context, companyID types.ULID) ([]User, error) {
var users []User var users []User
result := GetEngine(ctx).Where("company_id = ?", companyID).Find(&users) // Apply the dynamic company condition
condition := getCompanyCondition(&companyID)
result := GetEngine(ctx).Scopes(condition).Find(&users)
if result.Error != nil { if result.Error != nil {
return nil, result.Error return nil, result.Error
} }
return users, nil return users, nil
} }
// CreateUser erstellt einen neuen Benutzer mit Validierung und sicherem Passwort-Hashing // CreateUser creates a new user with validation and secure password hashing
func CreateUser(ctx context.Context, create UserCreate) (*User, error) { func CreateUser(ctx context.Context, create UserCreate) (*User, error) {
// Validierung // Validation
if err := create.Validate(); err != nil { if err := create.Validate(); err != nil {
return nil, fmt.Errorf("validierungsfehler: %w", err) return nil, fmt.Errorf("validation error: %w", err)
} }
// Starten einer Transaktion // Start a transaction
var user *User var user *User
err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error { err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
// Prüfen, ob Email bereits existiert // Check if email already exists
var count int64 var count int64
if err := tx.Model(&User{}).Where("email = ?", create.Email).Count(&count).Error; err != nil { if err := tx.Model(&User{}).Where("email = ?", create.Email).Count(&count).Error; err != nil {
return fmt.Errorf("fehler beim Prüfen der Email: %w", err) return fmt.Errorf("error checking email: %w", err)
} }
if count > 0 { if count > 0 {
return errors.New("email wird bereits verwendet") return errors.New("email is already in use")
} }
// Prüfen, ob Company existiert // Check if company exists
var companyCount int64 var companyCount int64
if err := tx.Model(&Company{}).Where("id = ?", create.CompanyID).Count(&companyCount).Error; err != nil { if err := tx.Model(&Company{}).Where("id = ?", create.CompanyID).Count(&companyCount).Error; err != nil {
return fmt.Errorf("fehler beim Prüfen der Firma: %w", err) return fmt.Errorf("error checking company: %w", err)
} }
if companyCount == 0 { if companyCount == 0 {
return errors.New("die angegebene Firma existiert nicht") return errors.New("the specified company does not exist")
} }
// Passwort hashen mit einzigartigem Salt // Hash password with unique salt
pwData, err := HashPassword(create.Password) pwData, err := HashPassword(create.Password)
if err != nil { if err != nil {
return fmt.Errorf("fehler beim Hashen des Passworts: %w", err) return fmt.Errorf("error hashing password: %w", err)
} }
// Benutzer erstellen mit Salt und Hash getrennt gespeichert // Create user with salt and hash stored separately
newUser := User{ newUser := User{
Email: create.Email, Email: create.Email,
Salt: pwData.Salt, Salt: pwData.Salt,
@@ -392,7 +404,7 @@ func CreateUser(ctx context.Context, create UserCreate) (*User, error) {
} }
if err := tx.Create(&newUser).Error; err != nil { if err := tx.Create(&newUser).Error; err != nil {
return fmt.Errorf("fehler beim Erstellen des Benutzers: %w", err) return fmt.Errorf("error creating user: %w", err)
} }
user = &newUser user = &newUser
@@ -406,83 +418,90 @@ func CreateUser(ctx context.Context, create UserCreate) (*User, error) {
return user, nil return user, nil
} }
// UpdateUser aktualisiert einen bestehenden Benutzer // UpdateUser updates an existing user
func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) { func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) {
// Validierung // Validation
if err := update.Validate(); err != nil { if err := update.Validate(); err != nil {
return nil, fmt.Errorf("validierungsfehler: %w", err) return nil, fmt.Errorf("validation error: %w", err)
} }
// Benutzer suchen // Find user
user, err := GetUserByID(ctx, update.ID) user, err := GetUserByID(ctx, update.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if user == nil { if user == nil {
return nil, errors.New("benutzer nicht gefunden") return nil, errors.New("user not found")
} }
// Starten einer Transaktion für das Update // Start a transaction for the update
err = GetEngine(ctx).Transaction(func(tx *gorm.DB) error { err = GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
// Wenn Email aktualisiert wird, prüfen ob sie bereits verwendet wird // If email is updated, check if it's already in use
if update.Email != nil && *update.Email != user.Email { if update.Email != nil && *update.Email != user.Email {
var count int64 var count int64
if err := tx.Model(&User{}).Where("email = ? AND id != ?", *update.Email, update.ID).Count(&count).Error; err != nil { if err := tx.Model(&User{}).Where("email = ? AND id != ?", *update.Email, update.ID).Count(&count).Error; err != nil {
return fmt.Errorf("fehler beim Prüfen der Email: %w", err) return fmt.Errorf("error checking email: %w", err)
} }
if count > 0 { if count > 0 {
return errors.New("email wird bereits verwendet") return errors.New("email is already in use")
} }
} }
// Wenn CompanyID aktualisiert wird, prüfen ob sie existiert // If CompanyID is updated, check if it exists
if update.CompanyID != nil && update.CompanyID.Compare(user.CompanyID) != 0 { if update.CompanyID.Valid && update.CompanyID.Value != nil {
var companyCount int64 if user.CompanyID == nil || *update.CompanyID.Value != *user.CompanyID {
if err := tx.Model(&Company{}).Where("id = ?", *update.CompanyID).Count(&companyCount).Error; err != nil { var companyCount int64
return fmt.Errorf("fehler beim Prüfen der Firma: %w", err) if err := tx.Model(&Company{}).Where("id = ?", *update.CompanyID.Value).Count(&companyCount).Error; err != nil {
} return fmt.Errorf("error checking company: %w", err)
if companyCount == 0 { }
return errors.New("die angegebene Firma existiert nicht") if companyCount == 0 {
return errors.New("the specified company does not exist")
}
} }
} }
// Wenn Passwort aktualisiert wird, neu hashen mit neuem Salt // If password is updated, rehash with new salt
if update.Password != nil { if update.Password != nil {
pwData, err := HashPassword(*update.Password) pwData, err := HashPassword(*update.Password)
if err != nil { if err != nil {
return fmt.Errorf("fehler beim Hashen des Passworts: %w", err) return fmt.Errorf("error hashing password: %w", err)
} }
// Salt und Hash direkt im Modell aktualisieren // Update salt and hash directly in the model
if err := tx.Model(user).Updates(map[string]interface{}{ if err := tx.Model(user).Updates(map[string]any{
"salt": pwData.Salt, "salt": pwData.Salt,
"hash": pwData.Hash, "hash": pwData.Hash,
}).Error; err != nil { }).Error; err != nil {
return fmt.Errorf("fehler beim Aktualisieren des Passworts: %w", err) return fmt.Errorf("error updating password: %w", err)
} }
} }
// Map für generisches Update erstellen // Create map for generic update
updates := make(map[string]interface{}) updates := make(map[string]any)
// Nur nicht-Passwort-Felder dem Update hinzufügen // Add only non-password fields to the update
if update.Email != nil { if update.Email != nil {
updates["email"] = *update.Email updates["email"] = *update.Email
} }
if update.Role != nil { if update.Role != nil {
updates["role"] = *update.Role updates["role"] = *update.Role
} }
if update.CompanyID != nil { if update.CompanyID.Valid {
updates["company_id"] = *update.CompanyID if update.CompanyID.Value == nil {
updates["company_id"] = nil
} else {
updates["company_id"] = *update.CompanyID.Value
}
} }
if update.HourlyRate != nil { if update.HourlyRate != nil {
updates["hourly_rate"] = *update.HourlyRate updates["hourly_rate"] = *update.HourlyRate
} }
// Generisches Update nur ausführen, wenn es Änderungen gibt // Only execute generic update if there are changes
if len(updates) > 0 { if len(updates) > 0 {
if err := tx.Model(user).Updates(updates).Error; err != nil { if err := tx.Model(user).Updates(updates).Error; err != nil {
return fmt.Errorf("fehler beim Aktualisieren des Benutzers: %w", err) return fmt.Errorf("error updating user: %w", err)
} }
} }
@@ -493,41 +512,41 @@ func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) {
return nil, err return nil, err
} }
// Aktualisierte Daten aus der Datenbank laden // Load updated data from the database
return GetUserByID(ctx, update.ID) return GetUserByID(ctx, update.ID)
} }
// DeleteUser löscht einen Benutzer anhand seiner ID // DeleteUser deletes a user by their ID
func DeleteUser(ctx context.Context, id ulid.ULID) error { func DeleteUser(ctx context.Context, id types.ULID) error {
// Hier könnte man prüfen, ob abhängige Entitäten existieren // Here one could check if dependent entities exist
// z.B. nicht löschen, wenn noch Zeiteinträge vorhanden sind // e.g., don't delete if time entries still exist
result := GetEngine(ctx).Delete(&User{}, id) result := GetEngine(ctx).Delete(&User{}, id)
if result.Error != nil { if result.Error != nil {
return fmt.Errorf("fehler beim Löschen des Benutzers: %w", result.Error) return fmt.Errorf("error deleting user: %w", result.Error)
} }
return nil return nil
} }
// AuthenticateUser authentifiziert einen Benutzer mit Email und Passwort // AuthenticateUser authenticates a user with email and password
func AuthenticateUser(ctx context.Context, email, password string) (*User, error) { func AuthenticateUser(ctx context.Context, email, password string) (*User, error) {
user, err := GetUserByEmail(ctx, email) user, err := GetUserByEmail(ctx, email)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if user == nil { if user == nil {
// Gleiche Fehlermeldung, um keine Informationen über existierende Accounts preiszugeben // Same error message to avoid revealing information about existing accounts
return nil, errors.New("ungültige Anmeldeinformationen") return nil, errors.New("invalid login credentials")
} }
// Passwort überprüfen mit dem gespeicherten Salt // Verify password with the stored salt
isValid, err := VerifyPassword(password, user.Salt, user.Hash) isValid, err := VerifyPassword(password, user.Salt, user.Hash)
if err != nil { if err != nil {
return nil, fmt.Errorf("fehler bei der Passwortüberprüfung: %w", err) return nil, fmt.Errorf("error verifying password: %w", err)
} }
if !isValid { if !isValid {
return nil, errors.New("ungültige Anmeldeinformationen") return nil, errors.New("invalid login credentials")
} }
return user, nil return user, nil
+52
View File
@@ -0,0 +1,52 @@
package types
import (
"encoding/json"
"fmt"
)
// Nullable[T] - Generischer Typ für optionale Werte (nullable fields)
type Nullable[T any] struct {
Value *T // Der tatsächliche Wert (kann nil sein)
Valid bool // Gibt an, ob der Wert gesetzt wurde
}
// NewNullable erstellt eine gültige Nullable-Instanz
func NewNullable[T any](value T) Nullable[T] {
return Nullable[T]{Value: &value, Valid: true}
}
// Null erstellt eine leere Nullable-Instanz (ungesetzt)
func Null[T any]() Nullable[T] {
return Nullable[T]{Valid: true}
}
func Undefined[T any]() Nullable[T] {
return Nullable[T]{Valid: false}
}
// MarshalJSON - Serialisiert `Nullable[T]` korrekt ins JSON-Format
func (n Nullable[T]) MarshalJSON() ([]byte, error) {
if !n.Valid {
return []byte("null"), nil // Wenn nicht valid, dann NULL
}
return json.Marshal(n.Value) // Serialisiert den tatsächlichen Wert
}
// UnmarshalJSON - Deserialisiert JSON in `Nullable[T]`
func (n *Nullable[T]) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
n.Valid = true // Wert wurde gesetzt, aber auf NULL
n.Value = nil // Explizit NULL setzen
return nil
}
var v T
if err := json.Unmarshal(data, &v); err != nil {
return fmt.Errorf("invalid JSON for Nullable: %w", err)
}
n.Value = &v
n.Valid = true
return nil
}
+77
View File
@@ -0,0 +1,77 @@
package types
import (
"context"
"database/sql/driver"
"fmt"
"github.com/oklog/ulid/v2"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ULID wraps ulid.ULID to make it work nicely with GORM
type ULID struct {
ulid.ULID
}
// NewULIDWrapper creates a new ULID with a new ULID
func NewULIDWrapper() ULID {
return ULID{ULID: ulid.Make()}
}
// FromULID creates a ULID from a ulid.ULID
func FromULID(id ulid.ULID) ULID {
return ULID{ULID: id}
}
// ULIDWrapperFromString creates a ULID from a string
func ULIDFromString(id string) (ULID, error) {
parsed, err := ulid.Parse(id)
if err != nil {
return ULID{}, fmt.Errorf("failed to parse ULID string: %w", err)
}
return ULID{ULID: parsed}, nil
}
// Scan implements the sql.Scanner interface for ULID
func (u *ULID) Scan(src any) error {
switch v := src.(type) {
case []byte:
// If it's exactly 16 bytes, it's the binary representation
if len(v) == 16 {
copy(u.ULID[:], v)
return nil
}
// Otherwise, try as string
return fmt.Errorf("cannot scan []byte of length %d into ULID", len(v))
case string:
parsed, err := ulid.Parse(v)
if err != nil {
return fmt.Errorf("failed to parse ULID: %w", err)
}
u.ULID = parsed
return nil
default:
return fmt.Errorf("cannot scan %T into ULID", src)
}
}
// Value implements the driver.Valuer interface for ULID
// Returns the binary representation of the ULID for maximum efficiency
func (u ULID) Value() (driver.Value, error) {
return u.ULID.Bytes(), nil
}
// GormValue implements the gorm.Valuer interface for ULID
func (u ULID) GormValue(ctx context.Context, db *gorm.DB) clause.Expr {
return clause.Expr{
SQL: "?",
Vars: []any{u.Bytes()},
}
}
// Compare implements comparison for ULID
func (u ULID) Compare(other ULID) int {
return u.ULID.Compare(other.ULID)
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"fmt"
"os"
"os/exec"
"strings"
)
const (
dtoFilePath = "../frontend/src/types/dto.ts"
importStatement = `import { Nullable } from "./nullable";`
)
func main() {
// Run Tygo first
fmt.Println("🔄 Running tygo...")
if err := runTygo(); err != nil {
fmt.Println("❌ Error running tygo:", err)
os.Exit(1)
}
// Read dto.ts file
content, err := os.ReadFile(dtoFilePath)
if err != nil {
fmt.Println("❌ Could not read dto.ts:", err)
os.Exit(1)
}
// Convert to string
dtoContent := string(content)
// Check if import already exists
if strings.Contains(dtoContent, importStatement) {
fmt.Println("️ Import already exists in dto.ts, skipping.")
return
}
// Add import statement at the beginning
newContent := importStatement + "\n" + dtoContent
if err := os.WriteFile(dtoFilePath, []byte(newContent), 0644); err != nil {
fmt.Println("❌ Error writing dto.ts:", err)
os.Exit(1)
}
fmt.Println("✅ Successfully added Nullable<T> import to dto.ts")
}
// Runs Tygo command
func runTygo() error {
cmd := "tygo"
output, err := exec.Command(cmd, "generate").CombinedOutput()
if err != nil {
fmt.Println("Tygo output:", string(output))
return err
}
if len(output) > 0 {
fmt.Println("Tygo output:", string(output))
return nil
}
return nil
}
+7
View File
@@ -0,0 +1,7 @@
packages:
- path: "github.com/timetracker/backend/internal/api/dto"
type_mappings:
"time.Time": "string"
"types.ULID": "string"
"types.Nullable": "Nullable"
output_path: ../frontend/src/types/dto.ts
-6
View File
@@ -1,6 +0,0 @@
packages:
- path: github.com/timetracker/backend/internal/interfaces/http/dto
type_mappings:
"time.Time": "string"
"ulid.ULID": "string"
output_path: ../frontend/src/types/dto.ts
+26
View File
@@ -0,0 +1,26 @@
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
pgadmin:
image: dpage/pgadmin4
container_name: pgadmin
environment:
PGADMIN_DEFAULT_EMAIL: admin@example.com
PGADMIN_DEFAULT_PASSWORD: admin
ports:
- "8081:80"
depends_on:
- db
volumes:
db_data:
+1 -1
View File
@@ -2,7 +2,7 @@
**Note:** This document describes a *conceptual* architecture and is not a final, binding requirement. **Note:** This document describes a *conceptual* architecture and is not a final, binding requirement.
The backend is written in Go and follows the principles of **Clean Architecture** and **Domain-Driven Design (DDD)**. The backend is written in Go using idiomatic Go patterns. While initially following Clean Architecture and DDD principles, we've adapted to a more pragmatic approach that better fits Go's conventions and reduces boilerplate code.
## Project Structure ## Project Structure
``` ```
-101
View File
@@ -1,101 +0,0 @@
// interfaces/http/handlers/time_entry_handler.go
package main
import (
"net/http"
"github.com/email/timetracker/internal/application/timetracking"
"github.com/email/timetracker/internal/interfaces/http/dto"
"github.com/email/timetracker/internal/interfaces/http/middleware"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// TimeEntryHandler behandelt HTTP-Anfragen für Zeitbuchungen
type TimeEntryHandler struct {
createTimeEntryUseCase *timetracking.CreateTimeEntryUseCase
updateTimeEntryUseCase *timetracking.UpdateTimeEntryUseCase
listTimeEntriesUseCase *timetracking.ListTimeEntriesUseCase
deleteTimeEntryUseCase *timetracking.DeleteTimeEntryUseCase
}
// NewTimeEntryHandler erstellt einen neuen TimeEntryHandler
func NewTimeEntryHandler(
createTimeEntryUseCase *timetracking.CreateTimeEntryUseCase,
updateTimeEntryUseCase *timetracking.UpdateTimeEntryUseCase,
listTimeEntriesUseCase *timetracking.ListTimeEntriesUseCase,
deleteTimeEntryUseCase *timetracking.DeleteTimeEntryUseCase,
) *TimeEntryHandler {
return &TimeEntryHandler{
createTimeEntryUseCase: createTimeEntryUseCase,
updateTimeEntryUseCase: updateTimeEntryUseCase,
listTimeEntriesUseCase: listTimeEntriesUseCase,
deleteTimeEntryUseCase: deleteTimeEntryUseCase,
}
}
// RegisterRoutes registriert die Routen am Router
func (h *TimeEntryHandler) RegisterRoutes(router *gin.RouterGroup) {
timeEntries := router.Group("/time-entries")
{
timeEntries.GET("", h.ListTimeEntries)
timeEntries.POST("", h.CreateTimeEntry)
timeEntries.GET("/:id", h.GetTimeEntry)
timeEntries.PUT("/:id", h.UpdateTimeEntry)
timeEntries.DELETE("/:id", h.DeleteTimeEntry)
}
}
// CreateTimeEntry behandelt die Erstellung einer neuen Zeitbuchung
func (h *TimeEntryHandler) CreateTimeEntry(c *gin.Context) {
var req dto.CreateTimeEntryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Tenant-ID aus dem Kontext extrahieren
companyID, exists := middleware.GetCompanyID(c)
if !exists {
c.JSON(http.StatusBadRequest, gin.H{"error": "Company ID not found"})
return
}
// Benutzer-ID aus dem Kontext oder Request
var userID uuid.UUID
if req.UserID != nil {
userID = *req.UserID
} else {
currentUserID, exists := middleware.GetUserID(c)
if !exists {
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found"})
return
}
userID = currentUserID
}
// Command erstellen
cmd := timetracking.CreateTimeEntryCommand{
UserID: userID,
ProjectID: req.ProjectID,
ActivityID: req.ActivityID,
TaskID: req.TaskID,
StartTime: req.StartTime,
EndTime: req.EndTime,
Description: req.Description,
BillablePercentage: req.BillablePercentage,
}
// UseCase ausführen
result := h.createTimeEntryUseCase.Execute(c.Request.Context(), companyID, cmd)
if result.IsFailure() {
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error().Error()})
return
}
// TimeEntry in Response-DTO umwandeln
timeEntry := result.Value()
response := dto.MapTimeEntryToDTO(*timeEntry)
c.JSON(http.StatusCreated, response)
}
-52
View File
@@ -1,52 +0,0 @@
// domain/repositories/time_entry_repository.go
package repositories
import (
"context"
"time"
"github.com/email/timetracker/internal/domain/entities"
"github.com/email/timetracker/pkg/functional"
"github.com/google/uuid"
)
// TimeEntryFilter enthält Filter für die Suche nach Zeitbuchungen
type TimeEntryFilter struct {
UserID *uuid.UUID
ProjectID *uuid.UUID
CustomerID *uuid.UUID
StartDate *time.Time
EndDate *time.Time
ActivityID *uuid.UUID
TaskID *uuid.UUID
}
// TimeEntryRepository Interface für den Zugriff auf Zeitbuchungen
type TimeEntryRepository interface {
// FindByID sucht eine Zeitbuchung anhand ihrer ID
FindByID(ctx context.Context, companyID, id uuid.UUID) functional.Result[*entities.TimeEntry]
// FindAll sucht alle Zeitbuchungen mit optionalen Filtern
FindAll(ctx context.Context, companyID uuid.UUID, filter TimeEntryFilter) functional.Result[[]entities.TimeEntry]
// Create erstellt eine neue Zeitbuchung
Create(ctx context.Context, entry *entities.TimeEntry) functional.Result[*entities.TimeEntry]
// Update aktualisiert eine bestehende Zeitbuchung
Update(ctx context.Context, entry *entities.TimeEntry) functional.Result[*entities.TimeEntry]
// Delete löscht eine Zeitbuchung
Delete(ctx context.Context, companyID, id uuid.UUID) functional.Result[bool]
// GetSummary berechnet eine Zusammenfassung der Zeitbuchungen
GetSummary(ctx context.Context, companyID uuid.UUID, filter TimeEntryFilter) functional.Result[TimeEntrySummary]
}
// TimeEntrySummary enthält zusammengefasste Informationen über Zeitbuchungen
type TimeEntrySummary struct {
TotalDuration int
TotalBillableDuration int
TotalAmount float64
TotalBillableAmount float64
EntriesCount int
}
-99
View File
@@ -1,99 +0,0 @@
// application/timetracking/create_time_entry.go
package main
import (
"context"
"time"
"github.com/email/timetracker/internal/domain/entities"
"github.com/email/timetracker/internal/domain/repositories"
"github.com/email/timetracker/pkg/functional"
"github.com/email/timetracker/pkg/validator"
"github.com/google/uuid"
)
// CreateTimeEntryCommand enthält die Daten zum Erstellen einer Zeitbuchung
type CreateTimeEntryCommand struct {
UserID uuid.UUID
ProjectID uuid.UUID
ActivityID uuid.UUID
TaskID *uuid.UUID
StartTime time.Time
EndTime time.Time
Description string
BillablePercentage int
}
// CreateTimeEntryUseCase repräsentiert den Anwendungsfall zum Erstellen einer Zeitbuchung
type CreateTimeEntryUseCase struct {
timeEntryRepo repositories.TimeEntryRepository
projectRepo repositories.ProjectRepository
activityRepo repositories.ActivityRepository
userRepo repositories.UserRepository
}
// NewCreateTimeEntryUseCase erstellt eine neue Instanz des UseCase
func NewCreateTimeEntryUseCase(
timeEntryRepo repositories.TimeEntryRepository,
projectRepo repositories.ProjectRepository,
activityRepo repositories.ActivityRepository,
userRepo repositories.UserRepository,
) *CreateTimeEntryUseCase {
return &CreateTimeEntryUseCase{
timeEntryRepo: timeEntryRepo,
projectRepo: projectRepo,
activityRepo: activityRepo,
userRepo: userRepo,
}
}
// Execute führt den Anwendungsfall aus
func (uc *CreateTimeEntryUseCase) Execute(ctx context.Context, companyID uuid.UUID, cmd CreateTimeEntryCommand) functional.Result[*entities.TimeEntry] {
// Validierung
if err := validator.ValidateStruct(cmd); err != nil {
return functional.Failure[*entities.TimeEntry](err)
}
// Überprüfen, ob Projekt existiert und zum gleichen Tenant gehört
projectResult := uc.projectRepo.FindByID(ctx, companyID, cmd.ProjectID)
if projectResult.IsFailure() {
return functional.Failure[*entities.TimeEntry](projectResult.Error())
}
// Überprüfen, ob Activity existiert und zum gleichen Tenant gehört
activityResult := uc.activityRepo.FindByID(ctx, companyID, cmd.ActivityID)
if activityResult.IsFailure() {
return functional.Failure[*entities.TimeEntry](activityResult.Error())
}
activity := activityResult.Value()
// Benutzer abrufen für den Stundensatz
userResult := uc.userRepo.FindByID(ctx, companyID, cmd.UserID)
if userResult.IsFailure() {
return functional.Failure[*entities.TimeEntry](userResult.Error())
}
user := userResult.Value()
// Berechnung der Dauer in Minuten
durationMinutes := int(cmd.EndTime.Sub(cmd.StartTime).Minutes())
// TimeEntry erstellen
timeEntry := &entities.TimeEntry{
TenantEntity: entities.TenantEntity{
CompanyID: companyID,
},
UserID: cmd.UserID,
ProjectID: cmd.ProjectID,
ActivityID: cmd.ActivityID,
TaskID: cmd.TaskID,
StartTime: cmd.StartTime,
EndTime: cmd.EndTime,
DurationMinutes: durationMinutes,
Description: cmd.Description,
BillablePercentage: cmd.BillablePercentage,
BillingRate: activity.BillingRate,
}
// Speichern der TimeEntry
return uc.timeEntryRepo.Create(ctx, timeEntry)
}
-104
View File
@@ -1,104 +0,0 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// BaseEntity enthält gemeinsame Felder für alle Entitäten
type BaseEntity struct {
ID uuid.UUID `gorm:"type:uuid;primary_key"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index"`
}
// BeforeCreate setzt eine neue UUID vor dem Erstellen
func (base *BaseEntity) BeforeCreate(tx *gorm.DB) error {
if base.ID == uuid.Nil {
base.ID = uuid.New()
}
return nil
}
// TenantEntity erweitert BaseEntity um Company-ID für Multi-Tenancy
type TenantEntity struct {
BaseEntity
CompanyID uuid.UUID `gorm:"type:uuid;index:idx_tenant"`
}
// Role repräsentiert eine Benutzerrolle
type Role struct {
BaseEntity
Name string `gorm:"unique;not null"`
Description string
Permissions []Permission `gorm:"many2many:role_permissions;"`
}
// Permission repräsentiert eine einzelne Berechtigung
type Permission struct {
BaseEntity
Resource string `gorm:"not null"`
Action string `gorm:"not null"`
UniqueID string `gorm:"uniqueIndex"`
}
// User repräsentiert einen Benutzer im System
type User struct {
TenantEntity
Email string `gorm:"uniqueIndex;not null"`
FirstName string
LastName string
PasswordHash string `gorm:"not null"`
RoleID uuid.UUID `gorm:"type:uuid"`
Role Role `gorm:"foreignKey:RoleID"`
HourlyRate float64
IsActive bool `gorm:"default:true"`
}
// FullName gibt den vollständigen Namen des Benutzers zurück
func (u User) FullName() string {
return u.FirstName + " " + u.LastName
}
// TimeEntry repräsentiert eine Zeitbuchung
type TimeEntry struct {
TenantEntity
UserID uuid.UUID `gorm:"type:uuid;index:idx_user"`
User User `gorm:"foreignKey:UserID"`
ProjectID uuid.UUID `gorm:"type:uuid;index:idx_project"`
Project Project `gorm:"foreignKey:ProjectID"`
ActivityID uuid.UUID `gorm:"type:uuid"`
Activity Activity `gorm:"foreignKey:ActivityID"`
TaskID *uuid.UUID `gorm:"type:uuid;null"`
Task *Task `gorm:"foreignKey:TaskID"`
StartTime time.Time `gorm:"not null;index:idx_time_range"`
EndTime time.Time `gorm:"not null;index:idx_time_range"`
DurationMinutes int `gorm:"not null"`
Description string
BillablePercentage int `gorm:"default:100"`
BillingRate float64
}
type Project struct {
TenantEntity
Name string
}
type Activity struct {
TenantEntity
Name string
}
type Task struct {
TenantEntity
Name string
}
// CalculateBillableAmount berechnet den abrechenbaren Betrag
func (t TimeEntry) CalculateBillableAmount() float64 {
hours := float64(t.DurationMinutes) / 60.0
return hours * t.BillingRate * (float64(t.BillablePercentage) / 100.0)
}
-136
View File
@@ -1,136 +0,0 @@
// presentation/components/timeTracker/Timer/Timer.tsx
import React, { useState, useEffect } from 'react';
import { useTimeTracking } from '../../../hooks/useTimeTracking';
import { pipe, Option, fromNullable } from '../../../../utils/fp/option';
import { Button } from '../../common/Button';
import { formatDuration } from '../../../../utils/date/dateUtils';
interface TimerProps {
onComplete?: (duration: number) => void;
}
export const Timer: React.FC<TimerProps> = ({ onComplete }) => {
const [isRunning, setIsRunning] = useState(false);
const [startTime, setStartTime] = useState<Option<Date>>(Option.none());
const [elapsedTime, setElapsedTime] = useState(0);
const [selectedProject, setSelectedProject] = useState<Option<string>>(Option.none());
const [selectedActivity, setSelectedActivity] = useState<Option<string>>(Option.none());
const { lastTimeEntry, projects, activities } = useTimeTracking();
// Beim ersten Rendering die letzte Zeitbuchung laden
useEffect(() => {
pipe(
fromNullable(lastTimeEntry),
Option.map(entry => {
setSelectedProject(Option.some(entry.projectId));
setSelectedActivity(Option.some(entry.activityId));
})
);
}, [lastTimeEntry]);
// Timer-Logik
useEffect(() => {
let interval: NodeJS.Timeout | null = null;
if (isRunning) {
interval = setInterval(() => {
const now = new Date();
pipe(
startTime,
Option.map(start => {
const diff = now.getTime() - start.getTime();
setElapsedTime(Math.floor(diff / 1000));
})
);
}, 1000);
} else if (interval) {
clearInterval(interval);
}
return () => {
if (interval) clearInterval(interval);
};
}, [isRunning, startTime]);
// Timer starten
const handleStart = () => {
setStartTime(Option.some(new Date()));
setIsRunning(true);
};
// Timer stoppen
const handleStop = () => {
setIsRunning(false);
// Prüfen, ob Projekt und Aktivität ausgewählt wurden
const projectId = pipe(
selectedProject,
Option.getOrElse(() => '')
);
const activityId = pipe(
selectedActivity,
Option.getOrElse(() => '')
);
if (projectId && activityId && onComplete) {
onComplete(elapsedTime);
}
// Timer zurücksetzen
setElapsedTime(0);
setStartTime(Option.none());
};
return (
<div className="bg-white rounded-lg shadow-md p-4">
<div className="text-4xl text-center font-mono mb-4">
{formatDuration(elapsedTime)}
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Projekt
</label>
<select
className="w-full border border-gray-300 rounded-md px-3 py-2"
value={pipe(selectedProject, Option.getOrElse(() => ''))}
onChange={(e) => setSelectedProject(Option.some(e.target.value))}
disabled={isRunning}
>
<option value="">Projekt auswählen</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tätigkeit
</label>
<select
className="w-full border border-gray-300 rounded-md px-3 py-2"
value={pipe(selectedActivity, Option.getOrElse(() => ''))}
onChange={(e) => setSelectedActivity(Option.some(e.target.value))}
disabled={isRunning}
>
<option value="">Tätigkeit auswählen</option>
{activities.map((activity) => (
<option key={activity.id} value={activity.id}>
{activity.name}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-center">
</div>
</div>
);
};
-194
View File
@@ -1,194 +0,0 @@
# Database Schema (PostgreSQL)
```sql
-- Multi-Tenant
CREATE TABLE companies (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
address TEXT,
contact_email VARCHAR(255),
contact_phone VARCHAR(50),
logo_url TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Go structs for creating and updating customers
-- type CustomerCreate struct {
-- Name string
-- CompanyID int
-- }
-- type CustomerUpdate struct {
-- ID ulid.ULID
-- Name *string
-- CompanyID *int
-- }
-- Go structs for creating and updating companies
-- type CompanyCreate struct {
-- Name string
-- }
-- type CompanyUpdate struct {
-- ID ulid.ULID
-- Name *string
-- }
-- Go structs for creating and updating activities
-- type ActivityCreate struct {
-- Name string
-- BillingRate float64
-- }
-- type ActivityUpdate struct {
-- ID ulid.ULID
-- Name *string
-- BillingRate *float64
-- }
-- Users and Roles
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
permissions JSONB
);
CREATE TABLE users (
id UUID PRIMARY KEY,
company_id UUID REFERENCES companies(id),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
role_id INTEGER REFERENCES roles(id),
hourly_rate DECIMAL(10, 2),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Customers
CREATE TABLE customers (
id UUID PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id),
name VARCHAR(255) NOT NULL,
contact_person VARCHAR(255),
email VARCHAR(255),
phone VARCHAR(50),
address TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Projects
CREATE TABLE projects (
id UUID PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id),
customer_id UUID REFERENCES customers(id),
name VARCHAR(255) NOT NULL,
description TEXT,
start_date DATE,
end_date DATE,
status VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Activities
CREATE TABLE activities (
id UUID PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id),
name VARCHAR(255) NOT NULL,
description TEXT,
billing_rate DECIMAL(10, 2),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Time bookings
CREATE TABLE time_entries (
id UUID PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id),
user_id UUID NOT NULL REFERENCES users(id),
project_id UUID NOT NULL REFERENCES projects(id),
activity_id UUID NOT NULL REFERENCES activities(id),
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
duration INTEGER NOT NULL, -- in minutes
description TEXT,
billable_percentage INTEGER NOT NULL DEFAULT 100,
billing_rate DECIMAL(10, 2),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Version 2: Sprint/Task Management
CREATE TABLE sprints (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES projects(id),
name VARCHAR(255) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE task_statuses (
id SERIAL PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id),
name VARCHAR(100) NOT NULL,
color VARCHAR(7),
position INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE tasks (
id UUID PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id),
project_id UUID NOT NULL REFERENCES projects(id),
sprint_id UUID REFERENCES sprints(id),
title VARCHAR(255) NOT NULL,
description TEXT,
assignee_id UUID REFERENCES users(id),
status_id INTEGER REFERENCES task_statuses(id),
priority VARCHAR(50),
estimate INTEGER, -- in minutes
due_date TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE kanban_boards (
id UUID PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id),
project_id UUID NOT NULL REFERENCES projects(id),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE kanban_columns (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES kanban_boards(id),
name VARCHAR(100) NOT NULL,
position INTEGER NOT NULL,
task_status_id INTEGER REFERENCES task_statuses(id),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Linking time entries and tasks
ALTER TABLE time_entries ADD COLUMN task_id UUID REFERENCES tasks(id);
-- Indexes for performance
CREATE INDEX idx_time_entries_user ON time_entries(user_id);
CREATE INDEX idx_time_entries_project ON time_entries(project_id);
CREATE INDEX idx_time_entries_date ON time_entries(start_time);
CREATE INDEX idx_projects_company ON projects(company_id);
CREATE INDEX idx_users_company ON users(company_id);
CREATE INDEX idx_tasks_project ON tasks(project_id);
CREATE INDEX idx_tasks_sprint ON tasks(sprint_id);
-17
View File
@@ -1,17 +0,0 @@
# Deployment and DevOps
## Containerization
- Docker containers for backend and frontend
- Docker Compose for development environment
- Kubernetes manifests for production environment
## CI/CD Pipeline
- Automated tests (Unit, Integration, E2E)
- Automated deployment
- Version management
## Monitoring and Logging
- Prometheus for metrics
- Grafana for visualization
- ELK Stack or similar for logging
- Alerting for critical events
+84
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.
+41 -21
View File
@@ -1,3 +1,4 @@
import { Nullable } from "./nullable";
// Code generated by tygo. DO NOT EDIT. // Code generated by tygo. DO NOT EDIT.
////////// //////////
@@ -24,6 +25,24 @@ export interface ActivityUpdateDto {
billingRate?: number /* float64 */; billingRate?: number /* float64 */;
} }
//////////
// source: auth_dto.go
/**
* LoginDto represents the login request
*/
export interface LoginDto {
email: string;
password: string;
}
/**
* TokenDto represents the response after successful authentication
*/
export interface TokenDto {
token: string;
user: UserDto;
}
////////// //////////
// source: company_dto.go // source: company_dto.go
@@ -54,11 +73,12 @@ export interface CustomerDto {
updatedAt: string; updatedAt: string;
lastEditorID: string; lastEditorID: string;
name: string; name: string;
companyId: number /* int */; companyId?: string;
owningUserID?: string;
} }
export interface CustomerCreateDto { export interface CustomerCreateDto {
name: string; name: string;
companyId: number /* int */; companyId?: string;
} }
export interface CustomerUpdateDto { export interface CustomerUpdateDto {
id: string; id: string;
@@ -66,7 +86,8 @@ export interface CustomerUpdateDto {
updatedAt?: string; updatedAt?: string;
lastEditorID?: string; lastEditorID?: string;
name?: string; name?: string;
companyId?: number /* int */; companyId?: Nullable<string>;
owningUserID?: Nullable<string>;
} }
////////// //////////
@@ -78,11 +99,11 @@ export interface ProjectDto {
updatedAt: string; updatedAt: string;
lastEditorID: string; lastEditorID: string;
name: string; name: string;
customerId: number /* int */; customerId: string;
} }
export interface ProjectCreateDto { export interface ProjectCreateDto {
name: string; name: string;
customerId: number /* int */; customerId: string;
} }
export interface ProjectUpdateDto { export interface ProjectUpdateDto {
id: string; id: string;
@@ -90,7 +111,7 @@ export interface ProjectUpdateDto {
updatedAt?: string; updatedAt?: string;
lastEditorID?: string; lastEditorID?: string;
name?: string; name?: string;
customerId?: number /* int */; customerId?: string;
} }
////////// //////////
@@ -101,18 +122,18 @@ export interface TimeEntryDto {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
lastEditorID: string; lastEditorID: string;
userId: number /* int */; userId: string;
projectId: number /* int */; projectId: string;
activityId: number /* int */; activityId: string;
start: string; start: string;
end: string; end: string;
description: string; description: string;
billable: number /* int */; // Percentage (0-100) billable: number /* int */; // Percentage (0-100)
} }
export interface TimeEntryCreateDto { export interface TimeEntryCreateDto {
userId: number /* int */; userId: string;
projectId: number /* int */; projectId: string;
activityId: number /* int */; activityId: string;
start: string; start: string;
end: string; end: string;
description: string; description: string;
@@ -123,9 +144,9 @@ export interface TimeEntryUpdateDto {
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
lastEditorID?: string; lastEditorID?: string;
userId?: number /* int */; userId?: string;
projectId?: number /* int */; projectId?: string;
activityId?: number /* int */; activityId?: string;
start?: string; start?: string;
end?: string; end?: string;
description?: string; description?: string;
@@ -141,16 +162,15 @@ export interface UserDto {
updatedAt: string; updatedAt: string;
lastEditorID: string; lastEditorID: string;
email: string; email: string;
password: string; // Note: In a real application, you would NEVER send the password in a DTO. This is just for demonstration.
role: string; role: string;
companyId: number /* int */; companyId?: string;
hourlyRate: number /* float64 */; hourlyRate: number /* float64 */;
} }
export interface UserCreateDto { export interface UserCreateDto {
email: string; email: string;
password: string; // Note: In a real application, you would NEVER send the password in a DTO. This is just for demonstration. password: string;
role: string; role: string;
companyId: number /* int */; companyId?: string;
hourlyRate: number /* float64 */; hourlyRate: number /* float64 */;
} }
export interface UserUpdateDto { export interface UserUpdateDto {
@@ -159,8 +179,8 @@ export interface UserUpdateDto {
updatedAt?: string; updatedAt?: string;
lastEditorID?: string; lastEditorID?: string;
email?: string; email?: string;
password?: string; // Note: In a real application, you would NEVER send the password in a DTO. This is just for demonstration. password?: string;
role?: string; role?: string;
companyId?: number /* int */; companyId?: Nullable<string>;
hourlyRate?: number /* float64 */; hourlyRate?: number /* float64 */;
} }
+1
View File
@@ -0,0 +1 @@
export type Nullable<T> = T | null;
+25
View File
@@ -0,0 +1,25 @@
# Refactoring Plan for backend/internal/api/handlers
## Goal
Refactor the code in `backend/internal/api/handlers` to reduce repetition and create helper functions for boilerplate operations, utilizing functions from `backend/internal/api/utils/handler_utils.go` and creating new ones if necessary.
## Analysis
The following common patterns were identified in the handler files:
1. **Error Handling:** Each handler function repeats the same error handling pattern.
2. **DTO Binding:** Parsing the request body and handling potential errors.
3. **ID Parsing:** Parsing the ID from the URL and handling potential errors.
4. **DTO Conversion:** Converting between DTOs and models.
5. **Success Responses:** Calling `responses.SuccessResponse` with the appropriate HTTP status code and data.
6. **Not Found Responses:** Checking if a record exists and calling `responses.NotFoundResponse` if it doesn't.
The `Update` handler is the most complex and has the most potential for refactoring.
## Plan
1. **Implement a generic `HandleUpdate` function in `handler_utils.go`:** This function will encapsulate the common logic for updating entities, including parsing the ID, binding the JSON, converting the DTO to a model, calling the update function, and handling errors and not found cases. The function will also handle nullable fields correctly.
2. **Modify the existing handlers to use the new `HandleUpdate` function:** This will involve removing the duplicated code from each handler and calling the generic function instead.
3. **Create new helper functions in `handler_utils.go` if needed:** If there are any specific operations that are not covered by the existing utility functions, I will create new ones to handle them.