Compare commits
41 Commits
f567d086ec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b47da3673 | |||
| a9c7598862 | |||
| bcc3aadb85 | |||
| fcdeedf7e9 | |||
| 21c9233058 | |||
| b9c900578d | |||
| 294047a2b0 | |||
| 1198b326c1 | |||
| b47c29cf5a | |||
| 4170eb5fbd | |||
| 233f3cdb5c | |||
| da115dc3f6 | |||
| 0379ea4ae4 | |||
| 016078c1c3 | |||
| c3162756ad | |||
| 2e13d775fa | |||
| b545392f27 | |||
| 9057adebdd | |||
| c08da6fc92 | |||
| 165432208c | |||
| 728258caa7 | |||
| e336ff3ba2 | |||
| 2555143c0e | |||
| ec250570a6 | |||
| a0b0b98624 | |||
| 09584efa39 | |||
| 78be762430 | |||
| baf656c093 | |||
| 460235b832 | |||
| 9f8eab0fac | |||
| 4b98c1a9e5 | |||
| dde2017ad1 | |||
| 8785b86bfc | |||
| 58173b436c | |||
| 558ee70c21 | |||
| aa5c7e77fc | |||
| ce39b7ba34 | |||
| d1720ea33d | |||
| 7f275c774e | |||
| 3b0b2b4340 | |||
| 3193204dac |
+48
@@ -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.
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Gitea Actions Demo
|
||||
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
Explore-Gitea-Actions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
|
||||
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
|
||||
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
|
||||
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
|
||||
- name: List files in the repository
|
||||
run: |
|
||||
ls ${{ gitea.workspace }}
|
||||
- run: echo "🍏 This job's status is ${{ job.status }}."
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
keys
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
+76
-15
@@ -1,23 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
_ "github.com/timetracker/backend/docs" // This line is important for swag to work
|
||||
"github.com/timetracker/backend/internal/infrastructure/persistence/db"
|
||||
_ "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"
|
||||
_ "gorm.io/driver/postgres"
|
||||
// GORM IMPORTS MARKER
|
||||
)
|
||||
|
||||
// @title Time Tracker API
|
||||
// @version 1.0
|
||||
// @description This is a simple time tracker API.
|
||||
// @host localhost:8080
|
||||
// @BasePath /
|
||||
// @BasePath /api
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
|
||||
// @Summary Say hello
|
||||
// @Description Get a hello message
|
||||
@@ -30,21 +41,71 @@ func helloHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := config.LoadConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
db, _ := db.NewDatasourceContainer(db.DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "timetracker",
|
||||
Password: "timetracker",
|
||||
DBName: "timetracker",
|
||||
SSLMode: "disable",
|
||||
})
|
||||
// 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)
|
||||
}
|
||||
}()
|
||||
|
||||
// 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.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))
|
||||
|
||||
fmt.Println("Server listening on port 8080")
|
||||
r.Run(":8080") // Use Gin's Run method
|
||||
// Create a server with graceful shutdown
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// Start server in a goroutine
|
||||
go func() {
|
||||
fmt.Println("Server listening on port 8080")
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Error starting server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal to gracefully shut down the server
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
// Create a deadline for server shutdown
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Shutdown the server
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Server exited properly")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"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 := config.DefaultDatabaseConfig()
|
||||
|
||||
// Initialize database
|
||||
fmt.Println("Connecting to database...")
|
||||
if err := db.InitDB(dbConfig); err != nil {
|
||||
log.Fatalf("Error initializing database: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.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 := db.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!")
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"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 := config.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 := db.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 = db.InitDB(dbConfig); err != nil {
|
||||
log.Fatalf("Error initializing database: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.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")
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/models"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Get database configuration with sensible defaults
|
||||
dbConfig := config.DefaultDatabaseConfig()
|
||||
|
||||
// Initialize database
|
||||
fmt.Println("Connecting to database...")
|
||||
if err := db.InitDB(dbConfig); err != nil {
|
||||
log.Fatalf("Error initializing database: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.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")
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"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 := db.InitDB(cfg.Database); err != nil {
|
||||
log.Fatalf("Error initializing database: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.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 := db.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 db.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
File diff suppressed because it is too large
Load Diff
+4640
-1
File diff suppressed because it is too large
Load Diff
+2595
-1
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -4,6 +4,7 @@ go 1.23.6
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/oklog/ulid/v2 v2.1.0
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
@@ -12,6 +13,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||
@@ -48,7 +50,7 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/net v0.37.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
|
||||
@@ -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/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/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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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,omitempty" 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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,238 @@
|
||||
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 {
|
||||
var customerIdPtr *string
|
||||
if project.CustomerID != nil {
|
||||
customerIdStr := project.CustomerID.String()
|
||||
customerIdPtr = &customerIdStr
|
||||
}
|
||||
return dto.ProjectDto{
|
||||
ID: project.ID.String(),
|
||||
CreatedAt: project.CreatedAt,
|
||||
UpdatedAt: project.UpdatedAt,
|
||||
Name: project.Name,
|
||||
CustomerID: customerIdPtr,
|
||||
}
|
||||
}
|
||||
|
||||
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 = types.Null[types.ULID]()
|
||||
} else {
|
||||
customerID, err := types.ULIDFromString(*dto.CustomerID.Value)
|
||||
if err != nil {
|
||||
return models.ProjectUpdate{}, fmt.Errorf("invalid customer ID: %w", err)
|
||||
}
|
||||
update.CustomerID = types.NewNullable(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()
|
||||
}
|
||||
}
|
||||
@@ -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 config.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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// DatabaseConfig contains the configuration data for the database connection
|
||||
type DatabaseConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
DBName string
|
||||
SSLMode string
|
||||
MaxIdleConns int // Maximum number of idle connections
|
||||
MaxOpenConns int // Maximum number of open connections
|
||||
MaxLifetime time.Duration // Maximum lifetime of a connection
|
||||
LogLevel logger.LogLevel
|
||||
}
|
||||
|
||||
// DefaultDatabaseConfig returns a default configuration with sensible values
|
||||
func DefaultDatabaseConfig() DatabaseConfig {
|
||||
return DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "timetracker",
|
||||
Password: "password",
|
||||
DBName: "timetracker",
|
||||
SSLMode: "disable",
|
||||
MaxIdleConns: 10,
|
||||
MaxOpenConns: 100,
|
||||
MaxLifetime: time.Hour,
|
||||
LogLevel: logger.Info,
|
||||
}
|
||||
}
|
||||
|
||||
// JWTConfig represents the configuration for JWT authentication
|
||||
type JWTConfig struct {
|
||||
Secret string
|
||||
TokenDuration time.Duration
|
||||
KeyGenerate bool
|
||||
KeyDir string
|
||||
PrivKeyFile string
|
||||
PubKeyFile string
|
||||
KeyBits int
|
||||
}
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
Database DatabaseConfig
|
||||
JWTConfig 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: DefaultDatabaseConfig(),
|
||||
JWTConfig: 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
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// Global variable for the DB connection
|
||||
var db *gorm.DB
|
||||
|
||||
// ErrDBNotInitialized is returned when a database operation is attempted before initialization
|
||||
var ErrDBNotInitialized = errors.New("database not initialized")
|
||||
|
||||
// InitDB initializes the database connection (once at startup)
|
||||
// with the provided configuration
|
||||
func InitDB(config config.DatabaseConfig) error {
|
||||
// Create connection using the default database name
|
||||
gormDB, err := createConnection(config, config.DBName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the global db instance
|
||||
db = gormDB
|
||||
|
||||
// Configure connection pool
|
||||
return configureConnectionPool(db, config)
|
||||
}
|
||||
|
||||
// GetEngine returns the DB instance with context
|
||||
func GetEngine(ctx context.Context) *gorm.DB {
|
||||
if db == nil {
|
||||
panic(ErrDBNotInitialized)
|
||||
}
|
||||
return db.WithContext(ctx)
|
||||
}
|
||||
|
||||
// CloseDB closes the database connection
|
||||
func CloseDB() error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sqlDB, err := db.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
|
||||
}
|
||||
|
||||
// GetGormDB is used for special cases like database creation
|
||||
func GetGormDB(dbConfig config.DatabaseConfig, dbName string) (*gorm.DB, error) {
|
||||
return createConnection(dbConfig, dbName)
|
||||
}
|
||||
|
||||
// MigrateDB performs database migrations for all models
|
||||
// This is a placeholder that will be called by models.MigrateDB
|
||||
func MigrateDB() error {
|
||||
if db == nil {
|
||||
return ErrDBNotInitialized
|
||||
}
|
||||
// The actual migration is implemented in models.MigrateDB
|
||||
// This is just a placeholder to make the migrate/main.go file work
|
||||
return errors.New("MigrateDB should be called from models package")
|
||||
}
|
||||
|
||||
// createConnection creates a new database connection with the given configuration
|
||||
func createConnection(dbConfig config.DatabaseConfig, dbName string) (*gorm.DB, error) {
|
||||
// Create DSN (Data Source Name)
|
||||
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 := createGormLogger(dbConfig)
|
||||
|
||||
// Establish database connection with custom logger
|
||||
gormDB, 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 gormDB, nil
|
||||
}
|
||||
|
||||
// createGormLogger creates a configured GORM logger instance
|
||||
func createGormLogger(dbConfig config.DatabaseConfig) logger.Interface {
|
||||
return 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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// configureConnectionPool sets up the connection pool parameters
|
||||
func configureConnectionPool(db *gorm.DB, config config.DatabaseConfig) error {
|
||||
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)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
EntityBase
|
||||
Name string
|
||||
BillingRate float64
|
||||
}
|
||||
|
||||
type ActivityUpdate struct {
|
||||
ID ulid.ULID
|
||||
Name *string
|
||||
BillingRate *float64
|
||||
}
|
||||
|
||||
type ActivityCreate struct {
|
||||
Name string
|
||||
BillingRate float64
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
type EntityBase struct {
|
||||
ID ulid.ULID
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LastEditorID ulid.ULID
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package entities
|
||||
|
||||
import "github.com/oklog/ulid/v2"
|
||||
|
||||
type Company struct {
|
||||
EntityBase
|
||||
Name string
|
||||
}
|
||||
|
||||
type CompanyCreate struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type CompanyUpdate struct {
|
||||
ID ulid.ULID
|
||||
Name *string
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package entities
|
||||
|
||||
import "github.com/oklog/ulid/v2"
|
||||
|
||||
type Customer struct {
|
||||
EntityBase
|
||||
Name string
|
||||
CompanyID int
|
||||
}
|
||||
|
||||
type CustomerCreate struct {
|
||||
Name string
|
||||
CompanyID int
|
||||
}
|
||||
|
||||
type CustomerUpdate struct {
|
||||
ID ulid.ULID
|
||||
Name *string
|
||||
CompanyID *int
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package entities
|
||||
|
||||
import "github.com/oklog/ulid/v2"
|
||||
|
||||
type Project struct {
|
||||
EntityBase
|
||||
Name string
|
||||
CustomerID int
|
||||
}
|
||||
|
||||
type ProjectCreate struct {
|
||||
Name string
|
||||
CustomerID int
|
||||
}
|
||||
|
||||
type ProjectUpdate struct {
|
||||
ID ulid.ULID
|
||||
Name *string
|
||||
CustomerID *int
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
type TimeEntry struct {
|
||||
EntityBase
|
||||
UserID int
|
||||
ProjectID int
|
||||
ActivityID int
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Description string
|
||||
Billable int // Percentage (0-100)
|
||||
}
|
||||
|
||||
type TimeEntryCreate struct {
|
||||
UserID int
|
||||
ProjectID int
|
||||
ActivityID int
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Description string
|
||||
Billable int // Percentage (0-100)
|
||||
}
|
||||
|
||||
type TimeEntryUpdate struct {
|
||||
ID ulid.ULID
|
||||
UserID *int
|
||||
ProjectID *int
|
||||
ActivityID *int
|
||||
Start *time.Time
|
||||
End *time.Time
|
||||
Description *string
|
||||
Billable *int // Percentage (0-100)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package entities
|
||||
|
||||
import "github.com/oklog/ulid/v2"
|
||||
|
||||
type User struct {
|
||||
EntityBase
|
||||
Username string
|
||||
Password string
|
||||
Role string
|
||||
CompanyID int
|
||||
HourlyRate float64
|
||||
}
|
||||
|
||||
type UserCreate struct {
|
||||
Username string
|
||||
Password string
|
||||
Role string
|
||||
CompanyID int
|
||||
HourlyRate float64
|
||||
}
|
||||
|
||||
type UserUpdate struct {
|
||||
ID ulid.ULID
|
||||
Username *string
|
||||
Password *string
|
||||
Role *string
|
||||
CompanyID *int
|
||||
HourlyRate *float64
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
type ActivityDatasource interface {
|
||||
Get(ctx context.Context, id ulid.ULID) (*entities.Activity, error)
|
||||
Create(ctx context.Context, activity *entities.Activity) error
|
||||
Update(ctx context.Context, activity *entities.Activity) error
|
||||
Delete(ctx context.Context, id ulid.ULID) error
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
type CompanyDatasource interface {
|
||||
Get(ctx context.Context, id ulid.ULID) (*entities.Company, error)
|
||||
Create(ctx context.Context, company *entities.Company) error
|
||||
Update(ctx context.Context, company *entities.Company) error
|
||||
Delete(ctx context.Context, id ulid.ULID) error
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
type CustomerDatasource interface {
|
||||
Get(ctx context.Context, id ulid.ULID) (*entities.Customer, error)
|
||||
Create(ctx context.Context, customer *entities.Customer) error
|
||||
Update(ctx context.Context, customer *entities.Customer) error
|
||||
Delete(ctx context.Context, id ulid.ULID) error
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
type ProjectDatasource interface {
|
||||
Get(ctx context.Context, id ulid.ULID) (*entities.Project, error)
|
||||
Create(ctx context.Context, project *entities.Project) error
|
||||
Update(ctx context.Context, project *entities.Project) error
|
||||
Delete(ctx context.Context, id ulid.ULID) error
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
type TimeEntryDatasource interface {
|
||||
Get(ctx context.Context, id ulid.ULID) (*entities.TimeEntry, error)
|
||||
Create(ctx context.Context, timeEntry *entities.TimeEntry) error
|
||||
Update(ctx context.Context, timeEntry *entities.TimeEntry) error
|
||||
Delete(ctx context.Context, id ulid.ULID) error
|
||||
GetByRange(ctx context.Context, userID ulid.ULID, from time.Time, to time.Time) ([]*entities.TimeEntry, error)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
type UserDatasource interface {
|
||||
Get(ctx context.Context, id ulid.ULID) (*entities.User, error)
|
||||
Create(ctx context.Context, user *entities.User) error
|
||||
Update(ctx context.Context, user *entities.User) error
|
||||
Delete(ctx context.Context, id ulid.ULID) error
|
||||
GetByUsername(ctx context.Context, username string) (*entities.User, error)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
type ActivityRepository interface {
|
||||
Get(ctx context.Context, id ulid.ULID) (*entities.Activity, error)
|
||||
Create(ctx context.Context, activity *entities.ActivityCreate) error
|
||||
Update(ctx context.Context, activity *entities.ActivityUpdate) error
|
||||
Delete(ctx context.Context, id ulid.ULID) error
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
type CompanyRepository interface {
|
||||
Get(ctx context.Context, id ulid.ULID) (*entities.Company, error)
|
||||
Create(ctx context.Context, company *entities.Company) error
|
||||
Update(ctx context.Context, company *entities.Company) error
|
||||
Delete(ctx context.Context, id ulid.ULID) error
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
type CustomerRepository interface {
|
||||
Get(ctx context.Context, id ulid.ULID) (*entities.Customer, error)
|
||||
Create(ctx context.Context, customer *entities.Customer) error
|
||||
Update(ctx context.Context, customer *entities.Customer) error
|
||||
Delete(ctx context.Context, id ulid.ULID) error
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
type ProjectRepository interface {
|
||||
Get(ctx context.Context, id ulid.ULID) (*entities.Project, error)
|
||||
Create(ctx context.Context, project *entities.Project) error
|
||||
Update(ctx context.Context, project *entities.Project) error
|
||||
Delete(ctx context.Context, id ulid.ULID) error
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
type TimeEntryRepository interface {
|
||||
Get(ctx context.Context, id ulid.ULID) (*entities.TimeEntry, error)
|
||||
Create(ctx context.Context, timeEntry *entities.TimeEntry) error
|
||||
Update(ctx context.Context, timeEntry *entities.TimeEntry) error
|
||||
Delete(ctx context.Context, id ulid.ULID) error
|
||||
GetByRange(ctx context.Context, userID ulid.ULID, from time.Time, to time.Time) ([]*entities.TimeEntry, error)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
Get(ctx context.Context, id ulid.ULID) (*entities.User, error)
|
||||
Create(ctx context.Context, user *entities.UserCreate) error
|
||||
Update(ctx context.Context, user *entities.UserUpdate) error
|
||||
Delete(ctx context.Context, id ulid.ULID) error
|
||||
GetByUsername(ctx context.Context, username string) (*entities.User, error)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/timetracker/backend/internal/domain/persistence"
|
||||
"github.com/timetracker/backend/internal/infrastructure/persistence/db/ds"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DatabaseConfig enthält die Konfigurationsinformationen für die Datenbankverbindung
|
||||
type DatabaseConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
DBName string
|
||||
SSLMode string
|
||||
}
|
||||
|
||||
// DatasourceContainer enthält alle Datasource-Instanzen
|
||||
type DatasourceContainer struct {
|
||||
ActivityDatasource persistence.ActivityDatasource
|
||||
CompanyDatasource persistence.CompanyDatasource
|
||||
CustomerDatasource persistence.CustomerDatasource
|
||||
ProjectDatasource persistence.ProjectDatasource
|
||||
TimeEntryDatasource persistence.TimeEntryDatasource
|
||||
UserDatasource persistence.UserDatasource
|
||||
}
|
||||
|
||||
// NewDatasourceContainer erstellt und initialisiert alle Datasource-Instanzen
|
||||
func NewDatasourceContainer(config DatabaseConfig) (*DatasourceContainer, error) {
|
||||
// Erstelle DSN (Data Source Name) für die Datenbankverbindung
|
||||
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)
|
||||
|
||||
// Erstelle SQL-Datenbankverbindung
|
||||
sqlDB, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Öffnen der SQL-Verbindung: %w", err)
|
||||
}
|
||||
|
||||
// Konfiguriere Verbindungspool
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetMaxOpenConns(100)
|
||||
|
||||
// Initialisiere GORM mit der SQL-Verbindung
|
||||
gormDB, err := gorm.Open(postgres.New(postgres.Config{
|
||||
Conn: sqlDB,
|
||||
}), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Initialisieren von GORM: %w", err)
|
||||
}
|
||||
|
||||
// Erstelle alle Datasource-Instanzen
|
||||
return &DatasourceContainer{
|
||||
ActivityDatasource: ds.NewActivityDatasource(gormDB),
|
||||
CompanyDatasource: ds.NewCompanyDatasource(gormDB),
|
||||
CustomerDatasource: ds.NewCustomerDatasource(gormDB),
|
||||
ProjectDatasource: ds.NewProjectDatasource(gormDB),
|
||||
TimeEntryDatasource: ds.NewTimeEntryDatasource(gormDB),
|
||||
UserDatasource: ds.NewUserDatasource(gormDB),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close schließt die Datenbankverbindung
|
||||
func (r *DatasourceContainer) Close() error {
|
||||
db, err := r.getGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
// Helper-Methode, um die GORM-DB aus einem der Repositories zu extrahieren
|
||||
func (r *DatasourceContainer) getGormDB() (*gorm.DB, error) {
|
||||
// Wir nehmen an, dass alle Repositories das gleiche DB-Handle verwenden
|
||||
// Deshalb können wir einfach eines der Repositories nehmen
|
||||
// Dies funktioniert nur, wenn wir Zugriff auf die interne DB haben oder eine Methode hinzufügen
|
||||
// Hier müsste angepasst werden, wie Sie Zugriff auf die GORM-DB bekommen
|
||||
|
||||
// Beispiel (müsste angepasst werden):
|
||||
// activityDS, ok := r.ActivityDatasource.(*ds.ActivityDatasource)
|
||||
// if !ok {
|
||||
// return nil, fmt.Errorf("Konnte GORM-DB nicht aus ActivityDatasource extrahieren")
|
||||
// }
|
||||
// return activityDS.GetDB(), nil
|
||||
|
||||
// Placeholder für die tatsächliche Implementierung:
|
||||
return nil, fmt.Errorf("getGormDB() muss implementiert werden")
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package dbo
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ActivityDBO struct {
|
||||
gorm.Model
|
||||
ID ulid.ULID `gorm:"primaryKey;type:uuid"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
LastEditorID ulid.ULID
|
||||
Name string `gorm:"type:varchar(255);not null"`
|
||||
BillingRate float64 `gorm:"type:decimal(10,2)"`
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package dbo
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CompanyDBO struct {
|
||||
gorm.Model
|
||||
ID ulid.ULID `gorm:"primaryKey;type:uuid"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
LastEditorID ulid.ULID
|
||||
Name string `gorm:"type:varchar(255);not null"`
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package dbo
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CustomerDBO struct {
|
||||
gorm.Model
|
||||
ID ulid.ULID `gorm:"primaryKey;type:uuid"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
LastEditorID ulid.ULID
|
||||
Name string `gorm:"type:varchar(255);not null"`
|
||||
CompanyID int
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package dbo
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ProjectDBO struct {
|
||||
gorm.Model
|
||||
ID ulid.ULID `gorm:"primaryKey;type:uuid"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
LastEditorID ulid.ULID
|
||||
Name string `gorm:"type:varchar(255);not null"`
|
||||
CustomerID int
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package dbo
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TimeEntryDBO struct {
|
||||
gorm.Model
|
||||
ID ulid.ULID `gorm:"primaryKey;type:uuid"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
LastEditorID ulid.ULID
|
||||
UserID int
|
||||
ProjectID int
|
||||
ActivityID int
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Description string
|
||||
Billable int // Percentage (0-100)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package dbo
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserDBO struct {
|
||||
gorm.Model
|
||||
ID ulid.ULID `gorm:"primaryKey;type:uuid"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
LastEditorID ulid.ULID
|
||||
Username string
|
||||
Password string
|
||||
Role string
|
||||
CompanyID int
|
||||
HourlyRate float64
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package ds
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
"github.com/timetracker/backend/internal/domain/persistence"
|
||||
"github.com/timetracker/backend/internal/infrastructure/persistence/db/dbo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ActivityDatasource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewActivityDatasource(db *gorm.DB) persistence.ActivityDatasource {
|
||||
return &ActivityDatasource{db: db}
|
||||
}
|
||||
|
||||
func (r *ActivityDatasource) Get(ctx context.Context, id ulid.ULID) (*entities.Activity, error) {
|
||||
var activityDBO dbo.ActivityDBO
|
||||
if err := r.db.WithContext(ctx).First(&activityDBO, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activity := &entities.Activity{
|
||||
EntityBase: entities.EntityBase{
|
||||
ID: activityDBO.ID,
|
||||
CreatedAt: activityDBO.CreatedAt,
|
||||
UpdatedAt: activityDBO.UpdatedAt,
|
||||
},
|
||||
Name: activityDBO.Name,
|
||||
BillingRate: activityDBO.BillingRate,
|
||||
}
|
||||
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
func (r *ActivityDatasource) Create(ctx context.Context, activity *entities.Activity) error {
|
||||
activityDBO := dbo.ActivityDBO{
|
||||
ID: activity.ID,
|
||||
CreatedAt: activity.CreatedAt,
|
||||
UpdatedAt: activity.UpdatedAt,
|
||||
Name: activity.Name,
|
||||
BillingRate: activity.BillingRate,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Create(&activityDBO).Error
|
||||
}
|
||||
|
||||
func (r *ActivityDatasource) Update(ctx context.Context, activity *entities.Activity) error {
|
||||
activityDBO := dbo.ActivityDBO{
|
||||
ID: activity.ID,
|
||||
CreatedAt: activity.CreatedAt,
|
||||
UpdatedAt: activity.UpdatedAt,
|
||||
Name: activity.Name,
|
||||
BillingRate: activity.BillingRate,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Save(&activityDBO).Error
|
||||
}
|
||||
|
||||
func (r *ActivityDatasource) Delete(ctx context.Context, id ulid.ULID) error {
|
||||
return r.db.WithContext(ctx).Delete(&dbo.ActivityDBO{}, "id = ?", id).Error
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package ds
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
"github.com/timetracker/backend/internal/domain/persistence"
|
||||
"github.com/timetracker/backend/internal/infrastructure/persistence/db/dbo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CompanyyDatasource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCompanyDatasource(db *gorm.DB) persistence.CompanyDatasource {
|
||||
return &CompanyyDatasource{db: db}
|
||||
}
|
||||
|
||||
func (r *CompanyyDatasource) Get(ctx context.Context, id ulid.ULID) (*entities.Company, error) {
|
||||
var companyDBO dbo.CompanyDBO
|
||||
if err := r.db.WithContext(ctx).First(&companyDBO, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
company := &entities.Company{
|
||||
EntityBase: entities.EntityBase{
|
||||
ID: companyDBO.ID,
|
||||
CreatedAt: companyDBO.CreatedAt,
|
||||
UpdatedAt: companyDBO.UpdatedAt,
|
||||
},
|
||||
Name: companyDBO.Name,
|
||||
}
|
||||
|
||||
return company, nil
|
||||
}
|
||||
|
||||
func (r *CompanyyDatasource) Create(ctx context.Context, company *entities.Company) error {
|
||||
companyDBO := dbo.CompanyDBO{
|
||||
ID: company.ID,
|
||||
CreatedAt: company.CreatedAt,
|
||||
UpdatedAt: company.UpdatedAt,
|
||||
Name: company.Name,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Create(&companyDBO).Error
|
||||
}
|
||||
|
||||
func (r *CompanyyDatasource) Update(ctx context.Context, company *entities.Company) error {
|
||||
companyDBO := dbo.CompanyDBO{
|
||||
ID: company.ID,
|
||||
CreatedAt: company.CreatedAt,
|
||||
UpdatedAt: company.UpdatedAt,
|
||||
Name: company.Name,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Save(&companyDBO).Error
|
||||
}
|
||||
|
||||
func (r *CompanyyDatasource) Delete(ctx context.Context, id ulid.ULID) error {
|
||||
return r.db.WithContext(ctx).Delete(&dbo.CompanyDBO{}, "id = ?", id).Error
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package ds
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
"github.com/timetracker/backend/internal/domain/persistence"
|
||||
"github.com/timetracker/backend/internal/infrastructure/persistence/db/dbo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CustomerDatasource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomerDatasource(db *gorm.DB) persistence.CustomerDatasource {
|
||||
return &CustomerDatasource{db: db}
|
||||
}
|
||||
|
||||
func (r *CustomerDatasource) Get(ctx context.Context, id ulid.ULID) (*entities.Customer, error) {
|
||||
var customerDBO dbo.CustomerDBO
|
||||
if err := r.db.WithContext(ctx).First(&customerDBO, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
customer := &entities.Customer{
|
||||
EntityBase: entities.EntityBase{
|
||||
ID: customerDBO.ID,
|
||||
CreatedAt: customerDBO.CreatedAt,
|
||||
UpdatedAt: customerDBO.UpdatedAt,
|
||||
},
|
||||
Name: customerDBO.Name,
|
||||
CompanyID: customerDBO.CompanyID,
|
||||
}
|
||||
|
||||
return customer, nil
|
||||
}
|
||||
|
||||
func (r *CustomerDatasource) Create(ctx context.Context, customer *entities.Customer) error {
|
||||
customerDBO := dbo.CustomerDBO{
|
||||
ID: customer.ID,
|
||||
CreatedAt: customer.CreatedAt,
|
||||
UpdatedAt: customer.UpdatedAt,
|
||||
Name: customer.Name,
|
||||
CompanyID: customer.CompanyID,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Create(&customerDBO).Error
|
||||
}
|
||||
|
||||
func (r *CustomerDatasource) Update(ctx context.Context, customer *entities.Customer) error {
|
||||
customerDBO := dbo.CustomerDBO{
|
||||
ID: customer.ID,
|
||||
CreatedAt: customer.CreatedAt,
|
||||
UpdatedAt: customer.UpdatedAt,
|
||||
Name: customer.Name,
|
||||
CompanyID: customer.CompanyID,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Save(&customerDBO).Error
|
||||
}
|
||||
|
||||
func (r *CustomerDatasource) Delete(ctx context.Context, id ulid.ULID) error {
|
||||
return r.db.WithContext(ctx).Delete(&dbo.CustomerDBO{}, "id = ?", id).Error
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package ds
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
"github.com/timetracker/backend/internal/domain/persistence"
|
||||
"github.com/timetracker/backend/internal/infrastructure/persistence/db/dbo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ProjectDatasource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewProjectDatasource(db *gorm.DB) persistence.ProjectDatasource {
|
||||
return &ProjectDatasource{db: db}
|
||||
}
|
||||
|
||||
func (r *ProjectDatasource) Get(ctx context.Context, id ulid.ULID) (*entities.Project, error) {
|
||||
var projectDBO dbo.ProjectDBO
|
||||
if err := r.db.WithContext(ctx).First(&projectDBO, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
project := &entities.Project{
|
||||
EntityBase: entities.EntityBase{
|
||||
ID: projectDBO.ID,
|
||||
CreatedAt: projectDBO.CreatedAt,
|
||||
UpdatedAt: projectDBO.UpdatedAt,
|
||||
},
|
||||
Name: projectDBO.Name,
|
||||
CustomerID: projectDBO.CustomerID,
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func (r *ProjectDatasource) Create(ctx context.Context, project *entities.Project) error {
|
||||
projectDBO := dbo.ProjectDBO{
|
||||
ID: project.ID,
|
||||
CreatedAt: project.CreatedAt,
|
||||
UpdatedAt: project.UpdatedAt,
|
||||
Name: project.Name,
|
||||
CustomerID: project.CustomerID,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Create(&projectDBO).Error
|
||||
}
|
||||
|
||||
func (r *ProjectDatasource) Update(ctx context.Context, project *entities.Project) error {
|
||||
projectDBO := dbo.ProjectDBO{
|
||||
ID: project.ID,
|
||||
CreatedAt: project.CreatedAt,
|
||||
UpdatedAt: project.UpdatedAt,
|
||||
Name: project.Name,
|
||||
CustomerID: project.CustomerID,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Save(&projectDBO).Error
|
||||
}
|
||||
|
||||
func (r *ProjectDatasource) Delete(ctx context.Context, id ulid.ULID) error {
|
||||
return r.db.WithContext(ctx).Delete(&dbo.ProjectDBO{}, "id = ?", id).Error
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package ds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
"github.com/timetracker/backend/internal/domain/persistence"
|
||||
"github.com/timetracker/backend/internal/infrastructure/persistence/db/dbo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TimeEntryDatasource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTimeEntryDatasource(db *gorm.DB) persistence.TimeEntryDatasource {
|
||||
return &TimeEntryDatasource{db: db}
|
||||
}
|
||||
|
||||
func (r *TimeEntryDatasource) Get(ctx context.Context, id ulid.ULID) (*entities.TimeEntry, error) {
|
||||
var timeEntryDBO dbo.TimeEntryDBO
|
||||
if err := r.db.WithContext(ctx).First(&timeEntryDBO, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeEntry := &entities.TimeEntry{
|
||||
EntityBase: entities.EntityBase{
|
||||
ID: timeEntryDBO.ID,
|
||||
CreatedAt: timeEntryDBO.CreatedAt,
|
||||
UpdatedAt: timeEntryDBO.UpdatedAt,
|
||||
},
|
||||
UserID: timeEntryDBO.UserID,
|
||||
ProjectID: timeEntryDBO.ProjectID,
|
||||
ActivityID: timeEntryDBO.ActivityID,
|
||||
Start: timeEntryDBO.Start,
|
||||
End: timeEntryDBO.End,
|
||||
Description: timeEntryDBO.Description,
|
||||
Billable: timeEntryDBO.Billable,
|
||||
}
|
||||
|
||||
return timeEntry, nil
|
||||
}
|
||||
|
||||
func (r *TimeEntryDatasource) Create(ctx context.Context, timeEntry *entities.TimeEntry) error {
|
||||
timeEntryDBO := dbo.TimeEntryDBO{
|
||||
ID: timeEntry.ID,
|
||||
CreatedAt: timeEntry.CreatedAt,
|
||||
UpdatedAt: timeEntry.UpdatedAt,
|
||||
UserID: timeEntry.UserID,
|
||||
ProjectID: timeEntry.ProjectID,
|
||||
ActivityID: timeEntry.ActivityID,
|
||||
Start: timeEntry.Start,
|
||||
End: timeEntry.End,
|
||||
Description: timeEntry.Description,
|
||||
Billable: timeEntry.Billable,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Create(&timeEntryDBO).Error
|
||||
}
|
||||
|
||||
func (r *TimeEntryDatasource) Update(ctx context.Context, timeEntry *entities.TimeEntry) error {
|
||||
timeEntryDBO := dbo.TimeEntryDBO{
|
||||
ID: timeEntry.ID,
|
||||
CreatedAt: timeEntry.CreatedAt,
|
||||
UpdatedAt: timeEntry.UpdatedAt,
|
||||
UserID: timeEntry.UserID,
|
||||
ProjectID: timeEntry.ProjectID,
|
||||
ActivityID: timeEntry.ActivityID,
|
||||
Start: timeEntry.Start,
|
||||
End: timeEntry.End,
|
||||
Description: timeEntry.Description,
|
||||
Billable: timeEntry.Billable,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Save(&timeEntryDBO).Error
|
||||
}
|
||||
|
||||
func (r *TimeEntryDatasource) Delete(ctx context.Context, id ulid.ULID) error {
|
||||
return r.db.WithContext(ctx).Delete(&dbo.TimeEntryDBO{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *TimeEntryDatasource) GetByRange(ctx context.Context, userID ulid.ULID, from time.Time, to time.Time) ([]*entities.TimeEntry, error) {
|
||||
var timeEntryDBOs []*dbo.TimeEntryDBO
|
||||
if err := r.db.WithContext(ctx).Where("user_id = ? AND start_time >= ? AND end_time <= ?", userID, from, to).Find(&timeEntryDBOs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeEntries := make([]*entities.TimeEntry, len(timeEntryDBOs))
|
||||
for i, timeEntryDBO := range timeEntryDBOs {
|
||||
timeEntries[i] = &entities.TimeEntry{
|
||||
EntityBase: entities.EntityBase{
|
||||
ID: timeEntryDBO.ID,
|
||||
CreatedAt: timeEntryDBO.CreatedAt,
|
||||
UpdatedAt: timeEntryDBO.UpdatedAt,
|
||||
},
|
||||
UserID: timeEntryDBO.UserID,
|
||||
ProjectID: timeEntryDBO.ProjectID,
|
||||
ActivityID: timeEntryDBO.ActivityID,
|
||||
Start: timeEntryDBO.Start,
|
||||
End: timeEntryDBO.End,
|
||||
Description: timeEntryDBO.Description,
|
||||
Billable: timeEntryDBO.Billable,
|
||||
}
|
||||
}
|
||||
|
||||
return timeEntries, nil
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package ds
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/domain/entities"
|
||||
"github.com/timetracker/backend/internal/domain/persistence"
|
||||
"github.com/timetracker/backend/internal/infrastructure/persistence/db/dbo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserDatasource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserDatasource(db *gorm.DB) persistence.UserDatasource {
|
||||
return &UserDatasource{db: db}
|
||||
}
|
||||
|
||||
func (r *UserDatasource) Get(ctx context.Context, id ulid.ULID) (*entities.User, error) {
|
||||
var userDBO dbo.UserDBO
|
||||
if err := r.db.WithContext(ctx).First(&userDBO, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &entities.User{
|
||||
EntityBase: entities.EntityBase{
|
||||
ID: userDBO.ID,
|
||||
CreatedAt: userDBO.CreatedAt,
|
||||
UpdatedAt: userDBO.UpdatedAt,
|
||||
},
|
||||
Username: userDBO.Username,
|
||||
Password: userDBO.Password,
|
||||
Role: userDBO.Role,
|
||||
CompanyID: userDBO.CompanyID,
|
||||
HourlyRate: userDBO.HourlyRate,
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *UserDatasource) Create(ctx context.Context, user *entities.User) error {
|
||||
userDBO := dbo.UserDBO{
|
||||
ID: user.ID,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
Username: user.Username,
|
||||
Password: user.Password,
|
||||
Role: user.Role,
|
||||
CompanyID: user.CompanyID,
|
||||
HourlyRate: user.HourlyRate,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Create(&userDBO).Error
|
||||
}
|
||||
|
||||
func (r *UserDatasource) Update(ctx context.Context, user *entities.User) error {
|
||||
userDBO := dbo.UserDBO{
|
||||
ID: user.ID,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
Username: user.Username,
|
||||
Password: user.Password,
|
||||
Role: user.Role,
|
||||
CompanyID: user.CompanyID,
|
||||
HourlyRate: user.HourlyRate,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Save(&userDBO).Error
|
||||
}
|
||||
|
||||
func (r *UserDatasource) Delete(ctx context.Context, id ulid.ULID) error {
|
||||
return r.db.WithContext(ctx).Delete(&dbo.UserDBO{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *UserDatasource) GetByUsername(ctx context.Context, username string) (*entities.User, error) {
|
||||
var userDBO dbo.UserDBO
|
||||
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&userDBO).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &entities.User{
|
||||
EntityBase: entities.EntityBase{
|
||||
ID: userDBO.ID,
|
||||
CreatedAt: userDBO.CreatedAt,
|
||||
UpdatedAt: userDBO.UpdatedAt,
|
||||
},
|
||||
Username: userDBO.Username,
|
||||
Password: userDBO.Password,
|
||||
Role: userDBO.Role,
|
||||
CompanyID: userDBO.CompanyID,
|
||||
HourlyRate: userDBO.HourlyRate,
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,39 +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"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"` // Note: In a real application, you would NEVER send the password in a DTO. This is just for demonstration.
|
||||
Role string `json:"role"`
|
||||
CompanyID int `json:"companyId"`
|
||||
HourlyRate float64 `json:"hourlyRate"`
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"` // Note: In a real application, you would NEVER send the password in a DTO. This is just for demonstration.
|
||||
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"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"` // Note: In a real application, you would NEVER send the password in a DTO. This is just for demonstration.
|
||||
Role *string `json:"role"`
|
||||
CompanyID *int `json:"companyId"`
|
||||
HourlyRate *float64 `json:"hourlyRate"`
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Activity represents an activity in the system
|
||||
type Activity struct {
|
||||
EntityBase
|
||||
Name string `gorm:"column:name"`
|
||||
BillingRate float64 `gorm:"column:billing_rate"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for GORM
|
||||
func (Activity) TableName() string {
|
||||
return "activities"
|
||||
}
|
||||
|
||||
// ActivityUpdate contains the updatable fields of an Activity
|
||||
type ActivityUpdate struct {
|
||||
ID types.ULID `gorm:"-"` // Use "-" to indicate that this field should be ignored
|
||||
Name *string `gorm:"column:name"`
|
||||
BillingRate *float64 `gorm:"column:billing_rate"`
|
||||
}
|
||||
|
||||
// ActivityCreate contains the fields for creating a new Activity
|
||||
type ActivityCreate struct {
|
||||
Name string `gorm:"column:name"`
|
||||
BillingRate float64 `gorm:"column:billing_rate"`
|
||||
}
|
||||
|
||||
// GetActivityByID finds an Activity by its ID
|
||||
func GetActivityByID(ctx context.Context, id types.ULID) (*Activity, error) {
|
||||
var activity Activity
|
||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&activity)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &activity, nil
|
||||
}
|
||||
|
||||
// GetAllActivities returns all Activities
|
||||
func GetAllActivities(ctx context.Context) ([]Activity, error) {
|
||||
var activities []Activity
|
||||
result := db.GetEngine(ctx).Find(&activities)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
// CreateActivity creates a new Activity
|
||||
func CreateActivity(ctx context.Context, create ActivityCreate) (*Activity, error) {
|
||||
activity := Activity{
|
||||
Name: create.Name,
|
||||
BillingRate: create.BillingRate,
|
||||
}
|
||||
|
||||
result := db.GetEngine(ctx).Create(&activity)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return &activity, nil
|
||||
}
|
||||
|
||||
// UpdateActivity updates an existing Activity
|
||||
func UpdateActivity(ctx context.Context, update ActivityUpdate) (*Activity, error) {
|
||||
activity, err := GetActivityByID(ctx, update.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if activity == nil {
|
||||
return nil, errors.New("activity not found")
|
||||
}
|
||||
|
||||
// Use generic update function
|
||||
if err := UpdateModel(ctx, activity, update); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load updated data from the database
|
||||
return GetActivityByID(ctx, update.ID)
|
||||
}
|
||||
|
||||
// DeleteActivity deletes an Activity by its ID
|
||||
func DeleteActivity(ctx context.Context, id types.ULID) error {
|
||||
result := db.GetEngine(ctx).Delete(&Activity{}, id)
|
||||
return result.Error
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type EntityBase struct {
|
||||
ID types.ULID `gorm:"type:bytea;primaryKey"`
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time `gorm:"index"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
}
|
||||
|
||||
// BeforeCreate is called by GORM before creating a record
|
||||
func (eb *EntityBase) BeforeCreate(tx *gorm.DB) error {
|
||||
if eb.ID.Compare(types.ULID{}) == 0 { // If ID is empty
|
||||
// Generate a new types.ULID
|
||||
entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0)
|
||||
newID := ulid.MustNew(ulid.Timestamp(time.Now()), entropy)
|
||||
eb.ID = types.ULID{ULID: newID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Company represents a company in the system
|
||||
type Company struct {
|
||||
EntityBase
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for GORM
|
||||
func (Company) TableName() string {
|
||||
return "companies"
|
||||
}
|
||||
|
||||
// CompanyCreate contains the fields for creating a new company
|
||||
type CompanyCreate struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// CompanyUpdate contains the updatable fields of a company
|
||||
type CompanyUpdate struct {
|
||||
ID types.ULID `gorm:"-"` // Exclude from updates
|
||||
Name *string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
// GetCompanyByID finds a company by its ID
|
||||
func GetCompanyByID(ctx context.Context, id types.ULID) (*Company, error) {
|
||||
var company Company
|
||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&company)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &company, nil
|
||||
}
|
||||
|
||||
// GetAllCompanies returns all companies
|
||||
func GetAllCompanies(ctx context.Context) ([]Company, error) {
|
||||
var companies []Company
|
||||
result := db.GetEngine(ctx).Find(&companies)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return companies, nil
|
||||
}
|
||||
|
||||
func GetCustomersByCompanyID(ctx context.Context, companyID int) ([]Customer, error) {
|
||||
var customers []Customer
|
||||
result := db.GetEngine(ctx).Where("company_id = ?", companyID).Find(&customers)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return customers, nil
|
||||
}
|
||||
|
||||
// CreateCompany creates a new company
|
||||
func CreateCompany(ctx context.Context, create CompanyCreate) (*Company, error) {
|
||||
company := Company{
|
||||
Name: create.Name,
|
||||
}
|
||||
|
||||
result := db.GetEngine(ctx).Create(&company)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return &company, nil
|
||||
}
|
||||
|
||||
// UpdateCompany updates an existing company
|
||||
func UpdateCompany(ctx context.Context, update CompanyUpdate) (*Company, error) {
|
||||
company, err := GetCompanyByID(ctx, update.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if company == nil {
|
||||
return nil, errors.New("company not found")
|
||||
}
|
||||
|
||||
// Use generic update function
|
||||
if err := UpdateModel(ctx, company, update); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load updated data from the database
|
||||
return GetCompanyByID(ctx, update.ID)
|
||||
}
|
||||
|
||||
// DeleteCompany deletes a company by its ID
|
||||
func DeleteCompany(ctx context.Context, id types.ULID) error {
|
||||
result := db.GetEngine(ctx).Delete(&Company{}, id)
|
||||
return result.Error
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Customer represents a customer in the system
|
||||
type Customer struct {
|
||||
EntityBase
|
||||
Name string `gorm:"column:name"`
|
||||
CompanyID *types.ULID `gorm:"type:bytea;column:company_id"`
|
||||
OwnerUserID *types.ULID `gorm:"type:bytea;column:owner_user_id"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for GORM
|
||||
func (Customer) TableName() string {
|
||||
return "customers"
|
||||
}
|
||||
|
||||
// CustomerCreate contains the fields for creating a new customer
|
||||
type CustomerCreate struct {
|
||||
Name string
|
||||
CompanyID *types.ULID
|
||||
OwnerUserID *types.ULID
|
||||
}
|
||||
|
||||
// CustomerUpdate contains the updatable fields of a customer
|
||||
type CustomerUpdate struct {
|
||||
ID types.ULID `gorm:"-"` // Exclude from updates
|
||||
Name *string `gorm:"column:name"`
|
||||
CompanyID *types.ULID `gorm:"column:company_id"`
|
||||
OwnerUserID *types.ULID `gorm:"column:owner_user_id"`
|
||||
}
|
||||
|
||||
// GetCustomerByID finds a customer by its ID
|
||||
func GetCustomerByID(ctx context.Context, id types.ULID) (*Customer, error) {
|
||||
var customer Customer
|
||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&customer)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &customer, nil
|
||||
}
|
||||
|
||||
// GetAllCustomers returns all customers
|
||||
func GetAllCustomers(ctx context.Context) ([]Customer, error) {
|
||||
var customers []Customer
|
||||
result := db.GetEngine(ctx).Find(&customers)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return customers, nil
|
||||
}
|
||||
|
||||
// CreateCustomer creates a new customer
|
||||
func CreateCustomer(ctx context.Context, create CustomerCreate) (*Customer, error) {
|
||||
customer := Customer{
|
||||
Name: create.Name,
|
||||
CompanyID: create.CompanyID,
|
||||
}
|
||||
|
||||
result := db.GetEngine(ctx).Create(&customer)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return &customer, nil
|
||||
}
|
||||
|
||||
// UpdateCustomer updates an existing customer
|
||||
func UpdateCustomer(ctx context.Context, update CustomerUpdate) (*Customer, error) {
|
||||
customer, err := GetCustomerByID(ctx, update.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if customer == nil {
|
||||
return nil, errors.New("customer not found")
|
||||
}
|
||||
|
||||
// Use generic update function
|
||||
if err := UpdateModel(ctx, customer, update); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load updated data from the database
|
||||
return GetCustomerByID(ctx, update.ID)
|
||||
}
|
||||
|
||||
// DeleteCustomer deletes a customer by its ID
|
||||
func DeleteCustomer(ctx context.Context, id types.ULID) error {
|
||||
result := db.GetEngine(ctx).Delete(&Customer{}, id)
|
||||
return result.Error
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/permissions"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// MigrateDB performs database migrations for all models
|
||||
func MigrateDB() error {
|
||||
gormDB := db.GetEngine(context.Background())
|
||||
if gormDB == nil {
|
||||
return errors.New("database not initialized")
|
||||
}
|
||||
|
||||
log.Println("Starting database migration...")
|
||||
|
||||
// Add all models that should be migrated here
|
||||
err := gormDB.AutoMigrate(
|
||||
&Company{},
|
||||
&User{},
|
||||
&Customer{},
|
||||
&Project{},
|
||||
&Activity{},
|
||||
&TimeEntry{},
|
||||
&permissions.Role{},
|
||||
&permissions.Policy{},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error migrating database: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Database migration completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGormDB is used for special cases like database creation
|
||||
func GetGormDB(dbConfig config.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 {
|
||||
updateValue := reflect.ValueOf(updates)
|
||||
|
||||
// If updates is a pointer, use the value behind it
|
||||
if updateValue.Kind() == reflect.Ptr {
|
||||
updateValue = updateValue.Elem()
|
||||
}
|
||||
|
||||
// Make sure updates is a struct
|
||||
if updateValue.Kind() != reflect.Struct {
|
||||
return errors.New("updates must be a struct")
|
||||
}
|
||||
|
||||
updateType := updateValue.Type()
|
||||
updateMap := make(map[string]any)
|
||||
|
||||
// Iterate through all fields
|
||||
for i := range updateValue.NumField() {
|
||||
field := updateValue.Field(i)
|
||||
fieldType := updateType.Field(i)
|
||||
|
||||
// Skip unexported fields
|
||||
if !fieldType.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Special case: Skip ID field (use only for updates)
|
||||
if fieldType.Name == "ID" {
|
||||
continue
|
||||
}
|
||||
|
||||
// For pointer types, check if they are not nil
|
||||
if field.Kind() == reflect.Ptr && !field.IsNil() {
|
||||
// Extract field name from GORM tag or use default field name
|
||||
fieldName := fieldType.Name
|
||||
|
||||
if tag, ok := fieldType.Tag.Lookup("gorm"); ok {
|
||||
// Separate tag options
|
||||
options := strings.Split(tag, ";")
|
||||
for _, option := range options {
|
||||
if strings.HasPrefix(option, "column:") {
|
||||
fieldName = strings.TrimPrefix(option, "column:")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use the value behind the pointer
|
||||
updateMap[fieldName] = field.Elem().Interface()
|
||||
}
|
||||
}
|
||||
|
||||
if len(updateMap) == 0 {
|
||||
return nil // Nothing to update
|
||||
}
|
||||
|
||||
return db.GetEngine(ctx).Model(model).Updates(updateMap).Error
|
||||
}
|
||||
|
||||
// InitDB and CloseDB are forwarded to the db package for backward compatibility
|
||||
func InitDB(config config.DatabaseConfig) error {
|
||||
return db.InitDB(config)
|
||||
}
|
||||
|
||||
func CloseDB() error {
|
||||
return db.CloseDB()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrUserAlreadyExists = errors.New("user already exists")
|
||||
var ErrUserNotFound = errors.New("user not found")
|
||||
var ErrActivityNotFound = errors.New("activity not found")
|
||||
var ErrActivityAlreadyExists = errors.New("activity already exists")
|
||||
var ErrInvalidPassword = errors.New("invalid password")
|
||||
var ErrInvalidEmail = errors.New("invalid email")
|
||||
var ErrInvalidUsername = errors.New("invalid username")
|
||||
var ErrInvalidRole = errors.New("invalid role")
|
||||
var ErrInvalidCompanyID = errors.New("invalid company id")
|
||||
var ErrInvalidHourlyRate = errors.New("invalid hourly rate")
|
||||
var ErrInvalidID = errors.New("invalid id")
|
||||
var ErrTimeEntryNotFound = errors.New("time entry not found")
|
||||
var ErrTimeEntryAlreadyExists = errors.New("time entry already exists")
|
||||
var ErrInvalidDuration = errors.New("invalid duration")
|
||||
var ErrInvalidDescription = errors.New("invalid description")
|
||||
var ErrInvalidStartTime = errors.New("invalid start time")
|
||||
var ErrInvalidEndTime = errors.New("invalid end time")
|
||||
var ErrInvalidBillable = errors.New("invalid billable")
|
||||
var ErrInvalidProjectID = errors.New("invalid project id")
|
||||
var ErrProjectNotFound = errors.New("project not found")
|
||||
var ErrProjectAlreadyExists = errors.New("project already exists")
|
||||
var ErrInvalidName = errors.New("invalid name")
|
||||
var ErrInvalidClientID = errors.New("invalid client id")
|
||||
var ErrClientNotFound = errors.New("client not found")
|
||||
var ErrClientAlreadyExists = errors.New("client already exists")
|
||||
var ErrInvalidAddress = errors.New("invalid address")
|
||||
var ErrInvalidPhone = errors.New("invalid phone")
|
||||
@@ -0,0 +1,4 @@
|
||||
package models
|
||||
|
||||
// This file is intentionally left empty.
|
||||
// The JWTConfig struct has been moved to the config package.
|
||||
@@ -0,0 +1,238 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Project represents a project in the system
|
||||
type Project struct {
|
||||
EntityBase
|
||||
Name string `gorm:"column:name;not null"`
|
||||
CustomerID *types.ULID `gorm:"column:customer_id;type:bytea;index"`
|
||||
|
||||
// Relationships (for Eager Loading)
|
||||
Customer *Customer `gorm:"foreignKey:CustomerID"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for GORM
|
||||
func (Project) TableName() string {
|
||||
return "projects"
|
||||
}
|
||||
|
||||
// ProjectCreate contains the fields for creating a new project
|
||||
type ProjectCreate struct {
|
||||
Name string
|
||||
CustomerID *types.ULID
|
||||
}
|
||||
|
||||
// ProjectUpdate contains the updatable fields of a project
|
||||
type ProjectUpdate struct {
|
||||
ID types.ULID `gorm:"-"` // Exclude from updates
|
||||
Name *string `gorm:"column:name"`
|
||||
CustomerID types.Nullable[types.ULID] `gorm:"column:customer_id"`
|
||||
}
|
||||
|
||||
// Validate checks if the Create struct contains valid data
|
||||
func (pc *ProjectCreate) Validate() error {
|
||||
if pc.Name == "" {
|
||||
return errors.New("project name cannot be empty")
|
||||
}
|
||||
// Check for valid CustomerID
|
||||
if pc.CustomerID != nil && pc.CustomerID.Compare(types.ULID{}) == 0 {
|
||||
return errors.New("customerID cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks if the Update struct contains valid data
|
||||
func (pu *ProjectUpdate) Validate() error {
|
||||
if pu.Name != nil && *pu.Name == "" {
|
||||
return errors.New("project name cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProjectByID finds a project by its ID
|
||||
func GetProjectByID(ctx context.Context, id types.ULID) (*Project, error) {
|
||||
var project Project
|
||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&project)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
// GetProjectWithCustomer loads a project with the associated customer information
|
||||
func GetProjectWithCustomer(ctx context.Context, id types.ULID) (*Project, error) {
|
||||
var project Project
|
||||
result := db.GetEngine(ctx).Preload("Customer").Where("id = ?", id).First(&project)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
// GetAllProjects returns all projects
|
||||
func GetAllProjects(ctx context.Context) ([]Project, error) {
|
||||
var projects []Project
|
||||
result := db.GetEngine(ctx).Find(&projects)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
// GetAllProjectsWithCustomers returns all projects with customer information
|
||||
func GetAllProjectsWithCustomers(ctx context.Context) ([]Project, error) {
|
||||
var projects []Project
|
||||
result := db.GetEngine(ctx).Preload("Customer").Find(&projects)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
// GetProjectsByCustomerID returns all projects of a specific customer
|
||||
func GetProjectsByCustomerID(ctx context.Context, customerId types.ULID) ([]Project, error) {
|
||||
var projects []Project
|
||||
result := db.GetEngine(ctx).Where("customer_id = ?", customerId.ULID).Find(&projects)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
// CreateProject creates a new project with validation
|
||||
func CreateProject(ctx context.Context, create ProjectCreate) (*Project, error) {
|
||||
// Validation
|
||||
if err := create.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
}
|
||||
|
||||
// Check if the customer exists
|
||||
if create.CustomerID != nil {
|
||||
customer, err := GetCustomerByID(ctx, *create.CustomerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error checking the customer: %w", err)
|
||||
}
|
||||
if customer == nil {
|
||||
return nil, errors.New("the specified customer does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
project := Project{
|
||||
Name: create.Name,
|
||||
CustomerID: create.CustomerID,
|
||||
}
|
||||
|
||||
result := db.GetEngine(ctx).Create(&project)
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("error creating the project: %w", result.Error)
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
// UpdateProject updates an existing project with validation
|
||||
func UpdateProject(ctx context.Context, update ProjectUpdate) (*Project, error) {
|
||||
// Validation
|
||||
if err := update.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
}
|
||||
|
||||
project, err := GetProjectByID(ctx, update.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if project == nil {
|
||||
return nil, errors.New("project not found")
|
||||
}
|
||||
|
||||
// If CustomerID is updated, check if the customer exists
|
||||
if update.CustomerID.Valid {
|
||||
if update.CustomerID.Value != nil {
|
||||
customer, err := GetCustomerByID(ctx, *update.CustomerID.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error checking the customer: %w", err)
|
||||
}
|
||||
if customer == nil {
|
||||
return nil, errors.New("the specified customer does not exist")
|
||||
}
|
||||
} else {
|
||||
// If CustomerID is nil, set it to nil in the project
|
||||
project.CustomerID = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Use generic update function
|
||||
if err := UpdateModel(ctx, project, update); err != nil {
|
||||
return nil, fmt.Errorf("error updating the project: %w", err)
|
||||
}
|
||||
|
||||
// Load updated data from the database
|
||||
return GetProjectByID(ctx, update.ID)
|
||||
}
|
||||
|
||||
// DeleteProject deletes a project by its ID
|
||||
func DeleteProject(ctx context.Context, id types.ULID) error {
|
||||
// Here you could check if dependent entities exist
|
||||
result := db.GetEngine(ctx).Delete(&Project{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("error deleting the project: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateProjectWithTransaction creates a project within a transaction
|
||||
func CreateProjectWithTransaction(ctx context.Context, create ProjectCreate) (*Project, error) {
|
||||
// Validation
|
||||
if err := create.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
}
|
||||
|
||||
var project *Project
|
||||
|
||||
// Start transaction
|
||||
err := db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Customer check within the transaction
|
||||
var customer Customer
|
||||
if err := tx.Where("id = ?", create.CustomerID).First(&customer).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("the specified customer does not exist")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Create project
|
||||
newProject := Project{
|
||||
Name: create.Name,
|
||||
CustomerID: create.CustomerID,
|
||||
}
|
||||
|
||||
if err := tx.Create(&newProject).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Save project for return
|
||||
project = &newProject
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("transaction error: %w", err)
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TimeEntry represents a time entry in the system
|
||||
type TimeEntry struct {
|
||||
EntityBase
|
||||
UserID types.ULID `gorm:"column:user_id;type:bytea;not null;index"`
|
||||
ProjectID types.ULID `gorm:"column:project_id;type:bytea;not null;index"`
|
||||
ActivityID types.ULID `gorm:"column:activity_id;type:bytea;not null;index"`
|
||||
Start time.Time `gorm:"column:start;not null"`
|
||||
End time.Time `gorm:"column:end;not null"`
|
||||
Description string `gorm:"column:description"`
|
||||
Billable int `gorm:"column:billable"` // Percentage (0-100)
|
||||
|
||||
// Relationships for Eager Loading
|
||||
User *User `gorm:"foreignKey:UserID"`
|
||||
Project *Project `gorm:"foreignKey:ProjectID"`
|
||||
Activity *Activity `gorm:"foreignKey:ActivityID"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for GORM
|
||||
func (TimeEntry) TableName() string {
|
||||
return "time_entries"
|
||||
}
|
||||
|
||||
// TimeEntryCreate contains the fields for creating a new time entry
|
||||
type TimeEntryCreate struct {
|
||||
UserID types.ULID
|
||||
ProjectID types.ULID
|
||||
ActivityID types.ULID
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Description string
|
||||
Billable int // Percentage (0-100)
|
||||
}
|
||||
|
||||
// TimeEntryUpdate contains the updatable fields of a time entry
|
||||
type TimeEntryUpdate struct {
|
||||
ID types.ULID `gorm:"-"` // Exclude from updates
|
||||
UserID *types.ULID `gorm:"column:user_id"`
|
||||
ProjectID *types.ULID `gorm:"column:project_id"`
|
||||
ActivityID *types.ULID `gorm:"column:activity_id"`
|
||||
Start *time.Time `gorm:"column:start"`
|
||||
End *time.Time `gorm:"column:end"`
|
||||
Description *string `gorm:"column:description"`
|
||||
Billable *int `gorm:"column:billable"`
|
||||
}
|
||||
|
||||
// Validate checks if the Create struct contains valid data
|
||||
func (tc *TimeEntryCreate) Validate() error {
|
||||
// Check for empty IDs
|
||||
if tc.UserID.Compare(types.ULID{}) == 0 {
|
||||
return errors.New("userID cannot be empty")
|
||||
}
|
||||
if tc.ProjectID.Compare(types.ULID{}) == 0 {
|
||||
return errors.New("projectID cannot be empty")
|
||||
}
|
||||
if tc.ActivityID.Compare(types.ULID{}) == 0 {
|
||||
return errors.New("activityID cannot be empty")
|
||||
}
|
||||
|
||||
// Time checks
|
||||
if tc.Start.IsZero() {
|
||||
return errors.New("start time cannot be empty")
|
||||
}
|
||||
if tc.End.IsZero() {
|
||||
return errors.New("end time cannot be empty")
|
||||
}
|
||||
if tc.End.Before(tc.Start) {
|
||||
return errors.New("end time cannot be before start time")
|
||||
}
|
||||
|
||||
// Billable percentage check
|
||||
if tc.Billable < 0 || tc.Billable > 100 {
|
||||
return errors.New("billable must be between 0 and 100")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks if the Update struct contains valid data
|
||||
func (tu *TimeEntryUpdate) Validate() error {
|
||||
// Billable percentage check
|
||||
if tu.Billable != nil && (*tu.Billable < 0 || *tu.Billable > 100) {
|
||||
return errors.New("billable must be between 0 and 100")
|
||||
}
|
||||
|
||||
// Time checks
|
||||
if tu.Start != nil && tu.End != nil && tu.End.Before(*tu.Start) {
|
||||
return errors.New("end time cannot be before start time")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTimeEntryByID finds a time entry by its ID
|
||||
func GetTimeEntryByID(ctx context.Context, id types.ULID) (*TimeEntry, error) {
|
||||
var timeEntry TimeEntry
|
||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&timeEntry)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &timeEntry, nil
|
||||
}
|
||||
|
||||
// GetTimeEntryWithRelations loads a time entry with all associated data
|
||||
func GetTimeEntryWithRelations(ctx context.Context, id types.ULID) (*TimeEntry, error) {
|
||||
var timeEntry TimeEntry
|
||||
result := db.GetEngine(ctx).
|
||||
Preload("User").
|
||||
Preload("Project").
|
||||
Preload("Project.Customer"). // Nested relationship
|
||||
Preload("Activity").
|
||||
Where("id = ?", id).
|
||||
First(&timeEntry)
|
||||
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &timeEntry, nil
|
||||
}
|
||||
|
||||
// GetAllTimeEntries returns all time entries
|
||||
func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) {
|
||||
var timeEntries []TimeEntry
|
||||
result := db.GetEngine(ctx).Find(&timeEntries)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return timeEntries, nil
|
||||
}
|
||||
|
||||
// GetTimeEntriesByUserID returns all time entries of a user
|
||||
func GetTimeEntriesByUserID(ctx context.Context, userID types.ULID) ([]TimeEntry, error) {
|
||||
var timeEntries []TimeEntry
|
||||
result := db.GetEngine(ctx).Where("user_id = ?", userID).Find(&timeEntries)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return timeEntries, nil
|
||||
}
|
||||
|
||||
// GetTimeEntriesByProjectID returns all time entries of a project
|
||||
func GetTimeEntriesByProjectID(ctx context.Context, projectID types.ULID) ([]TimeEntry, error) {
|
||||
var timeEntries []TimeEntry
|
||||
result := db.GetEngine(ctx).Where("project_id = ?", projectID).Find(&timeEntries)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return timeEntries, nil
|
||||
}
|
||||
|
||||
// GetTimeEntriesByDateRange returns all time entries within a time range
|
||||
func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]TimeEntry, error) {
|
||||
var timeEntries []TimeEntry
|
||||
// Search for overlaps in the time range
|
||||
result := db.GetEngine(ctx).
|
||||
Where("(start BETWEEN ? AND ?) OR (end BETWEEN ? AND ?)",
|
||||
start, end, start, end).
|
||||
Find(&timeEntries)
|
||||
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return timeEntries, nil
|
||||
}
|
||||
|
||||
// SumBillableHoursByProject calculates the billable hours per project
|
||||
func SumBillableHoursByProject(ctx context.Context, projectID types.ULID) (float64, error) {
|
||||
type Result struct {
|
||||
TotalHours float64
|
||||
}
|
||||
|
||||
var result Result
|
||||
|
||||
// SQL calculation of weighted hours
|
||||
err := db.GetEngine(ctx).Raw(`
|
||||
SELECT SUM(
|
||||
EXTRACT(EPOCH FROM (end - start)) / 3600 * (billable / 100.0)
|
||||
) as total_hours
|
||||
FROM time_entries
|
||||
WHERE project_id = ?
|
||||
`, projectID).Scan(&result).Error
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return result.TotalHours, nil
|
||||
}
|
||||
|
||||
// CreateTimeEntry creates a new time entry with validation
|
||||
func CreateTimeEntry(ctx context.Context, create TimeEntryCreate) (*TimeEntry, error) {
|
||||
// Validation
|
||||
if err := create.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
}
|
||||
|
||||
// Start a transaction
|
||||
var timeEntry *TimeEntry
|
||||
|
||||
err := db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Check references
|
||||
if err := validateReferences(tx, create.UserID, create.ProjectID, create.ActivityID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create time entry
|
||||
newTimeEntry := TimeEntry{
|
||||
UserID: create.UserID,
|
||||
ProjectID: create.ProjectID,
|
||||
ActivityID: create.ActivityID,
|
||||
Start: create.Start,
|
||||
End: create.End,
|
||||
Description: create.Description,
|
||||
Billable: create.Billable,
|
||||
}
|
||||
|
||||
if err := tx.Create(&newTimeEntry).Error; err != nil {
|
||||
return fmt.Errorf("error creating the time entry: %w", err)
|
||||
}
|
||||
|
||||
timeEntry = &newTimeEntry
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return timeEntry, nil
|
||||
}
|
||||
|
||||
// validateReferences checks if all referenced entities exist
|
||||
func validateReferences(tx *gorm.DB, userID, projectID, activityID types.ULID) error {
|
||||
// Check user
|
||||
var userCount int64
|
||||
if err := tx.Model(&User{}).Where("id = ?", userID).Count(&userCount).Error; err != nil {
|
||||
return fmt.Errorf("error checking the user: %w", err)
|
||||
}
|
||||
if userCount == 0 {
|
||||
return errors.New("the specified user does not exist")
|
||||
}
|
||||
|
||||
// Check project
|
||||
var projectCount int64
|
||||
if err := tx.Model(&Project{}).Where("id = ?", projectID).Count(&projectCount).Error; err != nil {
|
||||
return fmt.Errorf("error checking the project: %w", err)
|
||||
}
|
||||
if projectCount == 0 {
|
||||
return errors.New("the specified project does not exist")
|
||||
}
|
||||
|
||||
// Check activity
|
||||
var activityCount int64
|
||||
if err := tx.Model(&Activity{}).Where("id = ?", activityID).Count(&activityCount).Error; err != nil {
|
||||
return fmt.Errorf("error checking the activity: %w", err)
|
||||
}
|
||||
if activityCount == 0 {
|
||||
return errors.New("the specified activity does not exist")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateTimeEntry updates an existing time entry with validation
|
||||
func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, error) {
|
||||
// Validation
|
||||
if err := update.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
}
|
||||
|
||||
// Find time entry
|
||||
timeEntry, err := GetTimeEntryByID(ctx, update.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if timeEntry == nil {
|
||||
return nil, errors.New("time entry not found")
|
||||
}
|
||||
|
||||
// Start a transaction for the update
|
||||
err = db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Check references if they are updated
|
||||
if update.UserID != nil || update.ProjectID != nil || update.ActivityID != nil {
|
||||
// Use current values if not updated
|
||||
userID := timeEntry.UserID
|
||||
if update.UserID != nil {
|
||||
userID = *update.UserID
|
||||
}
|
||||
|
||||
projectID := timeEntry.ProjectID
|
||||
if update.ProjectID != nil {
|
||||
projectID = *update.ProjectID
|
||||
}
|
||||
|
||||
activityID := timeEntry.ActivityID
|
||||
if update.ActivityID != nil {
|
||||
activityID = *update.ActivityID
|
||||
}
|
||||
|
||||
if err := validateReferences(tx, userID, projectID, activityID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Check time consistency
|
||||
start := timeEntry.Start
|
||||
if update.Start != nil {
|
||||
start = *update.Start
|
||||
}
|
||||
|
||||
end := timeEntry.End
|
||||
if update.End != nil {
|
||||
end = *update.End
|
||||
}
|
||||
|
||||
if end.Before(start) {
|
||||
return errors.New("end time cannot be before start time")
|
||||
}
|
||||
|
||||
// Use generic update
|
||||
if err := UpdateModel(ctx, timeEntry, update); err != nil {
|
||||
return fmt.Errorf("error updating the time entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load updated data from the database
|
||||
return GetTimeEntryByID(ctx, update.ID)
|
||||
}
|
||||
|
||||
// DeleteTimeEntry deletes a time entry by its ID
|
||||
func DeleteTimeEntry(ctx context.Context, id types.ULID) error {
|
||||
result := db.GetEngine(ctx).Delete(&TimeEntry{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("error deleting the time entry: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"slices"
|
||||
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
EntityBase
|
||||
Email string `gorm:"column:email;unique;not null"`
|
||||
Salt string `gorm:"column:salt;not null;type:varchar(64)"` // Base64-encoded Salt
|
||||
Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Base64-encoded Hash
|
||||
Role string `gorm:"column:role;not null;default:'user'"`
|
||||
CompanyID *types.ULID `gorm:"column:company_id;type:bytea;index"`
|
||||
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
|
||||
Companies []string `gorm:"type:text[]"`
|
||||
|
||||
// Relationship for Eager Loading
|
||||
Company *Company `gorm:"foreignKey:CompanyID"`
|
||||
}
|
||||
|
||||
// TableName provides the table name for GORM
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
|
||||
// UserCreate contains the fields for creating a new user
|
||||
type UserCreate struct {
|
||||
Email string
|
||||
Password string
|
||||
Role string
|
||||
CompanyID *types.ULID
|
||||
HourlyRate float64
|
||||
}
|
||||
|
||||
// UserUpdate contains the updatable fields of a user
|
||||
type UserUpdate struct {
|
||||
ID types.ULID `gorm:"-"` // Exclude from updates
|
||||
Email *string `gorm:"column:email"`
|
||||
Password *string `gorm:"-"` // Not stored directly in DB
|
||||
Role *string `gorm:"column:role"`
|
||||
CompanyID types.Nullable[types.ULID] `gorm:"column:company_id"`
|
||||
HourlyRate *float64 `gorm:"column:hourly_rate"`
|
||||
}
|
||||
|
||||
// PasswordData contains the data for password hash and salt
|
||||
type PasswordData struct {
|
||||
Salt string
|
||||
Hash string
|
||||
}
|
||||
|
||||
// GenerateSalt generates a cryptographically secure salt
|
||||
func GenerateSalt() (string, error) {
|
||||
salt := make([]byte, SaltLength)
|
||||
_, err := rand.Read(salt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(salt), nil
|
||||
}
|
||||
|
||||
// HashPassword creates a secure password hash with Argon2id and a random salt
|
||||
func HashPassword(password string) (PasswordData, error) {
|
||||
// Generate a cryptographically secure salt
|
||||
saltStr, err := GenerateSalt()
|
||||
if err != nil {
|
||||
return PasswordData{}, fmt.Errorf("error generating salt: %w", err)
|
||||
}
|
||||
|
||||
salt, err := base64.StdEncoding.DecodeString(saltStr)
|
||||
if err != nil {
|
||||
return PasswordData{}, fmt.Errorf("error decoding salt: %w", err)
|
||||
}
|
||||
|
||||
// Create hash with Argon2id (modern, secure hash function)
|
||||
hash := argon2.IDKey([]byte(password), salt, ArgonTime, ArgonMemory, ArgonThreads, ArgonKeyLen)
|
||||
hashStr := base64.StdEncoding.EncodeToString(hash)
|
||||
|
||||
return PasswordData{
|
||||
Salt: saltStr,
|
||||
Hash: hashStr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks if a password matches the hash
|
||||
func VerifyPassword(password, saltStr, hashStr string) (bool, error) {
|
||||
salt, err := base64.StdEncoding.DecodeString(saltStr)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error decoding salt: %w", err)
|
||||
}
|
||||
|
||||
hash, err := base64.StdEncoding.DecodeString(hashStr)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error decoding hash: %w", err)
|
||||
}
|
||||
|
||||
// Calculate hash with the same salt
|
||||
computedHash := argon2.IDKey([]byte(password), salt, ArgonTime, ArgonMemory, ArgonThreads, ArgonKeyLen)
|
||||
|
||||
// Constant time comparison to prevent timing attacks
|
||||
return hmacEqual(hash, computedHash), nil
|
||||
}
|
||||
|
||||
// hmacEqual performs a constant-time comparison (prevents timing attacks)
|
||||
func hmacEqual(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
var result byte
|
||||
for i := 0; i < len(a); i++ {
|
||||
result |= a[i] ^ b[i]
|
||||
}
|
||||
|
||||
return result == 0
|
||||
}
|
||||
|
||||
// Validate checks if the Create structure contains valid data
|
||||
func (uc *UserCreate) Validate() error {
|
||||
if uc.Email == "" {
|
||||
return errors.New("email cannot be empty")
|
||||
}
|
||||
|
||||
// Check email format
|
||||
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
if !emailRegex.MatchString(uc.Email) {
|
||||
return errors.New("invalid email format")
|
||||
}
|
||||
|
||||
if uc.Password == "" {
|
||||
return errors.New("password cannot be empty")
|
||||
}
|
||||
|
||||
// Check password complexity
|
||||
if len(uc.Password) < 10 {
|
||||
return errors.New("password must be at least 10 characters long")
|
||||
}
|
||||
|
||||
// More complex password validation
|
||||
var (
|
||||
hasUpper = false
|
||||
hasLower = false
|
||||
hasNumber = false
|
||||
hasSpecial = false
|
||||
)
|
||||
|
||||
for _, char := range uc.Password {
|
||||
switch {
|
||||
case 'A' <= char && char <= 'Z':
|
||||
hasUpper = true
|
||||
case 'a' <= char && char <= 'z':
|
||||
hasLower = true
|
||||
case '0' <= char && char <= '9':
|
||||
hasNumber = true
|
||||
case char == '!' || char == '@' || char == '#' || char == '$' ||
|
||||
char == '%' || char == '^' || char == '&' || char == '*':
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUpper || !hasLower || !hasNumber || !hasSpecial {
|
||||
return errors.New("password must contain uppercase letters, lowercase letters, numbers, and special characters")
|
||||
}
|
||||
|
||||
// Check role
|
||||
if uc.Role == "" {
|
||||
uc.Role = RoleUser // Set default role
|
||||
} else {
|
||||
validRoles := []string{RoleAdmin, RoleUser, RoleViewer}
|
||||
isValid := slices.Contains(validRoles, uc.Role)
|
||||
if !isValid {
|
||||
return fmt.Errorf("invalid role: %s, allowed are: %s",
|
||||
uc.Role, strings.Join(validRoles, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if uc.CompanyID != nil && uc.CompanyID.Compare(types.ULID{}) == 0 {
|
||||
return errors.New("companyID cannot be empty")
|
||||
}
|
||||
|
||||
if uc.HourlyRate < 0 {
|
||||
return errors.New("hourly rate cannot be negative")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks if the Update structure contains valid data
|
||||
func (uu *UserUpdate) Validate() error {
|
||||
if uu.Email != nil && *uu.Email == "" {
|
||||
return errors.New("email cannot be empty")
|
||||
}
|
||||
|
||||
// Check email format
|
||||
if uu.Email != nil {
|
||||
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
if !emailRegex.MatchString(*uu.Email) {
|
||||
return errors.New("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
if uu.Password != nil {
|
||||
if *uu.Password == "" {
|
||||
return errors.New("password cannot be empty")
|
||||
}
|
||||
|
||||
// Check password complexity
|
||||
if len(*uu.Password) < 10 {
|
||||
return errors.New("password must be at least 10 characters long")
|
||||
}
|
||||
|
||||
// More complex password validation
|
||||
var (
|
||||
hasUpper = false
|
||||
hasLower = false
|
||||
hasNumber = false
|
||||
hasSpecial = false
|
||||
)
|
||||
|
||||
for _, char := range *uu.Password {
|
||||
switch {
|
||||
case 'A' <= char && char <= 'Z':
|
||||
hasUpper = true
|
||||
case 'a' <= char && char <= 'z':
|
||||
hasLower = true
|
||||
case '0' <= char && char <= '9':
|
||||
hasNumber = true
|
||||
case char == '!' || char == '@' || char == '#' || char == '$' ||
|
||||
char == '%' || char == '^' || char == '&' || char == '*':
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUpper || !hasLower || !hasNumber || !hasSpecial {
|
||||
return errors.New("password must contain uppercase letters, lowercase letters, numbers, and special characters")
|
||||
}
|
||||
}
|
||||
|
||||
// Check role
|
||||
if uu.Role != nil {
|
||||
validRoles := []string{RoleAdmin, RoleUser, RoleViewer}
|
||||
isValid := false
|
||||
for _, role := range validRoles {
|
||||
if *uu.Role == role {
|
||||
isValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isValid {
|
||||
return fmt.Errorf("invalid role: %s, allowed are: %s",
|
||||
*uu.Role, strings.Join(validRoles, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if uu.HourlyRate != nil && *uu.HourlyRate < 0 {
|
||||
return errors.New("hourly rate cannot be negative")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByID finds a user by their ID
|
||||
func GetUserByID(ctx context.Context, id types.ULID) (*User, error) {
|
||||
var user User
|
||||
result := db.GetEngine(ctx).Where("id = ?", id).First(&user)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByEmail finds a user by their email
|
||||
func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
var user User
|
||||
result := db.GetEngine(ctx).Where("email = ?", email).First(&user)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserWithCompany loads a user with their company
|
||||
func GetUserWithCompany(ctx context.Context, id types.ULID) (*User, error) {
|
||||
var user User
|
||||
result := db.GetEngine(ctx).Preload("Company").Where("id = ?", id).First(&user)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetAllUsers returns all users
|
||||
func GetAllUsers(ctx context.Context) ([]User, error) {
|
||||
var users []User
|
||||
result := db.GetEngine(ctx).Find(&users)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// getCompanyCondition builds the company condition for queries
|
||||
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
|
||||
// Apply the dynamic company condition
|
||||
condition := getCompanyCondition(&companyID)
|
||||
result := db.GetEngine(ctx).Scopes(condition).Find(&users)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user with validation and secure password hashing
|
||||
func CreateUser(ctx context.Context, create UserCreate) (*User, error) {
|
||||
// Validation
|
||||
if err := create.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
}
|
||||
|
||||
// Start a transaction
|
||||
var user *User
|
||||
|
||||
err := db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Check if email already exists
|
||||
var count int64
|
||||
if err := tx.Model(&User{}).Where("email = ?", create.Email).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("error checking email: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New("email is already in use")
|
||||
}
|
||||
|
||||
if create.CompanyID != nil {
|
||||
// Check if company exists
|
||||
var companyCount int64
|
||||
if err := tx.Model(&Company{}).Where("id = ?", create.CompanyID).Count(&companyCount).Error; err != nil {
|
||||
return fmt.Errorf("error checking company: %w", err)
|
||||
}
|
||||
if companyCount == 0 {
|
||||
return errors.New("the specified company does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password with unique salt
|
||||
pwData, err := HashPassword(create.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error hashing password: %w", err)
|
||||
}
|
||||
|
||||
// Create user with salt and hash stored separately
|
||||
newUser := User{
|
||||
Email: create.Email,
|
||||
Salt: pwData.Salt,
|
||||
Hash: pwData.Hash,
|
||||
Role: create.Role,
|
||||
CompanyID: create.CompanyID,
|
||||
HourlyRate: create.HourlyRate,
|
||||
}
|
||||
|
||||
if err := tx.Create(&newUser).Error; err != nil {
|
||||
return fmt.Errorf("error creating user: %w", err)
|
||||
}
|
||||
|
||||
user = &newUser
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates an existing user
|
||||
func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) {
|
||||
// Validation
|
||||
if err := update.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
}
|
||||
|
||||
// Find user
|
||||
user, err := GetUserByID(ctx, update.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
// Start a transaction for the update
|
||||
err = db.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// If email is updated, check if it's already in use
|
||||
if update.Email != nil && *update.Email != user.Email {
|
||||
var count int64
|
||||
if err := tx.Model(&User{}).Where("email = ? AND id != ?", *update.Email, update.ID).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("error checking email: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New("email is already in use")
|
||||
}
|
||||
}
|
||||
|
||||
// If CompanyID is updated, check if it exists
|
||||
if update.CompanyID.Valid && update.CompanyID.Value != nil {
|
||||
if user.CompanyID == nil || *update.CompanyID.Value != *user.CompanyID {
|
||||
var companyCount int64
|
||||
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("the specified company does not exist")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If password is updated, rehash with new salt
|
||||
if update.Password != nil {
|
||||
pwData, err := HashPassword(*update.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error hashing password: %w", err)
|
||||
}
|
||||
|
||||
// Update salt and hash directly in the model
|
||||
if err := tx.Model(user).Updates(map[string]any{
|
||||
"salt": pwData.Salt,
|
||||
"hash": pwData.Hash,
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("error updating password: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create map for generic update
|
||||
updates := make(map[string]any)
|
||||
|
||||
// Add only non-password fields to the update
|
||||
if update.Email != nil {
|
||||
updates["email"] = *update.Email
|
||||
}
|
||||
if update.Role != nil {
|
||||
updates["role"] = *update.Role
|
||||
}
|
||||
if update.CompanyID.Valid {
|
||||
if update.CompanyID.Value == nil {
|
||||
updates["company_id"] = nil
|
||||
} else {
|
||||
updates["company_id"] = *update.CompanyID.Value
|
||||
}
|
||||
}
|
||||
if update.HourlyRate != nil {
|
||||
updates["hourly_rate"] = *update.HourlyRate
|
||||
}
|
||||
|
||||
// Only execute generic update if there are changes
|
||||
if len(updates) > 0 {
|
||||
if err := tx.Model(user).Updates(updates).Error; err != nil {
|
||||
return fmt.Errorf("error updating user: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load updated data from the database
|
||||
return GetUserByID(ctx, update.ID)
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user by their ID
|
||||
func DeleteUser(ctx context.Context, id types.ULID) error {
|
||||
// Here one could check if dependent entities exist
|
||||
// e.g., don't delete if time entries still exist
|
||||
|
||||
result := db.GetEngine(ctx).Delete(&User{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("error deleting user: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthenticateUser authenticates a user with email and password
|
||||
func AuthenticateUser(ctx context.Context, email, password string) (*User, error) {
|
||||
user, err := GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
// Same error message to avoid revealing information about existing accounts
|
||||
return nil, errors.New("invalid login credentials")
|
||||
}
|
||||
|
||||
// Verify password with the stored salt
|
||||
isValid, err := VerifyPassword(password, user.Salt, user.Hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error verifying password: %w", err)
|
||||
}
|
||||
|
||||
if !isValid {
|
||||
return nil, errors.New("invalid login credentials")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Argon2 Parameters
|
||||
const (
|
||||
// Recommended values for Argon2id
|
||||
ArgonTime = 1
|
||||
ArgonMemory = 64 * 1024 // 64MB
|
||||
ArgonThreads = 4
|
||||
ArgonKeyLen = 32
|
||||
SaltLength = 16
|
||||
)
|
||||
|
||||
// Role Constants
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleUser = "user"
|
||||
RoleViewer = "viewer"
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
func (u *User) EffectivePermissions(ctx context.Context, scope string) (Permission, error) {
|
||||
if u.ActiveRole == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Load the role and its associated policies using the helper function.
|
||||
role, err := LoadRoleWithPolicies(ctx, u.ActiveRole.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var perm Permission
|
||||
for _, policy := range role.Policies {
|
||||
for pat, p := range policy.Scopes {
|
||||
if MatchScope(pat, scope) {
|
||||
perm |= p
|
||||
}
|
||||
}
|
||||
}
|
||||
return perm, nil
|
||||
}
|
||||
|
||||
func (u *User) HasPermission(ctx context.Context, scope string, requiredPerm Permission) (bool, error) {
|
||||
effective, err := u.EffectivePermissions(ctx, scope)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return (effective & requiredPerm) == requiredPerm, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/db"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LoadRoleWithPolicies loads a role with its associated policies from the database.
|
||||
func LoadRoleWithPolicies(ctx context.Context, roleID ulid.ULID) (*Role, error) {
|
||||
var role Role
|
||||
err := db.GetEngine(ctx).Preload("Policies").First(&role, "id = ?", roleID).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("role with ID %s not found", roleID)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to load role: %w", err)
|
||||
}
|
||||
return &role, nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package permissions
|
||||
|
||||
import "strings"
|
||||
|
||||
func MatchScope(pattern, scope string) bool {
|
||||
if strings.HasSuffix(pattern, "/*") {
|
||||
prefix := strings.TrimSuffix(pattern, "/*")
|
||||
return strings.HasPrefix(scope, prefix)
|
||||
}
|
||||
return pattern == scope
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package permissions
|
||||
|
||||
type Permission uint64
|
||||
|
||||
const (
|
||||
PermRead Permission = 1 << iota // 1
|
||||
PermWrite // 2
|
||||
PermCreate // 4
|
||||
PermList // 8
|
||||
PermDelete // 16
|
||||
PermModerate // 32
|
||||
PermSuperadmin // 64
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
ID ulid.ULID `gorm:"primaryKey;type:bytea"`
|
||||
Name string `gorm:"not null"`
|
||||
RoleID ulid.ULID `gorm:"type:bytea"` //Fremdschlüssel
|
||||
Scopes Scopes `gorm:"type:jsonb;not null"` // JSONB-Spalte
|
||||
}
|
||||
|
||||
// Scopes type to handle JSON marshalling
|
||||
type Scopes map[string]Permission
|
||||
|
||||
// Scan scan value into Jsonb, implements sql.Scanner interface
|
||||
func (j *Scopes) Scan(value interface{}) error {
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value))
|
||||
}
|
||||
|
||||
var scopes map[string]Permission
|
||||
if err := json.Unmarshal(bytes, &scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
*j = scopes
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value return json value, implement driver.Valuer interface
|
||||
func (j Scopes) Value() (driver.Value, error) {
|
||||
return json.Marshal(j)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
type Role struct {
|
||||
ID ulid.ULID `gorm:"primaryKey;type:bytea"`
|
||||
Name string `gorm:"unique;not null"`
|
||||
Policies []Policy `gorm:"foreignKey:RoleID"`
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ActiveRole *Role `gorm:"foreignKey:UserID"` // Beziehung zur aktiven Rolle
|
||||
UserID ulid.ULID `gorm:"type:bytea"` //Fremdschlüssel
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "YOUR_POSTMAN_ID",
|
||||
"name": "Activity API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /api/activities",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/activities",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"activities"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/activities/:id",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/activities/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"activities",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/activities",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\",\n\t\"billingRate\": 0\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/activities",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"activities"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "PUT /api/activities/:id",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\",\n\t\"billingRate\": 0\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/activities/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"activities",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "DELETE /api/activities/:id",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/activities/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"activities",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "YOUR_POSTMAN_ID",
|
||||
"name": "Company API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /api/companies",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/companies",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"companies"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/companies/:id",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/companies/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"companies",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/companies",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/companies",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"companies"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "PUT /api/companies/:id",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/companies/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"companies",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "DELETE /api/companies/:id",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/companies/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"companies",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "YOUR_POSTMAN_ID",
|
||||
"name": "Customer API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /api/customers",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/customers",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"customers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/customers/:id",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/customers/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"customers",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/customers/company/:companyId",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/customers/company/:companyId",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"customers",
|
||||
"company",
|
||||
":companyId"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "companyId",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/customers",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\",\n\t\"companyId\": \"\",\n\t\"ownerUserID\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/customers",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"customers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "PUT /api/customers/:id",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\",\n\t\"companyId\": \"\",\n\t\"ownerUserID\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/customers/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"customers",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "DELETE /api/customers/:id",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/customers/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"customers",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "YOUR_POSTMAN_ID",
|
||||
"name": "Project API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /api/projects",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/projects/with-customers",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects/with-customers",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects",
|
||||
"with-customers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/projects/:id",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/projects/customer/:customerId",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects/customer/:customerId",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects",
|
||||
"customer",
|
||||
":customerId"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "customerId",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/projects",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\",\n\t\"customerId\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "PUT /api/projects/:id",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"name\": \"\",\n\t\"customerId\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "DELETE /api/projects/:id",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/projects/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"projects",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "YOUR_POSTMAN_ID",
|
||||
"name": "TimeEntry API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /api/time-entries",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/time-entries/me",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/me",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
"me"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/time-entries/range",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/range?start=2023-01-01T00:00:00Z&end=2023-01-02T00:00:00Z",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
"range"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "start",
|
||||
"value": "2023-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"key": "end",
|
||||
"value": "2023-01-02T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/time-entries/:id",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/time-entries/user/:userId",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/user/:userId",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
"user",
|
||||
":userId"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "userId",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/time-entries/project/:projectId",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/project/:projectId",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
"project",
|
||||
":projectId"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "projectId",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/time-entries",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"userID\": \"\",\n \"projectID\": \"\",\n \"activityID\": \"\",\n \"start\": \"2023-01-01T00:00:00Z\",\n \"end\": \"2023-01-01T01:00:00Z\",\n \"description\": \"\",\n \"billable\": true\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "PUT /api/time-entries/:id",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"userID\": \"\",\n \"projectID\": \"\",\n \"activityID\": \"\",\n \"start\": \"2023-01-01T00:00:00Z\",\n \"end\": \"2023-01-01T01:00:00Z\",\n \"description\": \"\",\n \"billable\": true\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "DELETE /api/time-entries/:id",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/time-entries/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"time-entries",
|
||||
":id"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "YOUR_POSTMAN_ID",
|
||||
"name": "User API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Auth",
|
||||
"item": [
|
||||
{
|
||||
"name": "POST /api/auth/login",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"email\": \"\",\n\t\"password\": \"\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/auth/login",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"auth",
|
||||
"login"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/auth/register",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"email\": \"\",\n\t\"password\": \"\",\n\t\"role\": \"user\",\n\t\"companyID\": \"\",\n\t\"hourlyRate\": 0\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/auth/register",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"auth",
|
||||
"register"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/auth/me",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/auth/me",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"auth",
|
||||
"me"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Users",
|
||||
"item": [
|
||||
{
|
||||
"name": "GET /api/users",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/users",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"users"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "GET /api/users/:id",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/users/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"users",
|
||||
":id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "POST /api/users",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"email\": \"\",\n\t\"password\": \"\",\n\t\"role\": \"user\",\n\t\"companyID\": \"\",\n\t\"hourlyRate\": 0\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/users",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"users"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "PUT /api/users/:id",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"email\": \"\",\n\t\"password\": \"\",\n\t\"role\": \"user\",\n\t\"companyID\": \"\",\n\t\"hourlyRate\": 0\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/users/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"users",
|
||||
":id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "DELETE /api/users/:id",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{JWT_TOKEN}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/api/users/:id",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"users",
|
||||
":id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user