Compare commits
54 Commits
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 | |||
f567d086ec | |||
17cb4505be | |||
4dda83904a | |||
115f2667f6 | |||
86f4c757e3 | |||
b2328b4e0c | |||
837cd55a33 | |||
9749d5658c | |||
0402b8ac65 | |||
56a6f3cfc4 | |||
98d21724ee | |||
2f469c1830 | |||
609bc904ea |
48
.clinerules
Normal file
48
.clinerules
Normal file
@ -0,0 +1,48 @@
|
||||
# TimeTracker Project Rules (v2)
|
||||
0. GENERAL
|
||||
DONT OVERENGINEER.
|
||||
USE IN LINE REPLACEMENTS IF POSSIBLE.
|
||||
SOLVE TASKS AS FAST AS POSSIBLE. EACH REQUEST COSTS THE USER MONEY.
|
||||
1. ARCHITECTURE
|
||||
- Multi-tenancy enforced via company_id in all DB queries
|
||||
2. CODING PRACTICES
|
||||
- Type safety enforced (Go 1.21+ generics, TypeScript strict mode)
|
||||
- Domain types must match across backend (Go) and frontend (TypeScript)
|
||||
- All database access through repository interfaces
|
||||
- API handlers must use DTOs for input/output
|
||||
- Use tygo to generate TypeScript types after modifying Go types
|
||||
3. SECURITY
|
||||
- JWT authentication required for all API endpoints
|
||||
- RBAC implemented in middleware/auth.go
|
||||
- Input validation using github.com/go-playground/validator
|
||||
- No raw SQL - use GORM query builder
|
||||
4. DOCUMENTATION
|
||||
- Architecture decisions recorded in docu/ARCHITECTURE.md
|
||||
- Type relationships documented in docu/domain_types.md
|
||||
5. TESTING
|
||||
- 80%+ test coverage for domain logic
|
||||
- Integration tests for API endpoints
|
||||
- Model tests in backend/cmd/modeltest
|
||||
6. FRONTEND
|
||||
- Next.js App Router pattern required
|
||||
8. DEVELOPMENT WORKFLOW
|
||||
- Makefile commands are only available in the backend folder
|
||||
- Common make commands:
|
||||
- make generate: Run code generation (tygo, swagger, etc.)
|
||||
- make test: Run all tests
|
||||
- make build: Build the application
|
||||
- make run: Start the development server
|
||||
9. CUSTOM RULES
|
||||
- Add custom rules to .clinerules if:
|
||||
- Unexpected behavior is encountered
|
||||
- Specific conditions require warnings
|
||||
- New patterns emerge that need documentation
|
||||
- DO NOT FIX UNUSED IMPORTS - this is the job of the linter
|
||||
10.Implement a REST API update handling in Go using Gin that ensures the following behavior:
|
||||
- The update request is received as JSON.
|
||||
- If a field is present in the JSON and set to null, the corresponding value in the database should be removed.
|
||||
- If a field is missing in the JSON, it should not be modified.
|
||||
- If a field is present in the JSON and not null, it should be updated.
|
||||
- Use either a struct or a map to handle the JSON data.
|
||||
- Ensure the update logic is robust and does not unintentionally remove or overwrite fields.
|
||||
- Optional: Handle error cases like invalid JSON and return appropriate HTTP status codes.
|
19
.gitea/workflows/demo.yaml
Normal file
19
.gitea/workflows/demo.yaml
Normal file
@ -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 }}."
|
14
backend/.env
Normal file
14
backend/.env
Normal file
@ -0,0 +1,14 @@
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=timetracker
|
||||
DB_PASSWORD=password
|
||||
DB_NAME=timetracker
|
||||
DB_SSLMODE=disable
|
||||
API_KEY=
|
||||
|
||||
# JWT Configuration
|
||||
#JWT_SECRET=test
|
||||
#JWT_KEY_DIR=keys
|
||||
#JWT_KEY_GENERATE=true
|
||||
JWT_TOKEN_DURATION=24h
|
||||
ENVIRONMENT=production
|
1
backend/.gitignore
vendored
Normal file
1
backend/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
keys
|
104
backend/Makefile
Normal file
104
backend/Makefile
Normal file
@ -0,0 +1,104 @@
|
||||
# Time Tracker Backend Makefile
|
||||
|
||||
.PHONY: db-start db-stop db-test model-test run build clean migrate seed swagger help
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# Variables
|
||||
BINARY_NAME=timetracker
|
||||
DB_CONTAINER=timetracker_db
|
||||
|
||||
# Help target
|
||||
help:
|
||||
@echo "Time Tracker Backend Makefile"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo " make db-start - Start the PostgreSQL database container"
|
||||
@echo " make db-stop - Stop the PostgreSQL database container"
|
||||
@echo " make db-test - Test the database connection"
|
||||
@echo " make model-test - Test the database models"
|
||||
@echo " make run - Run the application"
|
||||
@echo " make build - Build the application"
|
||||
@echo " make clean - Remove build artifacts"
|
||||
@echo " make migrate - Run database migrations"
|
||||
@echo " make seed - Seed the database with initial data"
|
||||
@echo " make db-drop-users - Drop the users table"
|
||||
@echo " make db-reinit - Re-initialize the database"
|
||||
@echo " make swagger - Generate swagger documentation"
|
||||
@echo " make help - Show this help message"
|
||||
@echo "" make generate-ts - Generate TypeScript types
|
||||
|
||||
# Start the database
|
||||
db-start:
|
||||
@echo "Starting PostgreSQL database container..."
|
||||
@cd .. && docker-compose up -d db
|
||||
@echo "Database container started"
|
||||
|
||||
# Stop the database
|
||||
db-stop:
|
||||
@echo "Stopping PostgreSQL database container..."
|
||||
@cd .. && docker-compose stop db
|
||||
@echo "Database container stopped"
|
||||
|
||||
# Test the database connection
|
||||
db-test:
|
||||
@echo "Testing database connection..."
|
||||
@go run cmd/dbtest/main.go
|
||||
|
||||
# Test the database models
|
||||
model-test:
|
||||
@echo "Testing database models..."
|
||||
@go run cmd/modeltest/main.go
|
||||
|
||||
# Run the application
|
||||
run:
|
||||
@echo "Running the application..."
|
||||
@go run cmd/api/main.go
|
||||
|
||||
# Build the application
|
||||
build:
|
||||
@echo "Building the application..."
|
||||
@go build -o $(BINARY_NAME) cmd/api/main.go
|
||||
@echo "Build complete: $(BINARY_NAME)"
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
@rm -f $(BINARY_NAME)
|
||||
@echo "Clean complete"
|
||||
|
||||
# Run database migrations
|
||||
migrate:
|
||||
@echo "Running database migrations..."
|
||||
@go run -mod=mod cmd/migrate/main.go
|
||||
@echo "Migrations complete"
|
||||
|
||||
# Seed the database with initial data
|
||||
seed:
|
||||
@echo "Seeding the database..."
|
||||
@go run -mod=mod cmd/seed/main.go
|
||||
@echo "Seeding complete"
|
||||
|
||||
# Drop the users table
|
||||
db-drop-users:
|
||||
@echo "Dropping the users table..."
|
||||
@export PG_HOST=$(DB_HOST); export PG_PORT=$(DB_PORT); export PG_USER=$(DB_USER); export PG_PASSWORD=$(DB_PASSWORD); export PG_DBNAME=$(DB_NAME); go run cmd/dbtest/main.go -drop_table=users
|
||||
@echo "Users table dropped"
|
||||
|
||||
# Re-initialize the database
|
||||
db-reinit:
|
||||
@echo "Re-initializing the database..."
|
||||
@PG_HOST=$(DB_HOST) PG_PORT=$(DB_PORT) PG_USER=$(DB_USER) PG_PASSWORD=$(DB_PASSWORD) PG_DBNAME=$(DB_NAME) go run cmd/migrate/main.go -create_db -drop_db
|
||||
# Generate swagger documentation
|
||||
swagger:
|
||||
@echo "Generating swagger documentation..."
|
||||
@swag init -g cmd/api/main.go
|
||||
@echo "Swagger documentation generated"
|
||||
|
||||
# Generate TypeScript types
|
||||
generate-ts:
|
||||
@echo "Generating TypeScript types..."
|
||||
@go run scripts/fix_tygo.go
|
||||
@echo "TypeScript types generated"
|
||||
|
147
backend/README.md
Normal file
147
backend/README.md
Normal file
@ -0,0 +1,147 @@
|
||||
# Time Tracker Backend
|
||||
|
||||
This is the backend service for the Time Tracker application, built with Go, Gin, and GORM.
|
||||
|
||||
## Database Setup
|
||||
|
||||
The application uses PostgreSQL as its database. The database connection is configured using GORM, a popular Go ORM library.
|
||||
|
||||
### Configuration
|
||||
|
||||
Database configuration is handled through the `models.DatabaseConfig` struct in `internal/models/db.go`. The application uses sensible defaults that can be overridden with environment variables:
|
||||
|
||||
- `DB_HOST`: Database host (default: "localhost")
|
||||
- `DB_PORT`: Database port (default: 5432)
|
||||
- `DB_USER`: Database user (default: "timetracker")
|
||||
- `DB_PASSWORD`: Database password (default: "password")
|
||||
- `DB_NAME`: Database name (default: "timetracker")
|
||||
- `DB_SSLMODE`: SSL mode (default: "disable")
|
||||
|
||||
### Running with Docker
|
||||
|
||||
The easiest way to run the database is using Docker Compose:
|
||||
|
||||
```bash
|
||||
# Start the database
|
||||
docker-compose up -d db
|
||||
|
||||
# Check if the database is running
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
The application automatically migrates the database schema on startup using GORM's AutoMigrate feature. This creates all necessary tables based on the model definitions.
|
||||
|
||||
### Initial Data Seeding
|
||||
|
||||
The application seeds the database with initial data if it's empty. This includes:
|
||||
|
||||
- A default company
|
||||
- An admin user with email "admin@example.com" and password "Admin@123456"
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Using Make Commands
|
||||
|
||||
The project includes a Makefile with common commands:
|
||||
|
||||
```bash
|
||||
# Start the database
|
||||
make db-start
|
||||
|
||||
# Test the database connection
|
||||
make db-test
|
||||
|
||||
# Run database migrations
|
||||
make migrate
|
||||
|
||||
# Seed the database with initial data
|
||||
make seed
|
||||
|
||||
# Test the database models
|
||||
make model-test
|
||||
|
||||
# Run the application
|
||||
make run
|
||||
|
||||
# Build the application
|
||||
make build
|
||||
|
||||
# Show all available commands
|
||||
make help
|
||||
```
|
||||
|
||||
### Manual Commands
|
||||
|
||||
If you prefer not to use Make, you can run the commands directly:
|
||||
|
||||
```bash
|
||||
# Start the database
|
||||
cd /path/to/timetracker
|
||||
docker-compose up -d db
|
||||
|
||||
# Test the database connection
|
||||
cd backend
|
||||
go run cmd/dbtest/main.go
|
||||
|
||||
# Run database migrations
|
||||
cd backend
|
||||
go run cmd/migrate/main.go
|
||||
|
||||
# Seed the database with initial data
|
||||
cd backend
|
||||
go run cmd/seed/main.go
|
||||
|
||||
# Run the backend application
|
||||
cd backend
|
||||
go run cmd/api/main.go
|
||||
```
|
||||
|
||||
The API will be available at http://localhost:8080/api and the Swagger documentation at http://localhost:8080/swagger/index.html.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can configure the database connection using environment variables:
|
||||
|
||||
```bash
|
||||
# Example: Connect to a different database
|
||||
DB_HOST=my-postgres-server DB_PORT=5432 DB_USER=myuser DB_PASSWORD=mypassword DB_NAME=mydb go run cmd/api/main.go
|
||||
```
|
||||
|
||||
## Database Models
|
||||
|
||||
The application uses the following models:
|
||||
|
||||
- `User`: Represents a user in the system
|
||||
- `Company`: Represents a company
|
||||
- `Customer`: Represents a customer
|
||||
- `Project`: Represents a project
|
||||
- `Activity`: Represents an activity
|
||||
- `TimeEntry`: Represents a time entry
|
||||
|
||||
Each model has corresponding CRUD operations and relationships defined in the `internal/models` directory.
|
||||
|
||||
## GORM Best Practices
|
||||
|
||||
The application follows these GORM best practices:
|
||||
|
||||
1. **Connection Pooling**: Configured with sensible defaults for maximum idle connections, maximum open connections, and connection lifetime.
|
||||
|
||||
2. **Migrations**: Uses GORM's AutoMigrate to automatically create and update database tables.
|
||||
|
||||
3. **Transactions**: Uses transactions for operations that require multiple database changes to ensure data consistency.
|
||||
|
||||
4. **Soft Deletes**: Uses GORM's soft delete feature to mark records as deleted without actually removing them from the database.
|
||||
|
||||
5. **Relationships**: Properly defines relationships between models using GORM's relationship features.
|
||||
|
||||
6. **Error Handling**: Properly handles database errors and returns appropriate error messages.
|
||||
|
||||
7. **Context Support**: Uses context for database operations to support timeouts and cancellation.
|
||||
|
||||
8. **Logging**: Configures GORM's logger for appropriate logging based on the environment.
|
||||
|
||||
9. **Graceful Shutdown**: Properly closes database connections when the application shuts down.
|
||||
|
||||
10. **Validation**: Implements validation for model fields before saving to the database.
|
111
backend/cmd/api/main.go
Normal file
111
backend/cmd/api/main.go
Normal file
@ -0,0 +1,111 @@
|
||||
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"
|
||||
"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"
|
||||
)
|
||||
|
||||
// @title Time Tracker API
|
||||
// @version 1.0
|
||||
// @description This is a simple time tracker API.
|
||||
// @host localhost:8080
|
||||
// @BasePath /api
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
|
||||
// @Summary Say hello
|
||||
// @Description Get a hello message
|
||||
// @ID hello
|
||||
// @Produce plain
|
||||
// @Success 200 {string} string "Hello from the Time Tracker Backend!"
|
||||
// @Router / [get]
|
||||
func helloHandler(c *gin.Context) {
|
||||
c.String(http.StatusOK, "Hello from the Time Tracker Backend!")
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := config.LoadConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
if err := models.InitDB(cfg.Database); err != nil {
|
||||
log.Fatalf("Error initializing database: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := models.CloseDB(); err != nil {
|
||||
log.Printf("Error closing database connection: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 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()
|
||||
|
||||
// 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))
|
||||
|
||||
// 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")
|
||||
}
|
102
backend/cmd/dbtest/main.go
Normal file
102
backend/cmd/dbtest/main.go
Normal file
@ -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!")
|
||||
}
|
110
backend/cmd/migrate/main.go
Normal file
110
backend/cmd/migrate/main.go
Normal file
@ -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")
|
||||
}
|
210
backend/cmd/modeltest/main.go
Normal file
210
backend/cmd/modeltest/main.go
Normal file
@ -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")
|
||||
}
|
93
backend/cmd/seed/main.go
Normal file
93
backend/cmd/seed/main.go
Normal file
@ -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
|
||||
})
|
||||
}
|
4694
backend/docs/docs.go
Normal file
4694
backend/docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
4670
backend/docs/swagger.json
Normal file
4670
backend/docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
2615
backend/docs/swagger.yaml
Normal file
2615
backend/docs/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
61
backend/go.mod
Normal file
61
backend/go.mod
Normal file
@ -0,0 +1,61 @@
|
||||
module github.com/timetracker/backend
|
||||
|
||||
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
|
||||
github.com/swaggo/swag v1.16.4
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
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
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/bytedance/sonic v1.13.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.25.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
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
|
||||
golang.org/x/net v0.37.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
)
|
169
backend/go.sum
Normal file
169
backend/go.sum
Normal file
@ -0,0 +1,169 @@
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/bytedance/sonic v1.13.1 h1:Jyd5CIvdFnkOWuKXr+wm4Nyk2h0yAFsr8ucJgEasO3g=
|
||||
github.com/bytedance/sonic v1.13.1/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
|
||||
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=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
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=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
|
||||
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
|
||||
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
|
||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
28
backend/internal/api/dto/activity_dto.go
Normal file
28
backend/internal/api/dto/activity_dto.go
Normal file
@ -0,0 +1,28 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type ActivityDto struct {
|
||||
ID string `json:"id" example:"a1b2c3d4e5f6"`
|
||||
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
LastEditorID string `json:"lastEditorID" example:"u1v2w3x4y5z6"`
|
||||
Name string `json:"name" example:"Development"`
|
||||
BillingRate float64 `json:"billingRate" example:"100.00"`
|
||||
}
|
||||
|
||||
type ActivityCreateDto struct {
|
||||
Name string `json:"name" example:"Development"`
|
||||
BillingRate float64 `json:"billingRate" example:"100.00"`
|
||||
}
|
||||
|
||||
type ActivityUpdateDto struct {
|
||||
ID string `json:"id" example:"a1b2c3d4e5f6"`
|
||||
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
LastEditorID *string `json:"lastEditorID" example:"u1v2w3x4y5z6"`
|
||||
Name *string `json:"name" example:"Development"`
|
||||
BillingRate *float64 `json:"billingRate" example:"100.00"`
|
||||
}
|
13
backend/internal/api/dto/auth_dto.go
Normal file
13
backend/internal/api/dto/auth_dto.go
Normal file
@ -0,0 +1,13 @@
|
||||
package dto
|
||||
|
||||
// LoginDto represents the login request
|
||||
type LoginDto struct {
|
||||
Email string `json:"email" example:"admin@example.com"`
|
||||
Password string `json:"password" example:"Admin@123456"`
|
||||
}
|
||||
|
||||
// TokenDto represents the response after successful authentication
|
||||
type TokenDto struct {
|
||||
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"`
|
||||
User UserDto `json:"user"`
|
||||
}
|
23
backend/internal/api/dto/company_dto.go
Normal file
23
backend/internal/api/dto/company_dto.go
Normal file
@ -0,0 +1,23 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type CompanyDto struct {
|
||||
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||
Name string `json:"name" example:"Acme Corp"`
|
||||
}
|
||||
|
||||
type CompanyCreateDto struct {
|
||||
Name string `json:"name" example:"Acme Corp"`
|
||||
}
|
||||
|
||||
type CompanyUpdateDto struct {
|
||||
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
Name *string `json:"name" example:"Acme Corp"`
|
||||
}
|
32
backend/internal/api/dto/customer_dto.go
Normal file
32
backend/internal/api/dto/customer_dto.go
Normal file
@ -0,0 +1,32 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
)
|
||||
|
||||
type CustomerDto struct {
|
||||
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||
Name string `json:"name" example:"John Doe"`
|
||||
CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||
OwnerUserID *string `json:"owningUserID" example:"01HGW2BBG0000000000000000"`
|
||||
}
|
||||
|
||||
type CustomerCreateDto struct {
|
||||
Name string `json:"name" example:"John Doe"`
|
||||
CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||
}
|
||||
|
||||
type CustomerUpdateDto struct {
|
||||
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
LastEditorID *string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||
Name *string `json:"name" example:"John Doe"`
|
||||
CompanyID types.Nullable[string] `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||
OwnerUserID types.Nullable[string] `json:"owningUserID" example:"01HGW2BBG0000000000000000"`
|
||||
}
|
28
backend/internal/api/dto/project_dto.go
Normal file
28
backend/internal/api/dto/project_dto.go
Normal file
@ -0,0 +1,28 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
)
|
||||
|
||||
type ProjectDto struct {
|
||||
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||
Name string `json:"name" example:"Time Tracking App"`
|
||||
CustomerID *string `json:"customerId,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"`
|
||||
}
|
41
backend/internal/api/dto/timeentry_dto.go
Normal file
41
backend/internal/api/dto/timeentry_dto.go
Normal file
@ -0,0 +1,41 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type TimeEntryDto struct {
|
||||
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||
UserID string `json:"userId" example:"01HGW2BBG0000000000000000"`
|
||||
ProjectID string `json:"projectId" example:"01HGW2BBG0000000000000000"`
|
||||
ActivityID string `json:"activityId" example:"01HGW2BBG0000000000000000"`
|
||||
Start time.Time `json:"start" example:"2024-01-01T08:00:00Z"`
|
||||
End time.Time `json:"end" example:"2024-01-01T17:00:00Z"`
|
||||
Description string `json:"description" example:"Working on the Time Tracking App"`
|
||||
Billable int `json:"billable" example:"100"` // Percentage (0-100)
|
||||
}
|
||||
|
||||
type TimeEntryCreateDto struct {
|
||||
UserID string `json:"userId" example:"01HGW2BBG0000000000000000"`
|
||||
ProjectID string `json:"projectId" example:"01HGW2BBG0000000000000000"`
|
||||
ActivityID string `json:"activityId" example:"01HGW2BBG0000000000000000"`
|
||||
Start time.Time `json:"start" example:"2024-01-01T08:00:00Z"`
|
||||
End time.Time `json:"end" example:"2024-01-01T17:00:00Z"`
|
||||
Description string `json:"description" example:"Working on the Time Tracking App"`
|
||||
Billable int `json:"billable" example:"100"` // Percentage (0-100)
|
||||
}
|
||||
|
||||
type TimeEntryUpdateDto struct {
|
||||
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
UserID *string `json:"userId" example:"01HGW2BBG0000000000000000"`
|
||||
ProjectID *string `json:"projectId" example:"01HGW2BBG0000000000000000"`
|
||||
ActivityID *string `json:"activityId" example:"01HGW2BBG0000000000000000"`
|
||||
Start *time.Time `json:"start" example:"2024-01-01T08:00:00Z"`
|
||||
End *time.Time `json:"end" example:"2024-01-01T17:00:00Z"`
|
||||
Description *string `json:"description" example:"Working on the Time Tracking App"`
|
||||
Billable *int `json:"billable" example:"100"` // Percentage (0-100)
|
||||
}
|
37
backend/internal/api/dto/user_dto.go
Normal file
37
backend/internal/api/dto/user_dto.go
Normal file
@ -0,0 +1,37 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
)
|
||||
|
||||
type UserDto struct {
|
||||
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||
Email string `json:"email" example:"test@example.com"`
|
||||
Role string `json:"role" example:"admin"`
|
||||
CompanyID *string `json:"companyId,omitempty" example:"01HGW2BBG0000000000000000"`
|
||||
HourlyRate float64 `json:"hourlyRate" example:"50.00"`
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
Email string `json:"email" example:"test@example.com"`
|
||||
Password string `json:"password" example:"password123"`
|
||||
Role string `json:"role" example:"admin"`
|
||||
CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||
HourlyRate float64 `json:"hourlyRate" example:"50.00"`
|
||||
}
|
||||
|
||||
type UserUpdateDto struct {
|
||||
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
LastEditorID *string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||
Email *string `json:"email" example:"test@example.com"`
|
||||
Password *string `json:"password" example:"password123"`
|
||||
Role *string `json:"role" example:"admin"`
|
||||
CompanyID types.Nullable[string] `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||
HourlyRate *float64 `json:"hourlyRate" example:"50.00"`
|
||||
}
|
180
backend/internal/api/handlers/activity_handler.go
Normal file
180
backend/internal/api/handlers/activity_handler.go
Normal file
@ -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)
|
||||
}
|
169
backend/internal/api/handlers/company_handler.go
Normal file
169
backend/internal/api/handlers/company_handler.go
Normal file
@ -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)
|
||||
}
|
278
backend/internal/api/handlers/customer_handler.go
Normal file
278
backend/internal/api/handlers/customer_handler.go
Normal file
@ -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)
|
||||
}
|
238
backend/internal/api/handlers/project_handler.go
Normal file
238
backend/internal/api/handlers/project_handler.go
Normal file
@ -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)
|
||||
}
|
316
backend/internal/api/handlers/timeentry_handler.go
Normal file
316
backend/internal/api/handlers/timeentry_handler.go
Normal file
@ -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)
|
||||
}
|
359
backend/internal/api/handlers/user_handler.go
Normal file
359
backend/internal/api/handlers/user_handler.go
Normal file
@ -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)
|
||||
}
|
35
backend/internal/api/middleware/api_key_middleware.go
Normal file
35
backend/internal/api/middleware/api_key_middleware.go
Normal file
@ -0,0 +1,35 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/timetracker/backend/internal/api/responses"
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
)
|
||||
|
||||
// APIKeyMiddleware checks for a valid API key if configured
|
||||
func APIKeyMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Skip if no API key is configured
|
||||
if cfg.APIKey == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Get API key from header
|
||||
apiKey := c.GetHeader("X-API-Key")
|
||||
if apiKey == "" {
|
||||
responses.UnauthorizedResponse(c, "API key is required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
if apiKey != cfg.APIKey {
|
||||
responses.UnauthorizedResponse(c, "Invalid API key")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
364
backend/internal/api/middleware/jwt_auth.go
Normal file
364
backend/internal/api/middleware/jwt_auth.go
Normal file
@ -0,0 +1,364 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/timetracker/backend/internal/api/responses"
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
"github.com/timetracker/backend/internal/models"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
signKey *rsa.PrivateKey
|
||||
verifyKey *rsa.PublicKey
|
||||
)
|
||||
|
||||
// InitJWTKeys initializes the JWT keys
|
||||
func InitJWTKeys() error {
|
||||
cfg := config.MustLoadConfig()
|
||||
|
||||
// If a secret is provided, we'll use HMAC-SHA256, so no need for certificates
|
||||
if cfg.JWTConfig.Secret != "" {
|
||||
println("Using HMAC-SHA256 for JWT")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if keys exist
|
||||
privKeyPath := filepath.Join(cfg.JWTConfig.KeyDir, cfg.JWTConfig.PrivKeyFile)
|
||||
pubKeyPath := filepath.Join(cfg.JWTConfig.KeyDir, cfg.JWTConfig.PubKeyFile)
|
||||
|
||||
keysExist := fileExists(privKeyPath) && fileExists(pubKeyPath)
|
||||
|
||||
// Generate keys if they don't exist and KeyGenerate is true
|
||||
if !keysExist && cfg.JWTConfig.KeyGenerate {
|
||||
println("Generating RSA keys")
|
||||
if err := generateRSAKeys(cfg.JWTConfig); err != nil {
|
||||
return fmt.Errorf("failed to generate RSA keys: %w", err)
|
||||
}
|
||||
} else if !keysExist {
|
||||
return errors.New("JWT keys not found and key generation is disabled")
|
||||
}
|
||||
|
||||
// Load keys
|
||||
var err error
|
||||
signKey, err = loadPrivateKey(privKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load private key: %w", err)
|
||||
}
|
||||
|
||||
verifyKey, err = loadPublicKey(pubKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load public key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
// generateRSAKeys generates RSA keys and saves them to disk
|
||||
func generateRSAKeys(cfg 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
|
||||
}
|
85
backend/internal/api/responses/response.go
Normal file
85
backend/internal/api/responses/response.go
Normal file
@ -0,0 +1,85 @@
|
||||
package responses
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Response is a standardized API response structure
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error *ErrorInfo `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorInfo contains detailed error information
|
||||
type ErrorInfo struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ErrorResponse codes
|
||||
const (
|
||||
ErrorCodeValidation = "VALIDATION_ERROR"
|
||||
ErrorCodeNotFound = "NOT_FOUND"
|
||||
ErrorCodeUnauthorized = "UNAUTHORIZED"
|
||||
ErrorCodeForbidden = "FORBIDDEN"
|
||||
ErrorCodeInternal = "INTERNAL_ERROR"
|
||||
ErrorCodeBadRequest = "BAD_REQUEST"
|
||||
ErrorCodeConflict = "CONFLICT"
|
||||
)
|
||||
|
||||
// SuccessResponse sends a successful response with data
|
||||
func SuccessResponse(c *gin.Context, statusCode int, data interface{}) {
|
||||
c.JSON(statusCode, Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// ErrorResponse sends an error response
|
||||
func ErrorResponse(c *gin.Context, statusCode int, errorCode string, message string) {
|
||||
c.JSON(statusCode, Response{
|
||||
Success: false,
|
||||
Error: &ErrorInfo{
|
||||
Code: errorCode,
|
||||
Message: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// BadRequestResponse sends a 400 Bad Request response
|
||||
func BadRequestResponse(c *gin.Context, message string) {
|
||||
ErrorResponse(c, http.StatusBadRequest, ErrorCodeBadRequest, message)
|
||||
}
|
||||
|
||||
// ValidationErrorResponse sends a 400 Bad Request response for validation errors
|
||||
func ValidationErrorResponse(c *gin.Context, message string) {
|
||||
ErrorResponse(c, http.StatusBadRequest, ErrorCodeValidation, message)
|
||||
}
|
||||
|
||||
// NotFoundResponse sends a 404 Not Found response
|
||||
func NotFoundResponse(c *gin.Context, message string) {
|
||||
ErrorResponse(c, http.StatusNotFound, ErrorCodeNotFound, message)
|
||||
}
|
||||
|
||||
// UnauthorizedResponse sends a 401 Unauthorized response
|
||||
func UnauthorizedResponse(c *gin.Context, message string) {
|
||||
ErrorResponse(c, http.StatusUnauthorized, ErrorCodeUnauthorized, message)
|
||||
}
|
||||
|
||||
// ForbiddenResponse sends a 403 Forbidden response
|
||||
func ForbiddenResponse(c *gin.Context, message string) {
|
||||
ErrorResponse(c, http.StatusForbidden, ErrorCodeForbidden, message)
|
||||
}
|
||||
|
||||
// InternalErrorResponse sends a 500 Internal Server Error response
|
||||
func InternalErrorResponse(c *gin.Context, message string) {
|
||||
ErrorResponse(c, http.StatusInternalServerError, ErrorCodeInternal, message)
|
||||
}
|
||||
|
||||
// ConflictResponse sends a 409 Conflict response
|
||||
func ConflictResponse(c *gin.Context, message string) {
|
||||
ErrorResponse(c, http.StatusConflict, ErrorCodeConflict, message)
|
||||
}
|
110
backend/internal/api/routes/router.go
Normal file
110
backend/internal/api/routes/router.go
Normal file
@ -0,0 +1,110 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/timetracker/backend/internal/api/handlers"
|
||||
"github.com/timetracker/backend/internal/api/middleware"
|
||||
"github.com/timetracker/backend/internal/config"
|
||||
)
|
||||
|
||||
// SetupRouter configures all the routes for the API
|
||||
func SetupRouter(r *gin.Engine, cfg *config.Config) {
|
||||
// Create handlers
|
||||
// Apply API key middleware to all API routes
|
||||
r.Use(middleware.APIKeyMiddleware(cfg))
|
||||
userHandler := handlers.NewUserHandler()
|
||||
activityHandler := handlers.NewActivityHandler()
|
||||
companyHandler := handlers.NewCompanyHandler()
|
||||
customerHandler := handlers.NewCustomerHandler()
|
||||
projectHandler := handlers.NewProjectHandler()
|
||||
timeEntryHandler := handlers.NewTimeEntryHandler()
|
||||
|
||||
// API routes
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// Auth routes (public)
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/login", userHandler.Login)
|
||||
auth.POST("/register", userHandler.Register)
|
||||
}
|
||||
|
||||
// Protected routes
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
// Auth routes (protected)
|
||||
protectedAuth := protected.Group("/auth")
|
||||
{
|
||||
protectedAuth.GET("/me", userHandler.GetCurrentUser)
|
||||
}
|
||||
|
||||
// User routes
|
||||
users := protected.Group("/users")
|
||||
{
|
||||
users.GET("", userHandler.GetUsers)
|
||||
users.GET("/:id", userHandler.GetUserByID)
|
||||
users.POST("", middleware.RoleMiddleware("admin"), userHandler.CreateUser)
|
||||
users.PUT("/:id", middleware.RoleMiddleware("admin"), userHandler.UpdateUser)
|
||||
users.DELETE("/:id", middleware.RoleMiddleware("admin"), userHandler.DeleteUser)
|
||||
}
|
||||
|
||||
// Activity routes
|
||||
activities := protected.Group("/activities")
|
||||
{
|
||||
activities.GET("", activityHandler.GetActivities)
|
||||
activities.GET("/:id", activityHandler.GetActivityByID)
|
||||
activities.POST("", middleware.RoleMiddleware("admin"), activityHandler.CreateActivity)
|
||||
activities.PUT("/:id", middleware.RoleMiddleware("admin"), activityHandler.UpdateActivity)
|
||||
activities.DELETE("/:id", middleware.RoleMiddleware("admin"), activityHandler.DeleteActivity)
|
||||
}
|
||||
|
||||
// Company routes
|
||||
companies := protected.Group("/companies")
|
||||
{
|
||||
companies.GET("", companyHandler.GetCompanies)
|
||||
companies.GET("/:id", companyHandler.GetCompanyByID)
|
||||
companies.POST("", middleware.RoleMiddleware("admin"), companyHandler.CreateCompany)
|
||||
companies.PUT("/:id", middleware.RoleMiddleware("admin"), companyHandler.UpdateCompany)
|
||||
companies.DELETE("/:id", middleware.RoleMiddleware("admin"), companyHandler.DeleteCompany)
|
||||
}
|
||||
|
||||
// Customer routes
|
||||
customers := protected.Group("/customers")
|
||||
{
|
||||
customers.GET("", customerHandler.GetCustomers)
|
||||
customers.GET("/:id", customerHandler.GetCustomerByID)
|
||||
customers.GET("/company/:companyId", customerHandler.GetCustomersByCompanyID)
|
||||
customers.POST("", middleware.RoleMiddleware("admin"), customerHandler.CreateCustomer)
|
||||
customers.PUT("/:id", middleware.RoleMiddleware("admin"), customerHandler.UpdateCustomer)
|
||||
customers.DELETE("/:id", middleware.RoleMiddleware("admin"), customerHandler.DeleteCustomer)
|
||||
}
|
||||
|
||||
// Project routes
|
||||
projects := protected.Group("/projects")
|
||||
{
|
||||
projects.GET("", projectHandler.GetProjects)
|
||||
projects.GET("/with-customers", projectHandler.GetProjectsWithCustomers)
|
||||
projects.GET("/:id", projectHandler.GetProjectByID)
|
||||
projects.GET("/customer/:customerId", projectHandler.GetProjectsByCustomerID)
|
||||
projects.POST("", middleware.RoleMiddleware("admin"), projectHandler.CreateProject)
|
||||
projects.PUT("/:id", middleware.RoleMiddleware("admin"), projectHandler.UpdateProject)
|
||||
projects.DELETE("/:id", middleware.RoleMiddleware("admin"), projectHandler.DeleteProject)
|
||||
}
|
||||
|
||||
// Time Entry routes
|
||||
timeEntries := protected.Group("/time-entries")
|
||||
{
|
||||
timeEntries.GET("", timeEntryHandler.GetTimeEntries)
|
||||
timeEntries.GET("/me", timeEntryHandler.GetMyTimeEntries)
|
||||
timeEntries.GET("/range", timeEntryHandler.GetTimeEntriesByDateRange)
|
||||
timeEntries.GET("/:id", timeEntryHandler.GetTimeEntryByID)
|
||||
timeEntries.GET("/user/:userId", timeEntryHandler.GetTimeEntriesByUserID)
|
||||
timeEntries.GET("/project/:projectId", timeEntryHandler.GetTimeEntriesByProjectID)
|
||||
timeEntries.POST("", timeEntryHandler.CreateTimeEntry)
|
||||
timeEntries.PUT("/:id", timeEntryHandler.UpdateTimeEntry)
|
||||
timeEntries.DELETE("/:id", timeEntryHandler.DeleteTimeEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
286
backend/internal/api/utils/handler_utils.go
Normal file
286
backend/internal/api/utils/handler_utils.go
Normal file
@ -0,0 +1,286 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/timetracker/backend/internal/api/responses"
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
)
|
||||
|
||||
// ParseID parses an ID from the URL parameter and converts it to a types.ULID
|
||||
func ParseID(c *gin.Context, paramName string) (types.ULID, error) {
|
||||
idStr := c.Param(paramName)
|
||||
return types.ULIDFromString(idStr)
|
||||
}
|
||||
|
||||
// BindJSON binds the request body to the provided struct
|
||||
func BindJSON(c *gin.Context, obj interface{}) error {
|
||||
if err := c.ShouldBindJSON(obj); err != nil {
|
||||
return fmt.Errorf("invalid request body: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertToDTO converts a slice of models to a slice of DTOs using the provided conversion function
|
||||
func ConvertToDTO[M any, D any](models []M, convertFn func(*M) D) []D {
|
||||
dtos := make([]D, len(models))
|
||||
for i, model := range models {
|
||||
// Create a copy of the model to avoid issues with loop variable capture
|
||||
modelCopy := model
|
||||
dtos[i] = convertFn(&modelCopy)
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
|
||||
// HandleGetAll is a generic function to handle GET all entities endpoints
|
||||
func HandleGetAll[M any, D any](
|
||||
c *gin.Context,
|
||||
getAllFn func(ctx context.Context) ([]M, error),
|
||||
convertFn func(*M) D,
|
||||
entityName string,
|
||||
) {
|
||||
// Get entities from the database
|
||||
entities, err := getAllFn(c.Request.Context())
|
||||
if err != nil {
|
||||
responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to DTOs
|
||||
dtos := ConvertToDTO(entities, convertFn)
|
||||
|
||||
responses.SuccessResponse(c, 200, dtos)
|
||||
}
|
||||
|
||||
// HandleGetByID is a generic function to handle GET entity by ID endpoints
|
||||
func HandleGetByID[M any, D any](
|
||||
c *gin.Context,
|
||||
getByIDFn func(ctx context.Context, id types.ULID) (*M, error),
|
||||
convertFn func(*M) D,
|
||||
entityName string,
|
||||
) {
|
||||
// Parse ID from URL
|
||||
id, err := ParseID(c, "id")
|
||||
if err != nil {
|
||||
responses.BadRequestResponse(c, fmt.Sprintf("Invalid %s ID format", entityName))
|
||||
return
|
||||
}
|
||||
|
||||
// Get entity from the database
|
||||
entity, err := getByIDFn(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if entity == nil {
|
||||
responses.NotFoundResponse(c, fmt.Sprintf("%s not found", entityName))
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to DTO
|
||||
dto := convertFn(entity)
|
||||
|
||||
responses.SuccessResponse(c, 200, dto)
|
||||
}
|
||||
|
||||
// HandleCreate is a generic function to handle POST entity endpoints
|
||||
func HandleCreate[C any, M any, D any](
|
||||
c *gin.Context,
|
||||
createFn func(ctx context.Context, create C) (*M, error),
|
||||
convertFn func(*M) D,
|
||||
entityName string,
|
||||
) {
|
||||
// Parse request body
|
||||
var createDTO C
|
||||
if err := BindJSON(c, &createDTO); err != nil {
|
||||
responses.BadRequestResponse(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Create entity in the database
|
||||
entity, err := createFn(c.Request.Context(), createDTO)
|
||||
if err != nil {
|
||||
responses.InternalErrorResponse(c, fmt.Sprintf("Error creating %s: %s", entityName, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to DTO
|
||||
dto := convertFn(entity)
|
||||
|
||||
responses.SuccessResponse(c, 201, dto)
|
||||
}
|
||||
|
||||
// HandleDelete is a generic function to handle DELETE entity endpoints
|
||||
func HandleDelete(
|
||||
c *gin.Context,
|
||||
deleteFn func(ctx context.Context, id types.ULID) error,
|
||||
entityName string,
|
||||
) {
|
||||
// Parse ID from URL
|
||||
id, err := ParseID(c, "id")
|
||||
if err != nil {
|
||||
responses.BadRequestResponse(c, fmt.Sprintf("Invalid %s ID format", entityName))
|
||||
return
|
||||
}
|
||||
|
||||
// Delete entity from the database
|
||||
err = deleteFn(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
responses.InternalErrorResponse(c, fmt.Sprintf("Error deleting %s: %s", entityName, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
responses.SuccessResponse(c, 204, nil)
|
||||
}
|
||||
|
||||
// HandleUpdate is a generic function to handle PUT entity endpoints
|
||||
// It takes a prepareUpdateFn that handles parsing the ID, binding the JSON, and converting the DTO to a model update object
|
||||
func HandleUpdate[U any, M any, D any](
|
||||
c *gin.Context,
|
||||
updateFn func(ctx context.Context, update U) (*M, error),
|
||||
convertFn func(*M) D,
|
||||
prepareUpdateFn func(*gin.Context) (U, error),
|
||||
entityName string,
|
||||
) {
|
||||
// Prepare the update object (parse ID, bind JSON, convert DTO to model)
|
||||
update, err := prepareUpdateFn(c)
|
||||
if err != nil {
|
||||
// The prepareUpdateFn should handle setting the appropriate error response
|
||||
return
|
||||
}
|
||||
|
||||
// Update entity in the database
|
||||
entity, err := updateFn(c.Request.Context(), update)
|
||||
if err != nil {
|
||||
responses.InternalErrorResponse(c, fmt.Sprintf("Error updating %s: %s", entityName, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if entity == nil {
|
||||
responses.NotFoundResponse(c, fmt.Sprintf("%s not found", entityName))
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to DTO
|
||||
dto := convertFn(entity)
|
||||
|
||||
responses.SuccessResponse(c, http.StatusOK, dto)
|
||||
}
|
||||
|
||||
// HandleGetByFilter is a generic function to handle GET entities by a filter parameter
|
||||
func HandleGetByFilter[M any, D any](
|
||||
c *gin.Context,
|
||||
getByFilterFn func(ctx context.Context, filterID types.ULID) ([]M, error),
|
||||
convertFn func(*M) D,
|
||||
entityName string,
|
||||
paramName string,
|
||||
) {
|
||||
// Parse filter ID from URL
|
||||
filterID, err := ParseID(c, paramName)
|
||||
if err != nil {
|
||||
responses.BadRequestResponse(c, fmt.Sprintf("Invalid %s ID format", paramName))
|
||||
return
|
||||
}
|
||||
|
||||
// Get entities from the database
|
||||
entities, err := getByFilterFn(c.Request.Context(), filterID)
|
||||
if err != nil {
|
||||
responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to DTOs
|
||||
dtos := ConvertToDTO(entities, convertFn)
|
||||
|
||||
responses.SuccessResponse(c, http.StatusOK, dtos)
|
||||
}
|
||||
|
||||
// HandleGetByUserID is a specialized function to handle GET entities by user ID
|
||||
func HandleGetByUserID[M any, D any](
|
||||
c *gin.Context,
|
||||
getByUserIDFn func(ctx context.Context, userID types.ULID) ([]M, error),
|
||||
convertFn func(*M) D,
|
||||
entityName string,
|
||||
) {
|
||||
// Get user ID from context (set by AuthMiddleware)
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
responses.UnauthorizedResponse(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
userIDStr, ok := userID.(string)
|
||||
if !ok {
|
||||
responses.InternalErrorResponse(c, "Invalid user ID type in context")
|
||||
return
|
||||
}
|
||||
|
||||
parsedUserID, err := types.ULIDFromString(userIDStr)
|
||||
if err != nil {
|
||||
responses.InternalErrorResponse(c, fmt.Sprintf("Error parsing user ID: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Get entities from the database
|
||||
entities, err := getByUserIDFn(c.Request.Context(), parsedUserID)
|
||||
if err != nil {
|
||||
responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to DTOs
|
||||
dtos := ConvertToDTO(entities, convertFn)
|
||||
|
||||
responses.SuccessResponse(c, http.StatusOK, dtos)
|
||||
}
|
||||
|
||||
// HandleGetByDateRange is a specialized function to handle GET entities by date range
|
||||
func HandleGetByDateRange[M any, D any](
|
||||
c *gin.Context,
|
||||
getByDateRangeFn func(ctx context.Context, start, end time.Time) ([]M, error),
|
||||
convertFn func(*M) D,
|
||||
entityName string,
|
||||
) {
|
||||
// Parse date range from query parameters
|
||||
startStr := c.Query("start")
|
||||
endStr := c.Query("end")
|
||||
|
||||
if startStr == "" || endStr == "" {
|
||||
responses.BadRequestResponse(c, "Start and end dates are required")
|
||||
return
|
||||
}
|
||||
|
||||
start, err := time.Parse(time.RFC3339, startStr)
|
||||
if err != nil {
|
||||
responses.BadRequestResponse(c, "Invalid start date format. Use ISO 8601 format (e.g., 2023-01-01T00:00:00Z)")
|
||||
return
|
||||
}
|
||||
|
||||
end, err := time.Parse(time.RFC3339, endStr)
|
||||
if err != nil {
|
||||
responses.BadRequestResponse(c, "Invalid end date format. Use ISO 8601 format (e.g., 2023-01-01T00:00:00Z)")
|
||||
return
|
||||
}
|
||||
|
||||
if end.Before(start) {
|
||||
responses.BadRequestResponse(c, "End date cannot be before start date")
|
||||
return
|
||||
}
|
||||
|
||||
// Get entities from the database
|
||||
entities, err := getByDateRangeFn(c.Request.Context(), start, end)
|
||||
if err != nil {
|
||||
responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to DTOs
|
||||
dtos := ConvertToDTO(entities, convertFn)
|
||||
|
||||
responses.SuccessResponse(c, http.StatusOK, dtos)
|
||||
}
|
171
backend/internal/config/config.go
Normal file
171
backend/internal/config/config.go
Normal file
@ -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
|
||||
}
|
126
backend/internal/db/db.go
Normal file
126
backend/internal/db/db.go
Normal file
@ -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
|
||||
}
|
97
backend/internal/models/activity.go
Normal file
97
backend/internal/models/activity.go
Normal file
@ -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
|
||||
}
|
28
backend/internal/models/base.go
Normal file
28
backend/internal/models/base.go
Normal file
@ -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
|
||||
}
|
102
backend/internal/models/company.go
Normal file
102
backend/internal/models/company.go
Normal file
@ -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
|
||||
}
|
100
backend/internal/models/customer.go
Normal file
100
backend/internal/models/customer.go
Normal file
@ -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
|
||||
}
|
142
backend/internal/models/db.go
Normal file
142
backend/internal/models/db.go
Normal file
@ -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()
|
||||
}
|
31
backend/internal/models/errors.go
Normal file
31
backend/internal/models/errors.go
Normal file
@ -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")
|
4
backend/internal/models/jwt.go
Normal file
4
backend/internal/models/jwt.go
Normal file
@ -0,0 +1,4 @@
|
||||
package models
|
||||
|
||||
// This file is intentionally left empty.
|
||||
// The JWTConfig struct has been moved to the config package.
|
238
backend/internal/models/project.go
Normal file
238
backend/internal/models/project.go
Normal file
@ -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
|
||||
}
|
361
backend/internal/models/timeentry.go
Normal file
361
backend/internal/models/timeentry.go
Normal file
@ -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
|
||||
}
|
556
backend/internal/models/user.go
Normal file
556
backend/internal/models/user.go
Normal file
@ -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"
|
||||
)
|
35
backend/internal/permissions/evaluator.go
Normal file
35
backend/internal/permissions/evaluator.go
Normal file
@ -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
|
||||
}
|
23
backend/internal/permissions/helpers.go
Normal file
23
backend/internal/permissions/helpers.go
Normal file
@ -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
|
||||
}
|
11
backend/internal/permissions/matching.go
Normal file
11
backend/internal/permissions/matching.go
Normal file
@ -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
|
||||
}
|
13
backend/internal/permissions/permissions.go
Normal file
13
backend/internal/permissions/permissions.go
Normal file
@ -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
|
||||
)
|
40
backend/internal/permissions/policy.go
Normal file
40
backend/internal/permissions/policy.go
Normal file
@ -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)
|
||||
}
|
11
backend/internal/permissions/role.go
Normal file
11
backend/internal/permissions/role.go
Normal file
@ -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"`
|
||||
}
|
10
backend/internal/permissions/user.go
Normal file
10
backend/internal/permissions/user.go
Normal file
@ -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
|
||||
}
|
52
backend/internal/types/nullable.go
Normal file
52
backend/internal/types/nullable.go
Normal file
@ -0,0 +1,52 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Nullable[T] - Generischer Typ für optionale Werte (nullable fields)
|
||||
type Nullable[T any] struct {
|
||||
Value *T // Der tatsächliche Wert (kann nil sein)
|
||||
Valid bool // Gibt an, ob der Wert gesetzt wurde
|
||||
}
|
||||
|
||||
// NewNullable erstellt eine gültige Nullable-Instanz
|
||||
func NewNullable[T any](value T) Nullable[T] {
|
||||
return Nullable[T]{Value: &value, Valid: true}
|
||||
}
|
||||
|
||||
// Null erstellt eine leere Nullable-Instanz (ungesetzt)
|
||||
func Null[T any]() Nullable[T] {
|
||||
return Nullable[T]{Valid: true}
|
||||
}
|
||||
|
||||
func Undefined[T any]() Nullable[T] {
|
||||
return Nullable[T]{Valid: false}
|
||||
}
|
||||
|
||||
// MarshalJSON - Serialisiert `Nullable[T]` korrekt ins JSON-Format
|
||||
func (n Nullable[T]) MarshalJSON() ([]byte, error) {
|
||||
if !n.Valid {
|
||||
return []byte("null"), nil // Wenn nicht valid, dann NULL
|
||||
}
|
||||
return json.Marshal(n.Value) // Serialisiert den tatsächlichen Wert
|
||||
}
|
||||
|
||||
// UnmarshalJSON - Deserialisiert JSON in `Nullable[T]`
|
||||
func (n *Nullable[T]) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
n.Valid = true // Wert wurde gesetzt, aber auf NULL
|
||||
n.Value = nil // Explizit NULL setzen
|
||||
return nil
|
||||
}
|
||||
|
||||
var v T
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return fmt.Errorf("invalid JSON for Nullable: %w", err)
|
||||
}
|
||||
|
||||
n.Value = &v
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
77
backend/internal/types/ulid.go
Normal file
77
backend/internal/types/ulid.go
Normal file
@ -0,0 +1,77 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ULID wraps ulid.ULID to make it work nicely with GORM
|
||||
type ULID struct {
|
||||
ulid.ULID
|
||||
}
|
||||
|
||||
// NewULIDWrapper creates a new ULID with a new ULID
|
||||
func NewULIDWrapper() ULID {
|
||||
return ULID{ULID: ulid.Make()}
|
||||
}
|
||||
|
||||
// FromULID creates a ULID from a ulid.ULID
|
||||
func FromULID(id ulid.ULID) ULID {
|
||||
return ULID{ULID: id}
|
||||
}
|
||||
|
||||
// ULIDWrapperFromString creates a ULID from a string
|
||||
func ULIDFromString(id string) (ULID, error) {
|
||||
parsed, err := ulid.Parse(id)
|
||||
if err != nil {
|
||||
return ULID{}, fmt.Errorf("failed to parse ULID string: %w", err)
|
||||
}
|
||||
return ULID{ULID: parsed}, nil
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface for ULID
|
||||
func (u *ULID) Scan(src any) error {
|
||||
switch v := src.(type) {
|
||||
case []byte:
|
||||
// If it's exactly 16 bytes, it's the binary representation
|
||||
if len(v) == 16 {
|
||||
copy(u.ULID[:], v)
|
||||
return nil
|
||||
}
|
||||
// Otherwise, try as string
|
||||
return fmt.Errorf("cannot scan []byte of length %d into ULID", len(v))
|
||||
case string:
|
||||
parsed, err := ulid.Parse(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse ULID: %w", err)
|
||||
}
|
||||
u.ULID = parsed
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into ULID", src)
|
||||
}
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for ULID
|
||||
// Returns the binary representation of the ULID for maximum efficiency
|
||||
func (u ULID) Value() (driver.Value, error) {
|
||||
return u.ULID.Bytes(), nil
|
||||
}
|
||||
|
||||
// GormValue implements the gorm.Valuer interface for ULID
|
||||
func (u ULID) GormValue(ctx context.Context, db *gorm.DB) clause.Expr {
|
||||
return clause.Expr{
|
||||
SQL: "?",
|
||||
Vars: []any{u.Bytes()},
|
||||
}
|
||||
}
|
||||
|
||||
// Compare implements comparison for ULID
|
||||
func (u ULID) Compare(other ULID) int {
|
||||
return u.ULID.Compare(other.ULID)
|
||||
}
|
BIN
backend/migrate
Executable file
BIN
backend/migrate
Executable file
Binary file not shown.
168
backend/postman/activity.postman_collection.json
Normal file
168
backend/postman/activity.postman_collection.json
Normal file
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
168
backend/postman/company.postman_collection.json
Normal file
168
backend/postman/company.postman_collection.json
Normal file
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
200
backend/postman/customer.postman_collection.json
Normal file
200
backend/postman/customer.postman_collection.json
Normal file
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
225
backend/postman/project.postman_collection.json
Normal file
225
backend/postman/project.postman_collection.json
Normal file
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
292
backend/postman/timeentry.postman_collection.json
Normal file
292
backend/postman/timeentry.postman_collection.json
Normal file
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
241
backend/postman/user.postman_collection.json
Normal file
241
backend/postman/user.postman_collection.json
Normal file
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
62
backend/scripts/fix_tygo.go
Normal file
62
backend/scripts/fix_tygo.go
Normal file
@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
dtoFilePath = "../frontend/src/types/dto.ts"
|
||||
importStatement = `import { Nullable } from "./nullable";`
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Run Tygo first
|
||||
fmt.Println("🔄 Running tygo...")
|
||||
if err := runTygo(); err != nil {
|
||||
fmt.Println("❌ Error running tygo:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Read dto.ts file
|
||||
content, err := os.ReadFile(dtoFilePath)
|
||||
if err != nil {
|
||||
fmt.Println("❌ Could not read dto.ts:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Convert to string
|
||||
dtoContent := string(content)
|
||||
|
||||
// Check if import already exists
|
||||
if strings.Contains(dtoContent, importStatement) {
|
||||
fmt.Println("ℹ️ Import already exists in dto.ts, skipping.")
|
||||
return
|
||||
}
|
||||
|
||||
// Add import statement at the beginning
|
||||
newContent := importStatement + "\n" + dtoContent
|
||||
if err := os.WriteFile(dtoFilePath, []byte(newContent), 0644); err != nil {
|
||||
fmt.Println("❌ Error writing dto.ts:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Successfully added Nullable<T> import to dto.ts")
|
||||
}
|
||||
|
||||
// Runs Tygo command
|
||||
func runTygo() error {
|
||||
cmd := "tygo"
|
||||
output, err := exec.Command(cmd, "generate").CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Println("Tygo output:", string(output))
|
||||
return err
|
||||
}
|
||||
if len(output) > 0 {
|
||||
fmt.Println("Tygo output:", string(output))
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
7
backend/tygo.yaml
Normal file
7
backend/tygo.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
packages:
|
||||
- path: "github.com/timetracker/backend/internal/api/dto"
|
||||
type_mappings:
|
||||
"time.Time": "string"
|
||||
"types.ULID": "string"
|
||||
"types.Nullable": "Nullable"
|
||||
output_path: ../frontend/src/types/dto.ts
|
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal file
@ -0,0 +1,26 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:14
|
||||
container_name: timetracker_db
|
||||
restart: always
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_USER: timetracker
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: timetracker
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4
|
||||
container_name: pgadmin
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@example.com
|
||||
PGADMIN_DEFAULT_PASSWORD: admin
|
||||
ports:
|
||||
- "8081:80"
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
volumes:
|
||||
db_data:
|
@ -13,6 +13,8 @@ This document provides an overview of the Time Tracking and Management System. F
|
||||
- [Extensibility and Integrations](extensibility_integrations.md)
|
||||
- [LLM Guidance](llm_guidance.md)
|
||||
- [Roadmap](Roadmap.md)
|
||||
- [Domain Types](domain_types.md)
|
||||
- [DTOs](dtos.md)
|
||||
|
||||
## Code Examples
|
||||
- [GORM Entities](code_examples/gorm_entities.go)
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
**Note:** This document describes a *conceptual* architecture and is not a final, binding requirement.
|
||||
|
||||
The backend is written in Go and follows the principles of **Clean Architecture** and **Domain-Driven Design (DDD)**.
|
||||
The backend is written in Go using idiomatic Go patterns. While initially following Clean Architecture and DDD principles, we've adapted to a more pragmatic approach that better fits Go's conventions and reduces boilerplate code.
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
|
@ -1,101 +0,0 @@
|
||||
// interfaces/http/handlers/time_entry_handler.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/username/timetracker/internal/application/timetracking"
|
||||
"github.com/username/timetracker/internal/interfaces/http/dto"
|
||||
"github.com/username/timetracker/internal/interfaces/http/middleware"
|
||||
)
|
||||
|
||||
// TimeEntryHandler behandelt HTTP-Anfragen für Zeitbuchungen
|
||||
type TimeEntryHandler struct {
|
||||
createTimeEntryUseCase *timetracking.CreateTimeEntryUseCase
|
||||
updateTimeEntryUseCase *timetracking.UpdateTimeEntryUseCase
|
||||
listTimeEntriesUseCase *timetracking.ListTimeEntriesUseCase
|
||||
deleteTimeEntryUseCase *timetracking.DeleteTimeEntryUseCase
|
||||
}
|
||||
|
||||
// NewTimeEntryHandler erstellt einen neuen TimeEntryHandler
|
||||
func NewTimeEntryHandler(
|
||||
createTimeEntryUseCase *timetracking.CreateTimeEntryUseCase,
|
||||
updateTimeEntryUseCase *timetracking.UpdateTimeEntryUseCase,
|
||||
listTimeEntriesUseCase *timetracking.ListTimeEntriesUseCase,
|
||||
deleteTimeEntryUseCase *timetracking.DeleteTimeEntryUseCase,
|
||||
) *TimeEntryHandler {
|
||||
return &TimeEntryHandler{
|
||||
createTimeEntryUseCase: createTimeEntryUseCase,
|
||||
updateTimeEntryUseCase: updateTimeEntryUseCase,
|
||||
listTimeEntriesUseCase: listTimeEntriesUseCase,
|
||||
deleteTimeEntryUseCase: deleteTimeEntryUseCase,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registriert die Routen am Router
|
||||
func (h *TimeEntryHandler) RegisterRoutes(router *gin.RouterGroup) {
|
||||
timeEntries := router.Group("/time-entries")
|
||||
{
|
||||
timeEntries.GET("", h.ListTimeEntries)
|
||||
timeEntries.POST("", h.CreateTimeEntry)
|
||||
timeEntries.GET("/:id", h.GetTimeEntry)
|
||||
timeEntries.PUT("/:id", h.UpdateTimeEntry)
|
||||
timeEntries.DELETE("/:id", h.DeleteTimeEntry)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTimeEntry behandelt die Erstellung einer neuen Zeitbuchung
|
||||
func (h *TimeEntryHandler) CreateTimeEntry(c *gin.Context) {
|
||||
var req dto.CreateTimeEntryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Tenant-ID aus dem Kontext extrahieren
|
||||
companyID, exists := middleware.GetCompanyID(c)
|
||||
if !exists {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Company ID not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Benutzer-ID aus dem Kontext oder Request
|
||||
var userID uuid.UUID
|
||||
if req.UserID != nil {
|
||||
userID = *req.UserID
|
||||
} else {
|
||||
currentUserID, exists := middleware.GetUserID(c)
|
||||
if !exists {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found"})
|
||||
return
|
||||
}
|
||||
userID = currentUserID
|
||||
}
|
||||
|
||||
// Command erstellen
|
||||
cmd := timetracking.CreateTimeEntryCommand{
|
||||
UserID: userID,
|
||||
ProjectID: req.ProjectID,
|
||||
ActivityID: req.ActivityID,
|
||||
TaskID: req.TaskID,
|
||||
StartTime: req.StartTime,
|
||||
EndTime: req.EndTime,
|
||||
Description: req.Description,
|
||||
BillablePercentage: req.BillablePercentage,
|
||||
}
|
||||
|
||||
// UseCase ausführen
|
||||
result := h.createTimeEntryUseCase.Execute(c.Request.Context(), companyID, cmd)
|
||||
if result.IsFailure() {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error().Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// TimeEntry in Response-DTO umwandeln
|
||||
timeEntry := result.Value()
|
||||
response := dto.MapTimeEntryToDTO(*timeEntry)
|
||||
|
||||
c.JSON(http.StatusCreated, response)
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
// domain/repositories/time_entry_repository.go
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/username/timetracker/internal/domain/entities"
|
||||
"github.com/username/timetracker/pkg/functional"
|
||||
)
|
||||
|
||||
// TimeEntryFilter enthält Filter für die Suche nach Zeitbuchungen
|
||||
type TimeEntryFilter struct {
|
||||
UserID *uuid.UUID
|
||||
ProjectID *uuid.UUID
|
||||
CustomerID *uuid.UUID
|
||||
StartDate *time.Time
|
||||
EndDate *time.Time
|
||||
ActivityID *uuid.UUID
|
||||
TaskID *uuid.UUID
|
||||
}
|
||||
|
||||
// TimeEntryRepository Interface für den Zugriff auf Zeitbuchungen
|
||||
type TimeEntryRepository interface {
|
||||
// FindByID sucht eine Zeitbuchung anhand ihrer ID
|
||||
FindByID(ctx context.Context, companyID, id uuid.UUID) functional.Result[*entities.TimeEntry]
|
||||
|
||||
// FindAll sucht alle Zeitbuchungen mit optionalen Filtern
|
||||
FindAll(ctx context.Context, companyID uuid.UUID, filter TimeEntryFilter) functional.Result[[]entities.TimeEntry]
|
||||
|
||||
// Create erstellt eine neue Zeitbuchung
|
||||
Create(ctx context.Context, entry *entities.TimeEntry) functional.Result[*entities.TimeEntry]
|
||||
|
||||
// Update aktualisiert eine bestehende Zeitbuchung
|
||||
Update(ctx context.Context, entry *entities.TimeEntry) functional.Result[*entities.TimeEntry]
|
||||
|
||||
// Delete löscht eine Zeitbuchung
|
||||
Delete(ctx context.Context, companyID, id uuid.UUID) functional.Result[bool]
|
||||
|
||||
// GetSummary berechnet eine Zusammenfassung der Zeitbuchungen
|
||||
GetSummary(ctx context.Context, companyID uuid.UUID, filter TimeEntryFilter) functional.Result[TimeEntrySummary]
|
||||
}
|
||||
|
||||
// TimeEntrySummary enthält zusammengefasste Informationen über Zeitbuchungen
|
||||
type TimeEntrySummary struct {
|
||||
TotalDuration int
|
||||
TotalBillableDuration int
|
||||
TotalAmount float64
|
||||
TotalBillableAmount float64
|
||||
EntriesCount int
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
// application/timetracking/create_time_entry.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/username/timetracker/internal/domain/entities"
|
||||
"github.com/username/timetracker/internal/domain/repositories"
|
||||
"github.com/username/timetracker/pkg/functional"
|
||||
"github.com/username/timetracker/pkg/validator"
|
||||
)
|
||||
|
||||
// CreateTimeEntryCommand enthält die Daten zum Erstellen einer Zeitbuchung
|
||||
type CreateTimeEntryCommand struct {
|
||||
UserID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
ActivityID uuid.UUID
|
||||
TaskID *uuid.UUID
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
Description string
|
||||
BillablePercentage int
|
||||
}
|
||||
|
||||
// CreateTimeEntryUseCase repräsentiert den Anwendungsfall zum Erstellen einer Zeitbuchung
|
||||
type CreateTimeEntryUseCase struct {
|
||||
timeEntryRepo repositories.TimeEntryRepository
|
||||
projectRepo repositories.ProjectRepository
|
||||
activityRepo repositories.ActivityRepository
|
||||
userRepo repositories.UserRepository
|
||||
}
|
||||
|
||||
// NewCreateTimeEntryUseCase erstellt eine neue Instanz des UseCase
|
||||
func NewCreateTimeEntryUseCase(
|
||||
timeEntryRepo repositories.TimeEntryRepository,
|
||||
projectRepo repositories.ProjectRepository,
|
||||
activityRepo repositories.ActivityRepository,
|
||||
userRepo repositories.UserRepository,
|
||||
) *CreateTimeEntryUseCase {
|
||||
return &CreateTimeEntryUseCase{
|
||||
timeEntryRepo: timeEntryRepo,
|
||||
projectRepo: projectRepo,
|
||||
activityRepo: activityRepo,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute führt den Anwendungsfall aus
|
||||
func (uc *CreateTimeEntryUseCase) Execute(ctx context.Context, companyID uuid.UUID, cmd CreateTimeEntryCommand) functional.Result[*entities.TimeEntry] {
|
||||
// Validierung
|
||||
if err := validator.ValidateStruct(cmd); err != nil {
|
||||
return functional.Failure[*entities.TimeEntry](err)
|
||||
}
|
||||
|
||||
// Überprüfen, ob Projekt existiert und zum gleichen Tenant gehört
|
||||
projectResult := uc.projectRepo.FindByID(ctx, companyID, cmd.ProjectID)
|
||||
if projectResult.IsFailure() {
|
||||
return functional.Failure[*entities.TimeEntry](projectResult.Error())
|
||||
}
|
||||
|
||||
// Überprüfen, ob Activity existiert und zum gleichen Tenant gehört
|
||||
activityResult := uc.activityRepo.FindByID(ctx, companyID, cmd.ActivityID)
|
||||
if activityResult.IsFailure() {
|
||||
return functional.Failure[*entities.TimeEntry](activityResult.Error())
|
||||
}
|
||||
activity := activityResult.Value()
|
||||
|
||||
// Benutzer abrufen für den Stundensatz
|
||||
userResult := uc.userRepo.FindByID(ctx, companyID, cmd.UserID)
|
||||
if userResult.IsFailure() {
|
||||
return functional.Failure[*entities.TimeEntry](userResult.Error())
|
||||
}
|
||||
user := userResult.Value()
|
||||
|
||||
// Berechnung der Dauer in Minuten
|
||||
durationMinutes := int(cmd.EndTime.Sub(cmd.StartTime).Minutes())
|
||||
|
||||
// TimeEntry erstellen
|
||||
timeEntry := &entities.TimeEntry{
|
||||
TenantEntity: entities.TenantEntity{
|
||||
CompanyID: companyID,
|
||||
},
|
||||
UserID: cmd.UserID,
|
||||
ProjectID: cmd.ProjectID,
|
||||
ActivityID: cmd.ActivityID,
|
||||
TaskID: cmd.TaskID,
|
||||
StartTime: cmd.StartTime,
|
||||
EndTime: cmd.EndTime,
|
||||
DurationMinutes: durationMinutes,
|
||||
Description: cmd.Description,
|
||||
BillablePercentage: cmd.BillablePercentage,
|
||||
BillingRate: activity.BillingRate,
|
||||
}
|
||||
|
||||
// Speichern der TimeEntry
|
||||
return uc.timeEntryRepo.Create(ctx, timeEntry)
|
||||
}
|
@ -1,104 +0,0 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BaseEntity enthält gemeinsame Felder für alle Entitäten
|
||||
type BaseEntity struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
}
|
||||
|
||||
// BeforeCreate setzt eine neue UUID vor dem Erstellen
|
||||
func (base *BaseEntity) BeforeCreate(tx *gorm.DB) error {
|
||||
if base.ID == uuid.Nil {
|
||||
base.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TenantEntity erweitert BaseEntity um Company-ID für Multi-Tenancy
|
||||
type TenantEntity struct {
|
||||
BaseEntity
|
||||
CompanyID uuid.UUID `gorm:"type:uuid;index:idx_tenant"`
|
||||
}
|
||||
|
||||
// Role repräsentiert eine Benutzerrolle
|
||||
type Role struct {
|
||||
BaseEntity
|
||||
Name string `gorm:"unique;not null"`
|
||||
Description string
|
||||
Permissions []Permission `gorm:"many2many:role_permissions;"`
|
||||
}
|
||||
|
||||
// Permission repräsentiert eine einzelne Berechtigung
|
||||
type Permission struct {
|
||||
BaseEntity
|
||||
Resource string `gorm:"not null"`
|
||||
Action string `gorm:"not null"`
|
||||
UniqueID string `gorm:"uniqueIndex"`
|
||||
}
|
||||
|
||||
// User repräsentiert einen Benutzer im System
|
||||
type User struct {
|
||||
TenantEntity
|
||||
Email string `gorm:"uniqueIndex;not null"`
|
||||
FirstName string
|
||||
LastName string
|
||||
PasswordHash string `gorm:"not null"`
|
||||
RoleID uuid.UUID `gorm:"type:uuid"`
|
||||
Role Role `gorm:"foreignKey:RoleID"`
|
||||
HourlyRate float64
|
||||
IsActive bool `gorm:"default:true"`
|
||||
}
|
||||
|
||||
// FullName gibt den vollständigen Namen des Benutzers zurück
|
||||
func (u User) FullName() string {
|
||||
return u.FirstName + " " + u.LastName
|
||||
}
|
||||
|
||||
// TimeEntry repräsentiert eine Zeitbuchung
|
||||
type TimeEntry struct {
|
||||
TenantEntity
|
||||
UserID uuid.UUID `gorm:"type:uuid;index:idx_user"`
|
||||
User User `gorm:"foreignKey:UserID"`
|
||||
ProjectID uuid.UUID `gorm:"type:uuid;index:idx_project"`
|
||||
Project Project `gorm:"foreignKey:ProjectID"`
|
||||
ActivityID uuid.UUID `gorm:"type:uuid"`
|
||||
Activity Activity `gorm:"foreignKey:ActivityID"`
|
||||
TaskID *uuid.UUID `gorm:"type:uuid;null"`
|
||||
Task *Task `gorm:"foreignKey:TaskID"`
|
||||
StartTime time.Time `gorm:"not null;index:idx_time_range"`
|
||||
EndTime time.Time `gorm:"not null;index:idx_time_range"`
|
||||
DurationMinutes int `gorm:"not null"`
|
||||
Description string
|
||||
BillablePercentage int `gorm:"default:100"`
|
||||
BillingRate float64
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
TenantEntity
|
||||
Name string
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
TenantEntity
|
||||
Name string
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
TenantEntity
|
||||
Name string
|
||||
}
|
||||
|
||||
// CalculateBillableAmount berechnet den abrechenbaren Betrag
|
||||
func (t TimeEntry) CalculateBillableAmount() float64 {
|
||||
hours := float64(t.DurationMinutes) / 60.0
|
||||
return hours * t.BillingRate * (float64(t.BillablePercentage) / 100.0)
|
||||
}
|
@ -1,136 +0,0 @@
|
||||
// presentation/components/timeTracker/Timer/Timer.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTimeTracking } from '../../../hooks/useTimeTracking';
|
||||
import { pipe, Option, fromNullable } from '../../../../utils/fp/option';
|
||||
import { Button } from '../../common/Button';
|
||||
import { formatDuration } from '../../../../utils/date/dateUtils';
|
||||
|
||||
interface TimerProps {
|
||||
onComplete?: (duration: number) => void;
|
||||
}
|
||||
|
||||
export const Timer: React.FC<TimerProps> = ({ onComplete }) => {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [startTime, setStartTime] = useState<Option<Date>>(Option.none());
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [selectedProject, setSelectedProject] = useState<Option<string>>(Option.none());
|
||||
const [selectedActivity, setSelectedActivity] = useState<Option<string>>(Option.none());
|
||||
|
||||
const { lastTimeEntry, projects, activities } = useTimeTracking();
|
||||
|
||||
// Beim ersten Rendering die letzte Zeitbuchung laden
|
||||
useEffect(() => {
|
||||
pipe(
|
||||
fromNullable(lastTimeEntry),
|
||||
Option.map(entry => {
|
||||
setSelectedProject(Option.some(entry.projectId));
|
||||
setSelectedActivity(Option.some(entry.activityId));
|
||||
})
|
||||
);
|
||||
}, [lastTimeEntry]);
|
||||
|
||||
// Timer-Logik
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (isRunning) {
|
||||
interval = setInterval(() => {
|
||||
const now = new Date();
|
||||
pipe(
|
||||
startTime,
|
||||
Option.map(start => {
|
||||
const diff = now.getTime() - start.getTime();
|
||||
setElapsedTime(Math.floor(diff / 1000));
|
||||
})
|
||||
);
|
||||
}, 1000);
|
||||
} else if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [isRunning, startTime]);
|
||||
|
||||
// Timer starten
|
||||
const handleStart = () => {
|
||||
setStartTime(Option.some(new Date()));
|
||||
setIsRunning(true);
|
||||
};
|
||||
|
||||
// Timer stoppen
|
||||
const handleStop = () => {
|
||||
setIsRunning(false);
|
||||
|
||||
// Prüfen, ob Projekt und Aktivität ausgewählt wurden
|
||||
const projectId = pipe(
|
||||
selectedProject,
|
||||
Option.getOrElse(() => '')
|
||||
);
|
||||
|
||||
const activityId = pipe(
|
||||
selectedActivity,
|
||||
Option.getOrElse(() => '')
|
||||
);
|
||||
|
||||
if (projectId && activityId && onComplete) {
|
||||
onComplete(elapsedTime);
|
||||
}
|
||||
|
||||
// Timer zurücksetzen
|
||||
setElapsedTime(0);
|
||||
setStartTime(Option.none());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-4">
|
||||
<div className="text-4xl text-center font-mono mb-4">
|
||||
{formatDuration(elapsedTime)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Projekt
|
||||
</label>
|
||||
<select
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2"
|
||||
value={pipe(selectedProject, Option.getOrElse(() => ''))}
|
||||
onChange={(e) => setSelectedProject(Option.some(e.target.value))}
|
||||
disabled={isRunning}
|
||||
>
|
||||
<option value="">Projekt auswählen</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tätigkeit
|
||||
</label>
|
||||
<select
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2"
|
||||
value={pipe(selectedActivity, Option.getOrElse(() => ''))}
|
||||
onChange={(e) => setSelectedActivity(Option.some(e.target.value))}
|
||||
disabled={isRunning}
|
||||
>
|
||||
<option value="">Tätigkeit auswählen</option>
|
||||
{activities.map((activity) => (
|
||||
<option key={activity.id} value={activity.id}>
|
||||
{activity.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,160 +0,0 @@
|
||||
# Database Schema (PostgreSQL)
|
||||
|
||||
```sql
|
||||
-- Multi-Tenant
|
||||
CREATE TABLE companies (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address TEXT,
|
||||
contact_email VARCHAR(255),
|
||||
contact_phone VARCHAR(50),
|
||||
logo_url TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Users and Roles
|
||||
CREATE TABLE roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(50) UNIQUE NOT NULL,
|
||||
permissions JSONB
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID REFERENCES companies(id),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
role_id INTEGER REFERENCES roles(id),
|
||||
hourly_rate DECIMAL(10, 2),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Customers
|
||||
CREATE TABLE customers (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
contact_person VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
phone VARCHAR(50),
|
||||
address TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Projects
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
customer_id UUID REFERENCES customers(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
status VARCHAR(50),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Activities
|
||||
CREATE TABLE activities (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
billing_rate DECIMAL(10, 2),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Time bookings
|
||||
CREATE TABLE time_entries (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
project_id UUID NOT NULL REFERENCES projects(id),
|
||||
activity_id UUID NOT NULL REFERENCES activities(id),
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
end_time TIMESTAMP NOT NULL,
|
||||
duration INTEGER NOT NULL, -- in minutes
|
||||
description TEXT,
|
||||
billable_percentage INTEGER NOT NULL DEFAULT 100,
|
||||
billing_rate DECIMAL(10, 2),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Version 2: Sprint/Task Management
|
||||
CREATE TABLE sprints (
|
||||
id UUID PRIMARY KEY,
|
||||
project_id UUID NOT NULL REFERENCES projects(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
status VARCHAR(50),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE task_statuses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
color VARCHAR(7),
|
||||
position INTEGER NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE tasks (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
project_id UUID NOT NULL REFERENCES projects(id),
|
||||
sprint_id UUID REFERENCES sprints(id),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
assignee_id UUID REFERENCES users(id),
|
||||
status_id INTEGER REFERENCES task_statuses(id),
|
||||
priority VARCHAR(50),
|
||||
estimate INTEGER, -- in minutes
|
||||
due_date TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE kanban_boards (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
project_id UUID NOT NULL REFERENCES projects(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE kanban_columns (
|
||||
id UUID PRIMARY KEY,
|
||||
board_id UUID NOT NULL REFERENCES kanban_boards(id),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
task_status_id INTEGER REFERENCES task_statuses(id),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Linking time entries and tasks
|
||||
ALTER TABLE time_entries ADD COLUMN task_id UUID REFERENCES tasks(id);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_time_entries_user ON time_entries(user_id);
|
||||
CREATE INDEX idx_time_entries_project ON time_entries(project_id);
|
||||
CREATE INDEX idx_time_entries_date ON time_entries(start_time);
|
||||
CREATE INDEX idx_projects_company ON projects(company_id);
|
||||
CREATE INDEX idx_users_company ON users(company_id);
|
||||
CREATE INDEX idx_tasks_project ON tasks(project_id);
|
||||
CREATE INDEX idx_tasks_sprint ON tasks(sprint_id);
|
@ -1,17 +0,0 @@
|
||||
# Deployment and DevOps
|
||||
|
||||
## Containerization
|
||||
- Docker containers for backend and frontend
|
||||
- Docker Compose for development environment
|
||||
- Kubernetes manifests for production environment
|
||||
|
||||
## CI/CD Pipeline
|
||||
- Automated tests (Unit, Integration, E2E)
|
||||
- Automated deployment
|
||||
- Version management
|
||||
|
||||
## Monitoring and Logging
|
||||
- Prometheus for metrics
|
||||
- Grafana for visualization
|
||||
- ELK Stack or similar for logging
|
||||
- Alerting for critical events
|
27
docu/domain_types.md
Normal file
27
docu/domain_types.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Domain Types
|
||||
|
||||
This document describes the domain types used in the Time Tracker application. Domain types represent the core business concepts and are used throughout the application.
|
||||
|
||||
## Activity
|
||||
|
||||
The `Activity` type represents a specific activity that can be tracked, such as "Development", "Meeting", or "Bug Fixing".
|
||||
|
||||
## Company
|
||||
|
||||
The `Company` type represents a tenant in the multi-tenant application. Each company has its own set of users, customers, projects, and activities.
|
||||
|
||||
## Customer
|
||||
|
||||
The `Customer` type represents a customer of a company.
|
||||
|
||||
## Project
|
||||
|
||||
The `Project` type represents a project for a specific customer.
|
||||
|
||||
## TimeEntry
|
||||
|
||||
The `TimeEntry` type represents a time booking for a specific user, project, and activity.
|
||||
|
||||
## User
|
||||
|
||||
The `User` type represents a user of the application. Each user belongs to a company and has a specific role.
|
@ -1,93 +0,0 @@
|
||||
# Ergänzung zur Zeiterfassungstool Spezifikation
|
||||
|
||||
## 10. Datensicherheit und Datenschutz
|
||||
|
||||
### Sicherheitsmaßnahmen
|
||||
- **Verschlüsselung**: TLS für alle Verbindungen, Verschlüsselung sensibler Daten in der Datenbank
|
||||
- **Tenant-Isolation**: Strikte Trennung der Daten zwischen verschiedenen Companies
|
||||
- **Audit-Trail**: Lückenlose Protokollierung aller relevanten Systemaktivitäten
|
||||
- **DSGVO-Konformität**: Implementierung von Datenschutzfunktionen (Recht auf Vergessen, Datenexport)
|
||||
|
||||
## 11. Deployment und DevOps
|
||||
|
||||
### Containerisierung
|
||||
- Docker-Container für Backend und Frontend
|
||||
- Docker-Compose für Entwicklungsumgebung
|
||||
- Kubernetes-Manifest für Produktionsumgebung
|
||||
|
||||
### CI/CD-Pipeline
|
||||
- Automatisierte Tests (Unit, Integration, E2E)
|
||||
- Automatisiertes Deployment
|
||||
- Versionsmanagement
|
||||
|
||||
### Monitoring und Logging
|
||||
- Prometheus für Metriken
|
||||
- Grafana für Visualisierung
|
||||
- ELK-Stack oder ähnliches für Logging
|
||||
- Alerting bei kritischen Ereignissen
|
||||
|
||||
## 12. Backup und Recovery
|
||||
|
||||
- Regelmäßige Datenbank-Backups
|
||||
- Point-in-Time-Recovery
|
||||
- Disaster-Recovery-Plan
|
||||
|
||||
## 13. Offline-Funktionalität
|
||||
|
||||
- Progressive Web App (PWA) Funktionalitäten
|
||||
- Lokale Speicherung von Zeitbuchungen bei fehlender Internetverbindung
|
||||
- Synchronisation bei Wiederherstellung der Verbindung
|
||||
|
||||
## 14. Integrationen
|
||||
|
||||
### API-Integrationen
|
||||
- **Abrechnungssysteme**: Export von Abrechnungsdaten
|
||||
- **Kalendersysteme**: Synchronisation mit Google Calendar, Outlook, etc.
|
||||
- **Projektmanagementtools**: Jira, Trello, etc. (insbesondere für Version 2)
|
||||
- **Webhooks**: Für benutzerdefinierte Integrationen
|
||||
|
||||
### Single Sign-On (SSO)
|
||||
- Integration mit gängigen Identity Providern (Google, Microsoft, SAML)
|
||||
- OAuth 2.0 / OpenID Connect
|
||||
|
||||
## 15. Erweiterbarkeit
|
||||
|
||||
### Plugin-System
|
||||
- Möglichkeit zur Erweiterung durch Plugins
|
||||
- Hooks für benutzerdefinierte Logik
|
||||
|
||||
### Custom Fields
|
||||
- Erweiterbare Datenmodelle mit benutzerdefinierten Feldern
|
||||
- Anpassbare Formulare
|
||||
|
||||
## 16. Mobile App-Unterstützung
|
||||
|
||||
- Responsive Webdesign für mobile Browser
|
||||
- Native Mobile Apps (zukünftige Erweiterung)
|
||||
- API-Design mit Mobilnutzung im Blick
|
||||
|
||||
## 17. Internationalisierung und Lokalisierung
|
||||
|
||||
- Mehrsprachige Benutzeroberfläche
|
||||
- Lokalisierte Datums- und Zeitformate
|
||||
- Währungsumrechnung
|
||||
|
||||
## 18. Abrechnung und Fakturierung
|
||||
|
||||
- Generierung von Rechnungsentwürfen basierend auf Zeitbuchungen
|
||||
- Export von Abrechnungsdaten in verschiedenen Formaten (CSV, Excel, etc.)
|
||||
- Automatisierte Erinnerungen für nicht erfasste Zeiten
|
||||
|
||||
## 19. Benachrichtigungssystem
|
||||
|
||||
- E-Mail-Benachrichtigungen
|
||||
- In-App-Benachrichtigungen
|
||||
- Erinnerungen für ausstehende Zeiterfassungen
|
||||
- Genehmigungsanfragen
|
||||
|
||||
## 20. Analytics und Business Intelligence
|
||||
|
||||
- Erweiterte Analytik für Management-Insights
|
||||
- Trendanalysen und Prognosen
|
||||
- Ressourcenauslastung und -planung
|
||||
- Exportmöglichkeiten für BI-Tools
|
@ -1,22 +0,0 @@
|
||||
Folgende Punkte könnten sinnvoll ergänzt werden:
|
||||
|
||||
- **Audit-Logs**:
|
||||
- Protokollierung wichtiger Aktionen (z.B. Änderungen an Stammdaten, Rollenänderungen).
|
||||
|
||||
- **Benachrichtigungen**:
|
||||
- E-Mail/Push bei wichtigen Ereignissen (z.B. Abweichungen, Freigaben).
|
||||
|
||||
- **Fehlerbehandlung & Validierung**:
|
||||
- Einheitliche Strategie für API-Fehler und Client-seitige Validierungen.
|
||||
|
||||
- **Internationale Lokalisierung (i18n)**:
|
||||
- Unterstützung mehrerer Sprachen für UI-Texte und Berichte.
|
||||
|
||||
- **Performance & Skalierung**:
|
||||
- Caching-Strategien (z.B. Redis).
|
||||
- Indexing-Strategien für effiziente Queries.
|
||||
|
||||
- **Backup & Restore-Konzept**:
|
||||
- Regelmäßige Backups und Wiederherstellungsszenarien.
|
||||
|
||||
Möchtest du davon etwas in die Spezifikation übernehmen?
|
@ -1,93 +0,0 @@
|
||||
# Ergänzung zur Zeiterfassungstool Spezifikation
|
||||
|
||||
## 10. Datensicherheit und Datenschutz
|
||||
|
||||
### Sicherheitsmaßnahmen
|
||||
- **Verschlüsselung**: TLS für alle Verbindungen, Verschlüsselung sensibler Daten in der Datenbank
|
||||
- **Tenant-Isolation**: Strikte Trennung der Daten zwischen verschiedenen Companies
|
||||
- **Audit-Trail**: Lückenlose Protokollierung aller relevanten Systemaktivitäten
|
||||
- **DSGVO-Konformität**: Implementierung von Datenschutzfunktionen (Recht auf Vergessen, Datenexport)
|
||||
|
||||
## 11. Deployment und DevOps
|
||||
|
||||
### Containerisierung
|
||||
- Docker-Container für Backend und Frontend
|
||||
- Docker-Compose für Entwicklungsumgebung
|
||||
- Kubernetes-Manifest für Produktionsumgebung
|
||||
|
||||
### CI/CD-Pipeline
|
||||
- Automatisierte Tests (Unit, Integration, E2E)
|
||||
- Automatisiertes Deployment
|
||||
- Versionsmanagement
|
||||
|
||||
### Monitoring und Logging
|
||||
- Prometheus für Metriken
|
||||
- Grafana für Visualisierung
|
||||
- ELK-Stack oder ähnliches für Logging
|
||||
- Alerting bei kritischen Ereignissen
|
||||
|
||||
## 12. Backup und Recovery
|
||||
|
||||
- Regelmäßige Datenbank-Backups
|
||||
- Point-in-Time-Recovery
|
||||
- Disaster-Recovery-Plan
|
||||
|
||||
## 13. Offline-Funktionalität
|
||||
|
||||
- Progressive Web App (PWA) Funktionalitäten
|
||||
- Lokale Speicherung von Zeitbuchungen bei fehlender Internetverbindung
|
||||
- Synchronisation bei Wiederherstellung der Verbindung
|
||||
|
||||
## 14. Integrationen
|
||||
|
||||
### API-Integrationen
|
||||
- **Abrechnungssysteme**: Export von Abrechnungsdaten
|
||||
- **Kalendersysteme**: Synchronisation mit Google Calendar, Outlook, etc.
|
||||
- **Projektmanagementtools**: Jira, Trello, etc. (insbesondere für Version 2)
|
||||
- **Webhooks**: Für benutzerdefinierte Integrationen
|
||||
|
||||
### Single Sign-On (SSO)
|
||||
- Integration mit gängigen Identity Providern (Google, Microsoft, SAML)
|
||||
- OAuth 2.0 / OpenID Connect
|
||||
|
||||
## 15. Erweiterbarkeit
|
||||
|
||||
### Plugin-System
|
||||
- Möglichkeit zur Erweiterung durch Plugins
|
||||
- Hooks für benutzerdefinierte Logik
|
||||
|
||||
### Custom Fields
|
||||
- Erweiterbare Datenmodelle mit benutzerdefinierten Feldern
|
||||
- Anpassbare Formulare
|
||||
|
||||
## 16. Mobile App-Unterstützung
|
||||
|
||||
- Responsive Webdesign für mobile Browser
|
||||
- Native Mobile Apps (zukünftige Erweiterung)
|
||||
- API-Design mit Mobilnutzung im Blick
|
||||
|
||||
## 17. Internationalisierung und Lokalisierung
|
||||
|
||||
- Mehrsprachige Benutzeroberfläche
|
||||
- Lokalisierte Datums- und Zeitformate
|
||||
- Währungsumrechnung
|
||||
|
||||
## 18. Abrechnung und Fakturierung
|
||||
|
||||
- Generierung von Rechnungsentwürfen basierend auf Zeitbuchungen
|
||||
- Export von Abrechnungsdaten in verschiedenen Formaten (CSV, Excel, etc.)
|
||||
- Automatisierte Erinnerungen für nicht erfasste Zeiten
|
||||
|
||||
## 19. Benachrichtigungssystem
|
||||
|
||||
- E-Mail-Benachrichtigungen
|
||||
- In-App-Benachrichtigungen
|
||||
- Erinnerungen für ausstehende Zeiterfassungen
|
||||
- Genehmigungsanfragen
|
||||
|
||||
## 20. Analytics und Business Intelligence
|
||||
|
||||
- Erweiterte Analytik für Management-Insights
|
||||
- Trendanalysen und Prognosen
|
||||
- Ressourcenauslastung und -planung
|
||||
- Exportmöglichkeiten für BI-Tools
|
@ -1,147 +0,0 @@
|
||||
# Zeiterfassungstool Spezifikation
|
||||
|
||||
## 1. Überblick
|
||||
|
||||
Dieses Dokument spezifiziert ein modernes Zeiterfassungstool mit Multi-Tenant-Architektur, implementiert mit Go (Backend) und NextJS (Frontend). Das System unterstützt die Erfassung von Arbeitszeiten, Projektmanagement und Reporting-Funktionen.
|
||||
|
||||
## 2. Technologie-Stack
|
||||
|
||||
### Backend
|
||||
- **Programmiersprache**: Go
|
||||
- **Architektur**: Funktional-Programmierung (FPGO)
|
||||
- **Datenbank**: PostgreSQL mit ORM
|
||||
- **API-Dokumentation**: Swagger
|
||||
|
||||
### Frontend
|
||||
- **Framework**: NextJS
|
||||
- **Architektur**: Funktional-Programmierung (FPTS - Functional Programming TypeScript)
|
||||
- **UI-Komponenten**: React mit Tailwind CSS
|
||||
|
||||
## 3. Multi-Tenant-Architektur
|
||||
|
||||
Das System unterstützt mehrere Unternehmen (Companies) mit eigenen Benutzern und Daten.
|
||||
|
||||
### Rollen und Berechtigungen
|
||||
- **Admin**: Systemweiter Administrator mit vollständigem Zugriff
|
||||
- **Company**: Unternehmensadministrator
|
||||
- **Manager**: Projektmanager mit erweiterten Rechten
|
||||
- **User**: Standardbenutzer (Mitarbeiter)
|
||||
- **Auditor**: Nur-Lese-Zugriff für Berichte und Audits
|
||||
|
||||
## 4. Datenbankmodell
|
||||
|
||||
### Hauptentitäten
|
||||
|
||||
#### Tenant-Struktur
|
||||
- **Company**: Unternehmen
|
||||
- **User**: Benutzer mit Rollen
|
||||
|
||||
#### Zeiterfassung
|
||||
- **Kunde**: Kunde einer Company
|
||||
- **Projekt**: Projekte für Kunden
|
||||
- **Tätigkeit**: Arbeitstätigkeiten
|
||||
- **Buchung**: Zeiterfassung
|
||||
- **Stundensatz**: Pro Mitarbeiter
|
||||
- **Verrechnungspreis**: Pro Tätigkeit
|
||||
|
||||
#### Projektmanagement (Version 2)
|
||||
- **Sprint**: Iterationen innerhalb eines Projekts
|
||||
- **Task**: Aufgaben innerhalb eines Sprints
|
||||
- **KanbanBoard**: Visualisierung von Tasks
|
||||
|
||||
## 5. Backend-Architektur
|
||||
|
||||
### Modularisierung
|
||||
Das Backend wird in kleine, funktionale Module aufgeteilt, um die Kontextgröße für LLM-basierte Implementierung zu optimieren.
|
||||
|
||||
#### Core-Module
|
||||
- **auth**: Authentifizierung und Autorisierung
|
||||
- **tenant**: Multi-Tenant-Verwaltung
|
||||
- **user**: Benutzerverwaltung
|
||||
- **timetracking**: Zeiterfassung
|
||||
- **customer**: Kundenverwaltung
|
||||
- **project**: Projektverwaltung
|
||||
- **billing**: Abrechnung und Verrechnungspreise
|
||||
- **reporting**: Report-Generierung
|
||||
|
||||
### API-Design
|
||||
- RESTful API mit Swagger-Dokumentation
|
||||
- JWT-basierte Authentifizierung
|
||||
- Tenant-Isolation durch Middleware
|
||||
|
||||
## 6. Frontend-Architektur
|
||||
|
||||
### Komponentenstruktur
|
||||
- Modulare React-Komponenten
|
||||
- State Management mit Context API oder Redux
|
||||
- Responsive Design
|
||||
|
||||
### Hauptseiten
|
||||
- Dashboard (Übersicht)
|
||||
- Zeiterfassung mit Tracker
|
||||
- Kunden- und Projektverwaltung
|
||||
- Reporting und Auswertungen
|
||||
- Administrationsoberfläche
|
||||
|
||||
## 7. Funktionsumfang (Version 1)
|
||||
|
||||
### Unternehmensverwaltung
|
||||
- Anlegen und Verwalten von Companies (Multi-Tenant)
|
||||
- Benutzerverwaltung mit Rollenzuweisung
|
||||
|
||||
### Stammdatenverwaltung
|
||||
- Kunden anlegen und verwalten
|
||||
- Projekte anlegen und verwalten
|
||||
- Tätigkeiten definieren mit Verrechnungspreisen
|
||||
- Mitarbeiter-Stundensätze hinterlegen
|
||||
|
||||
### Zeiterfassung
|
||||
- Zeiten buchen mit Angaben zu:
|
||||
- Zeitraum (Start/Ende oder Dauer)
|
||||
- Projekt (Wohin)
|
||||
- Tätigkeit (Wofür)
|
||||
- Benutzer (Wer)
|
||||
- Beschreibung (optional)
|
||||
- Verrechnungspreis
|
||||
- Abrechenbarkeit (0-100% Slider)
|
||||
- Zeittracker mit Start/Stop-Funktion
|
||||
- Übernahme der letzten Buchungsparameter
|
||||
|
||||
### Reporting
|
||||
- Kumulation von Buchungen nach:
|
||||
- Projekten
|
||||
- Mitarbeitern
|
||||
- Kunden
|
||||
- Zeiträumen
|
||||
- PDF-Export von Reports
|
||||
- Dashboards mit Visualisierungen
|
||||
|
||||
## 8. Funktionsumfang (Version 2)
|
||||
|
||||
### Projektmanagement
|
||||
- Sprints innerhalb von Projekten
|
||||
- Task-Items in Sprints
|
||||
- Kanban-Boards für visuelle Darstellung
|
||||
- Zuweisung von Tasks zu Benutzern
|
||||
- Direkte Verknüpfung von Tasks mit Zeitbuchungen
|
||||
|
||||
## 9. Implementierungsplan
|
||||
|
||||
### Phase 1: Grundlegende Infrastruktur
|
||||
- Aufsetzen der Go-Backend-Struktur
|
||||
- Implementierung der Datenbank mit ORM
|
||||
- NextJS-Frontend-Grundgerüst
|
||||
- Authentifizierung und Autorisierung
|
||||
|
||||
### Phase 2: Version 1 Features
|
||||
- Stammdatenverwaltung
|
||||
- Zeiterfassung
|
||||
- Reporting
|
||||
- Dashboard
|
||||
|
||||
### Phase 3: Version 2 Features
|
||||
- Projektmanagement
|
||||
- Kanban-Boards
|
||||
- Task-Tracking
|
||||
|
||||
|
@ -1,87 +0,0 @@
|
||||
# Spezifikation Zeiterfassungs-Tool
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
### Backend
|
||||
- Go mit fp-go
|
||||
- PostgreSQL mit ORM (GORM oder Bun)
|
||||
- Swagger/OpenAPI
|
||||
- JWT-basierte Authentifizierung
|
||||
|
||||
### Frontend
|
||||
- Next.js mit fp-ts
|
||||
- React und Tailwind CSS
|
||||
- Responsive Webdesign
|
||||
- Progressive Web App (PWA)
|
||||
|
||||
## Multi-Tenancy & Rollen
|
||||
- **Mandantenfähigkeit**: Isolation von Daten je Unternehmen (Tenant)
|
||||
- **Rollen:** Admin, Company-Admin, Manager, Auditor, User
|
||||
|
||||
## Datenmodelle
|
||||
- **Company** (Tenant)
|
||||
- **User** (mit Rollen, Stundenrate)
|
||||
- **Kunde**
|
||||
- **Projekt** (mit Kundenreferenz)
|
||||
- **Tätigkeit** (Verrechnungspreis)
|
||||
- **Buchung** (Zeit, User, Projekt, Tätigkeit, Beschreibung optional, Abrechenbarkeit 0-100%)
|
||||
|
||||
## Funktionale Anforderungen
|
||||
### Version 1
|
||||
- Stammdatenverwaltung (Kunden, Projekte, Tätigkeiten, User)
|
||||
- Zeiterfassung mit Start/Stopp-Funktion und Parameterübernahme aus letzter Buchung
|
||||
- Übersichtliches Dashboard mit letzten Buchungen und Tracker
|
||||
- Aggregierte Reports nach Zeitraum, Projekt, Kunde, Mitarbeiter mit PDF-Export
|
||||
- Grafische Dashboards (Diagramme)
|
||||
|
||||
### Version 2
|
||||
- Projektverwaltung mit Sprints, Tasks
|
||||
- Kanban-Boards zur Aufgabenverwaltung
|
||||
- Direkte Verknüpfung von Tasks mit Zeitbuchungen
|
||||
|
||||
## Sicherheit und Datenschutz
|
||||
- Tenant-Isolation
|
||||
- TLS-Verschlüsselung und verschlüsselte Speicherung sensibler Daten
|
||||
- Audit-Logs (lückenlose Protokollierung)
|
||||
- DSGVO-konform (Datenexport, Recht auf Vergessen)
|
||||
|
||||
## Technische Umsetzung
|
||||
### Backend-Architektur
|
||||
- Modulare Pakete:
|
||||
- Auth
|
||||
- Tenant
|
||||
- User
|
||||
- TimeTracking
|
||||
- Customer
|
||||
- Project
|
||||
- Billing
|
||||
- Reporting
|
||||
- RESTful API mit Middleware zur Tenant-Isolation
|
||||
|
||||
### Frontend-Komponenten
|
||||
- Dashboard (Tracker, letzte Buchungen)
|
||||
- Buchungsformular und Historie
|
||||
- Stammdatenverwaltung (Kunden, Projekte, Tätigkeiten)
|
||||
- Reporting-Seiten mit PDF-Export
|
||||
- Admin-Oberfläche
|
||||
|
||||
## Deployment & DevOps
|
||||
- Docker, Docker-Compose (Entwicklung)
|
||||
- Kubernetes-Manifeste für Produktion
|
||||
- CI/CD mit automatischen Tests und Deployment
|
||||
- Monitoring und Logging (Prometheus, Grafana, ELK)
|
||||
- Regelmäßige Backups, Disaster-Recovery
|
||||
|
||||
## Erweiterbarkeit und Integrationen
|
||||
- API für externe Abrechnungssysteme und Kalenderintegration
|
||||
- Single Sign-On (Google, Microsoft, SAML)
|
||||
- Plugin-System und Custom Fields für Erweiterbarkeit
|
||||
- Analytics und BI-Export
|
||||
|
||||
## Zusätzliche Funktionen
|
||||
- Audit-Logs wichtiger Aktionen
|
||||
- Benachrichtigungssystem (E-Mail, In-App)
|
||||
- Lokalisierung (Mehrsprachigkeit, Zeit- und Datumsformate)
|
||||
|
||||
Diese aktualisierte Spezifikation berücksichtigt alle relevanten Punkte für eine modulare, erweiterbare und sichere Implementierung.
|
||||
|
@ -1,389 +0,0 @@
|
||||
# Zeiterfassungstool - Projektstruktur
|
||||
|
||||
## Backend (Go)
|
||||
|
||||
```
|
||||
timetracker-backend/
|
||||
├── cmd/ # Einstiegspunkte für die Anwendung
|
||||
│ ├── api/ # API-Server
|
||||
│ │ └── main.go
|
||||
│ └── worker/ # Hintergrundprozesse (Reports, Benachrichtigungen usw.)
|
||||
│ └── main.go
|
||||
├── internal/ # Interner Code, nicht exportierbar
|
||||
│ ├── auth/ # Authentifizierung und Autorisierung
|
||||
│ │ ├── jwt.go
|
||||
│ │ ├── middleware.go
|
||||
│ │ ├── permissions.go
|
||||
│ │ └── service.go
|
||||
│ ├── tenant/ # Multi-Tenant-Funktionalität
|
||||
│ │ ├── middleware.go
|
||||
│ │ └── service.go
|
||||
│ ├── user/ # Benutzerverwaltung
|
||||
│ │ ├── model.go
|
||||
│ │ ├── repository.go
|
||||
│ │ └── service.go
|
||||
│ ├── company/ # Unternehmensverwaltung
|
||||
│ │ ├── model.go
|
||||
│ │ ├── repository.go
|
||||
│ │ └── service.go
|
||||
│ ├── customer/ # Kundenverwaltung
|
||||
│ │ ├── model.go
|
||||
│ │ ├── repository.go
|
||||
│ │ └── service.go
|
||||
│ ├── project/ # Projektverwaltung
|
||||
│ │ ├── model.go
|
||||
│ │ ├── repository.go
|
||||
│ │ └── service.go
|
||||
│ ├── activity/ # Tätigkeitsverwaltung
|
||||
│ │ ├── model.go
|
||||
│ │ ├── repository.go
|
||||
│ │ └── service.go
|
||||
│ ├── timetracking/ # Zeiterfassung
|
||||
│ │ ├── model.go
|
||||
│ │ ├── repository.go
|
||||
│ │ └── service.go
|
||||
│ ├── billing/ # Abrechnung
|
||||
│ │ ├── model.go
|
||||
│ │ ├── repository.go
|
||||
│ │ └── service.go
|
||||
│ ├── reporting/ # Berichtswesen
|
||||
│ │ ├── generator.go
|
||||
│ │ ├── pdf.go
|
||||
│ │ └── service.go
|
||||
│ ├── notification/ # Benachrichtigungen
|
||||
│ │ ├── email.go
|
||||
│ │ ├── inapp.go
|
||||
│ │ └── service.go
|
||||
│ ├── kanban/ # Kanban-Board (Version 2)
|
||||
│ │ ├── model.go
|
||||
│ │ ├── repository.go
|
||||
│ │ └── service.go
|
||||
│ ├── task/ # Task-Management (Version 2)
|
||||
│ │ ├── model.go
|
||||
│ │ ├── repository.go
|
||||
│ │ └── service.go
|
||||
│ ├── sprint/ # Sprint-Management (Version 2)
|
||||
│ │ ├── model.go
|
||||
│ │ ├── repository.go
|
||||
│ │ └── service.go
|
||||
│ ├── config/ # Konfiguration
|
||||
│ │ └── config.go
|
||||
│ └── database/ # Datenbankzugriff
|
||||
│ ├── migrations/
|
||||
│ ├── seeds/
|
||||
│ └── connection.go
|
||||
├── pkg/ # Öffentliche Pakete, die exportiert werden können
|
||||
│ ├── apierror/ # API-Fehlerobjekte
|
||||
│ │ └── error.go
|
||||
│ ├── logger/ # Logging-Funktionalität
|
||||
│ │ └── logger.go
|
||||
│ ├── validator/ # Validierungsfunktionen
|
||||
│ │ └── validator.go
|
||||
│ └── utils/ # Allgemeine Hilfsfunktionen
|
||||
│ ├── dates.go
|
||||
│ ├── encryption.go
|
||||
│ └── helpers.go
|
||||
├── api/ # API-Definitionen
|
||||
│ ├── handlers/ # API-Handler
|
||||
│ │ ├── auth.go
|
||||
│ │ ├── user.go
|
||||
│ │ ├── company.go
|
||||
│ │ ├── customer.go
|
||||
│ │ ├── project.go
|
||||
│ │ ├── timetracking.go
|
||||
│ │ ├── reporting.go
|
||||
│ │ └── task.go
|
||||
│ ├── middleware/ # API-Middleware
|
||||
│ │ ├── auth.go
|
||||
│ │ ├── tenant.go
|
||||
│ │ └── logging.go
|
||||
│ ├── routes/ # API-Routen
|
||||
│ │ └── routes.go
|
||||
│ └── swagger/ # Swagger-Dokumentation
|
||||
│ └── swagger.yaml
|
||||
├── test/ # Tests
|
||||
│ ├── integration/
|
||||
│ ├── unit/
|
||||
│ └── mocks/
|
||||
├── scripts/ # Skripte für Build, Deployment usw.
|
||||
│ ├── build.sh
|
||||
│ ├── migrate.sh
|
||||
│ └── seed.sh
|
||||
├── docker/ # Docker-Konfiguration
|
||||
│ ├── Dockerfile
|
||||
│ └── docker-compose.yml
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Frontend (NextJS)
|
||||
|
||||
```
|
||||
timetracker-frontend/
|
||||
├── public/ # Statische Dateien
|
||||
│ ├── assets/
|
||||
│ │ ├── images/
|
||||
│ │ ├── icons/
|
||||
│ │ └── fonts/
|
||||
│ └── locales/ # Mehrsprachige Inhalte
|
||||
│ ├── de/
|
||||
│ └── en/
|
||||
├── src/
|
||||
│ ├── app/ # Next.js 13+ App Router
|
||||
│ │ ├── api/ # API-Routen (falls nötig)
|
||||
│ │ ├── (auth)/ # Authentifizierungsseiten
|
||||
│ │ │ ├── login/
|
||||
│ │ │ └── register/
|
||||
│ │ ├── dashboard/ # Dashboard-Seiten
|
||||
│ │ │ ├── page.tsx
|
||||
│ │ │ └── layout.tsx
|
||||
│ │ ├── time-tracking/ # Zeiterfassungsseiten
|
||||
│ │ │ ├── page.tsx
|
||||
│ │ │ ├── [id]/
|
||||
│ │ │ └── components/
|
||||
│ │ ├── projects/ # Projektseiten
|
||||
│ │ │ ├── page.tsx
|
||||
│ │ │ └── [id]/
|
||||
│ │ ├── customers/ # Kundenseiten
|
||||
│ │ │ ├── page.tsx
|
||||
│ │ │ └── [id]/
|
||||
│ │ ├── reports/ # Berichtsseiten
|
||||
│ │ │ ├── page.tsx
|
||||
│ │ │ └── [type]/
|
||||
│ │ ├── admin/ # Administrationsseiten
|
||||
│ │ │ ├── users/
|
||||
│ │ │ ├── companies/
|
||||
│ │ │ └── settings/
|
||||
│ │ ├── kanban/ # Kanban-Boards (Version 2)
|
||||
│ │ │ ├── page.tsx
|
||||
│ │ │ └── [id]/
|
||||
│ │ ├── layout.tsx
|
||||
│ │ └── page.tsx
|
||||
│ ├── components/ # Wiederverwendbare Komponenten
|
||||
│ │ ├── common/ # Allgemeine Komponenten
|
||||
│ │ │ ├── Button.tsx
|
||||
│ │ │ ├── Card.tsx
|
||||
│ │ │ ├── Input.tsx
|
||||
│ │ │ └── ...
|
||||
│ │ ├── layout/ # Layout-Komponenten
|
||||
│ │ │ ├── Navbar.tsx
|
||||
│ │ │ ├── Sidebar.tsx
|
||||
│ │ │ ├── Footer.tsx
|
||||
│ │ │ └── ...
|
||||
│ │ ├── dashboard/ # Dashboard-Komponenten
|
||||
│ │ │ ├── ActivityChart.tsx
|
||||
│ │ │ ├── RecentEntries.tsx
|
||||
│ │ │ └── ...
|
||||
│ │ ├── timetracker/ # Zeiterfassungskomponenten
|
||||
│ │ │ ├── Timer.tsx
|
||||
│ │ │ ├── EntryForm.tsx
|
||||
│ │ │ └── ...
|
||||
│ │ ├── reports/ # Berichtskomponenten
|
||||
│ │ │ ├── ReportFilter.tsx
|
||||
│ │ │ ├── Chart.tsx
|
||||
│ │ │ └── ...
|
||||
│ │ └── kanban/ # Kanban-Komponenten (Version 2)
|
||||
│ │ ├── Board.tsx
|
||||
│ │ ├── Column.tsx
|
||||
│ │ ├── Card.tsx
|
||||
│ │ └── ...
|
||||
│ ├── hooks/ # Custom React Hooks
|
||||
│ │ ├── useAuth.ts
|
||||
│ │ ├── useTimeTracking.ts
|
||||
│ │ ├── useProjects.ts
|
||||
│ │ └── ...
|
||||
│ ├── lib/ # Hilfsfunktionen und Bibliotheken
|
||||
│ │ ├── api.ts # API Client
|
||||
│ │ ├── auth.ts # Auth-Utilities
|
||||
│ │ ├── date-utils.ts # Date-Helpers
|
||||
│ │ └── ...
|
||||
│ ├── types/ # TypeScript-Typdefinitionen
|
||||
│ │ ├── auth.ts
|
||||
│ │ ├── user.ts
|
||||
│ │ ├── timeTracking.ts
|
||||
│ │ ├── project.ts
|
||||
│ │ └── ...
|
||||
│ ├── store/ # State Management (falls benötigt)
|
||||
│ │ ├── slices/
|
||||
│ │ └── index.ts
|
||||
│ ├── styles/ # CSS/SCSS Styles
|
||||
│ │ ├── globals.css
|
||||
│ │ └── theme.ts
|
||||
│ └── utils/ # Allgemeine Hilfsfunktionen
|
||||
│ ├── format.ts
|
||||
│ ├── validation.ts
|
||||
│ └── ...
|
||||
├── .env.local.example
|
||||
├── .eslintrc.json
|
||||
├── next.config.js
|
||||
├── package.json
|
||||
├── tailwind.config.js
|
||||
├── tsconfig.json
|
||||
├── jest.config.js # Test-Konfiguration
|
||||
├── postcss.config.js # PostCSS-Konfiguration
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Datenbankschema (PostgreSQL)
|
||||
|
||||
```sql
|
||||
-- Multi-Tenant
|
||||
CREATE TABLE companies (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address TEXT,
|
||||
contact_email VARCHAR(255),
|
||||
contact_phone VARCHAR(50),
|
||||
logo_url TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Benutzer und Rollen
|
||||
CREATE TABLE roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(50) UNIQUE NOT NULL,
|
||||
permissions JSONB
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID REFERENCES companies(id),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
role_id INTEGER REFERENCES roles(id),
|
||||
hourly_rate DECIMAL(10, 2),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Kunden
|
||||
CREATE TABLE customers (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
contact_person VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
phone VARCHAR(50),
|
||||
address TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Projekte
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
customer_id UUID REFERENCES customers(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
status VARCHAR(50),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Tätigkeiten
|
||||
CREATE TABLE activities (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
billing_rate DECIMAL(10, 2),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Zeitbuchungen
|
||||
CREATE TABLE time_entries (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
project_id UUID NOT NULL REFERENCES projects(id),
|
||||
activity_id UUID NOT NULL REFERENCES activities(id),
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
end_time TIMESTAMP NOT NULL,
|
||||
duration INTEGER NOT NULL, -- in minutes
|
||||
description TEXT,
|
||||
billable_percentage INTEGER NOT NULL DEFAULT 100,
|
||||
billing_rate DECIMAL(10, 2),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Version 2: Sprint/Task Management
|
||||
CREATE TABLE sprints (
|
||||
id UUID PRIMARY KEY,
|
||||
project_id UUID NOT NULL REFERENCES projects(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
status VARCHAR(50),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE task_statuses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
color VARCHAR(7),
|
||||
position INTEGER NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE tasks (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
project_id UUID NOT NULL REFERENCES projects(id),
|
||||
sprint_id UUID REFERENCES sprints(id),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
assignee_id UUID REFERENCES users(id),
|
||||
status_id INTEGER REFERENCES task_statuses(id),
|
||||
priority VARCHAR(50),
|
||||
estimate INTEGER, -- in minutes
|
||||
due_date TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE kanban_boards (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
project_id UUID NOT NULL REFERENCES projects(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE kanban_columns (
|
||||
id UUID PRIMARY KEY,
|
||||
board_id UUID NOT NULL REFERENCES kanban_boards(id),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
task_status_id INTEGER REFERENCES task_statuses(id),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Verknüpfung zwischen Zeitbuchungen und Tasks
|
||||
ALTER TABLE time_entries ADD COLUMN task_id UUID REFERENCES tasks(id);
|
||||
|
||||
-- Indizes für Performance
|
||||
CREATE INDEX idx_time_entries_user ON time_entries(user_id);
|
||||
CREATE INDEX idx_time_entries_project ON time_entries(project_id);
|
||||
CREATE INDEX idx_time_entries_date ON time_entries(start_time);
|
||||
CREATE INDEX idx_projects_company ON projects(company_id);
|
||||
CREATE INDEX idx_users_company ON users(company_id);
|
||||
CREATE INDEX idx_tasks_project ON tasks(project_id);
|
||||
CREATE INDEX idx_tasks_sprint ON tasks(sprint_id);
|
||||
```
|
@ -1,143 +0,0 @@
|
||||
Okay, ich schlage dir eine detaillierte Projektstruktur für ein Time Tracking and Management System vor, das in Go (Backend) und Next.js (Frontend) mit FPGO und FPTS umgesetzt wird. Das System soll Multi-Tenant-fähig sein, PostgreSQL als Datenbank verwenden und modular, übersichtlich sowie für die Entwicklung mit LLMs geeignet sein. Die Struktur ist in zwei Hauptteile gegliedert: Backend und Frontend. Hier ist der Vorschlag:
|
||||
|
||||
---
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
### 1. Backend (Go)
|
||||
|
||||
Das Backend ist in Go geschrieben und folgt den Prinzipien von **Clean Architecture** und **Domain-Driven Design (DDD)**, um die Geschäftslogik klar von technischen Details zu trennen. Dies sorgt für Testbarkeit und Erweiterbarkeit.
|
||||
|
||||
```
|
||||
backend-go/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Einstiegspunkt der Anwendung
|
||||
├── internal/ # Nicht-öffentlicher Code
|
||||
│ ├── domain/ # Domänen-Schicht: Geschäftlogik und Entitäten
|
||||
│ │ ├── entities/ # Domänenmodelle
|
||||
│ │ │ ├── user.go
|
||||
│ │ │ ├── company.go
|
||||
│ │ │ ├── project.go
|
||||
│ │ │ ├── client.go
|
||||
│ │ │ ├── activity.go
|
||||
│ │ │ ├── employee.go
|
||||
│ │ │ └── booking.go
|
||||
│ │ ├── repositories/ # Interfaces für Datenpersistenz
|
||||
│ │ │ ├── user_repository.go
|
||||
│ │ │ ├── company_repository.go
|
||||
│ │ │ ├── project_repository.go
|
||||
│ │ │ └── ...
|
||||
│ │ └── services/ # Domänenservices für Anwendungsfälle
|
||||
│ │ ├── user_service.go
|
||||
│ │ ├── booking_service.go
|
||||
│ │ └── ...
|
||||
│ ├── application/ # Anwendungsschicht: API-spezifische Logik
|
||||
│ │ └── services/
|
||||
│ │ └── api_service.go
|
||||
│ ├── infrastructure/ # Infrastrukturschicht: Externe Abhängigkeiten
|
||||
│ │ ├── persistence/ # Implementierung der Repositories mit GORM
|
||||
│ │ │ ├── gorm_user_repository.go
|
||||
│ │ │ ├── gorm_company_repository.go
|
||||
│ │ │ └── ...
|
||||
│ │ └── config/ # Konfigurationsmanagement
|
||||
│ │ └── config.go
|
||||
│ └── interfaces/ # Schnittstellenschicht: API-Handler
|
||||
│ └── http/
|
||||
│ ├── handlers/ # HTTP-Handler für API-Endpunkte
|
||||
│ │ ├── auth_handler.go
|
||||
│ │ ├── booking_handler.go
|
||||
│ │ ├── project_handler.go
|
||||
│ │ └── ...
|
||||
│ └── middleware/ # Middleware für Auth und Tenant-Isolierung
|
||||
│ ├── auth_middleware.go
|
||||
│ └── tenant_middleware.go
|
||||
├── pkg/ # Öffentliche, wiederverwendbare Packages
|
||||
│ ├── logger.go
|
||||
│ └── utils.go
|
||||
├── tests/ # Tests
|
||||
│ ├── unit/
|
||||
│ └── integration/
|
||||
├── docs/ # API-Dokumentation
|
||||
│ └── swagger.yaml
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
||||
#### Erläuterung:
|
||||
- **cmd/**: Enthält den Einstiegspunkt (`main.go`), der den Server startet.
|
||||
- **internal/**: Nicht-öffentlicher Code, unterteilt in:
|
||||
- **domain/**: Kernentitäten (z.B. `User`, `Company`), Repository-Interfaces und Domänenservices.
|
||||
- **application/**: API-spezifische Logik, die Domänenservices nutzt.
|
||||
- **infrastructure/**: Technische Implementierungen wie Datenbankzugriff (GORM für PostgreSQL) und Konfiguration.
|
||||
- **interfaces/**: HTTP-Handler (mit Gin) und Middleware für Authentifizierung und Multi-Tenant-Isolierung.
|
||||
- **pkg/**: Öffentliche Hilfsfunktionen (z.B. Logger).
|
||||
- **tests/**: Unit- und Integrationstests.
|
||||
- **docs/**: Swagger-Dokumentation für die API.
|
||||
|
||||
### 2. Frontend (Next.js)
|
||||
|
||||
Das Frontend nutzt **Next.js** mit **TypeScript** und **FPTS** für funktionale Programmierung. Die Struktur ist typisch für Next.js und fördert Wiederverwendbarkeit.
|
||||
|
||||
```
|
||||
frontend-nextjs/
|
||||
├── pages/ # Seitenkomponenten, die Routen entsprechen
|
||||
│ ├── index.tsx # Startseite
|
||||
│ ├── login.tsx
|
||||
│ ├── dashboard.tsx
|
||||
│ ├── bookings.tsx
|
||||
│ └── ...
|
||||
├── components/ # Wiederverwendbare UI-Komponenten
|
||||
│ ├── Header.tsx
|
||||
│ ├── BookingForm.tsx
|
||||
│ ├── ProjectList.tsx
|
||||
│ └── ...
|
||||
├── lib/ # Hilfsfunktionen und API-Calls
|
||||
│ ├── api.ts # Axios-Instanz für API-Anfragen
|
||||
│ ├── auth.ts # Authentifizierungslogik
|
||||
│ └── ...
|
||||
├── styles/ # Stylesheets
|
||||
│ ├── globals.css
|
||||
│ └── module.css
|
||||
├── public/ # Statische Dateien
|
||||
│ └── images/
|
||||
├── types/ # TypeScript-Typdefinitionen
|
||||
│ ├── user.ts
|
||||
│ ├── booking.ts
|
||||
│ └── ...
|
||||
├── tests/ # Tests für Komponenten und Seiten
|
||||
│ ├── components/
|
||||
│ └── pages/
|
||||
├── next.config.js
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
#### Erläuterung:
|
||||
- **pages/**: Seiten der Anwendung (z.B. `dashboard.tsx` für `/dashboard`).
|
||||
- **components/**: Wiederverwendbare UI-Komponenten (z.B. `BookingForm.tsx`).
|
||||
- **lib/**: Hilfsfunktionen, z.B. für API-Calls mit Axios.
|
||||
- **styles/**: Globale und modulare CSS-Dateien.
|
||||
- **public/**: Statische Dateien wie Bilder.
|
||||
- **types/**: Typdefinitionen für Typsicherheit.
|
||||
- **tests/**: Tests mit Tools wie Jest oder Cypress.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Tenant-Unterstützung
|
||||
|
||||
Die Multi-Tenant-Fähigkeit wird durch eine **Tenant-ID** (z.B. `company_id`) in den Datenbanktabellen umgesetzt. Das Backend verwendet ein **tenant_middleware.go**, das sicherstellt, dass Benutzer nur Daten ihres eigenen Tenants sehen, indem die `company_id` in jeder Anfrage geprüft wird.
|
||||
|
||||
## API-Dokumentation
|
||||
|
||||
Die API wird mit **Swagger** dokumentiert, gespeichert in `backend-go/docs/swagger.yaml`. Dies bietet eine klare, interaktive Übersicht der Endpunkte.
|
||||
|
||||
## Entwicklung mit LLMs
|
||||
|
||||
Für die Entwicklung mit LLMs ist der Code in kleine, klar benannte Dateien aufgeteilt (z.B. `user.go`, `booking_service.go`), die jeweils eine spezifische Aufgabe haben. Dies erleichtert LLMs das Verständnis und die Generierung von Code.
|
||||
|
||||
---
|
||||
|
||||
## Fazit
|
||||
|
||||
Diese Struktur ist modular, übersichtlich und erweiterbar. Sie nutzt bewährte Praktiken für Go und Next.js, unterstützt Multi-Tenant-Fähigkeit und ist durch die Aufteilung in kleine Module ideal für die Entwicklung mit LLMs. Wenn du Anpassungen oder mehr Details möchtest, lass es mich wissen!
|
@ -1,91 +0,0 @@
|
||||
# Spezifikation Zeiterfassungs-Tool
|
||||
|
||||
## Technische Rahmenbedingungen
|
||||
- **Backend:** Go mit fp-go
|
||||
- **Frontend:** Next.js mit fp-ts
|
||||
- **Datenbank:** PostgreSQL mit ORM (z.B. GORM oder Bun)
|
||||
- **API-Dokumentation:** Swagger/OpenAPI
|
||||
- **PDF-Generierung:** serverseitig
|
||||
|
||||
## Multi-Tenancy & Rollen
|
||||
- **Mandantenfähig**: Separierung aller Daten nach Unternehmen (Company)
|
||||
- **Rollen:**
|
||||
- Admin (systemweit)
|
||||
- Company (Unternehmensebene)
|
||||
- Manager
|
||||
- Auditor (Read-only Zugriff auf Reports)
|
||||
- User (regulärer Mitarbeiter)
|
||||
|
||||
## Datenmodelle (DB Entities)
|
||||
- **Company**
|
||||
- Kunden (Clients)
|
||||
- Projekte (Projects)
|
||||
- Tätigkeiten (Activities)
|
||||
- **User**
|
||||
- Stundenrate
|
||||
- Rolle
|
||||
- **Projekt**
|
||||
- Kundenreferenz
|
||||
- Beschreibung
|
||||
- **Tätigkeit**
|
||||
- Name
|
||||
- Verrechnungspreis
|
||||
- **Buchung**
|
||||
- Zeit (Start, Ende, Dauer)
|
||||
- User
|
||||
- Projekt
|
||||
- Tätigkeit
|
||||
- Beschreibung (optional)
|
||||
- Abrechenbarkeit (0-100%)
|
||||
- Verrechnungspreis (berechnet)
|
||||
|
||||
## Funktionale Anforderungen
|
||||
### Version 1
|
||||
- Verwaltung von Kunden, Projekten, Tätigkeiten
|
||||
- Mitarbeiterverwaltung mit Stundensatz
|
||||
- Erstellung von Zeitbuchungen mit optionaler Beschreibung und wählbarer Abrechenbarkeit
|
||||
- Automatische Übernahme der Parameter (Projekt, Tätigkeit, Beschreibung) der letzten Buchung
|
||||
- Live-Tracker mit Start-/Stopp-Funktion auf Dashboard
|
||||
- Reporting-Funktion
|
||||
- Aggregation der Buchungen nach Zeitraum, Projekt, Kunde, Mitarbeiter
|
||||
- Export als PDF
|
||||
- Grafische Dashboards (Kreis-/Balkendiagramme, Zeitverläufe)
|
||||
- Persönliches Dashboard
|
||||
- Liste vergangener Buchungen
|
||||
- Schnellstart-Tracker
|
||||
|
||||
### Version 2 (Ausblick)
|
||||
- Projektverwaltung mit Sprints und Task-Items
|
||||
- Kanban-Board zur Verwaltung von Tasks
|
||||
- Zuordnung von Tasks direkt bei Buchungsauswahl
|
||||
|
||||
## API-Endpunkte
|
||||
- Authentifizierung & Autorisierung (JWT o.ä.)
|
||||
- CRUD-Operationen für alle Entities
|
||||
- Endpunkte für Zeiterfassung, Buchungen starten/stoppen
|
||||
- Reporting-Endpoints mit Filterparametern (Zeitraum, Kunde, Projekt, Mitarbeiter)
|
||||
|
||||
## Frontend-Komponenten
|
||||
- **Dashboard**
|
||||
- Tracker-Komponente
|
||||
- Liste letzter Buchungen
|
||||
- Schnelleinstieg für häufige Aktionen
|
||||
- **Buchungs-Management**
|
||||
- Buchungsformular
|
||||
- Historie
|
||||
- **Verwaltungsseiten**
|
||||
- Kunden, Projekte, Tätigkeiten, User
|
||||
- **Reporting-Ansicht**
|
||||
- Interaktive Filtermöglichkeiten
|
||||
- Grafische Darstellung (Charts)
|
||||
- PDF-Export
|
||||
|
||||
## Technische Umsetzung
|
||||
- Modularer Aufbau (separate Pakete für Entities, Services, Controllers)
|
||||
- DDD-Prinzipien für saubere Trennung von Geschäftslogik
|
||||
- APIs sauber nach Verantwortlichkeiten getrennt (RESTful Design)
|
||||
- Swagger zur API-Dokumentation (generiert aus Annotationen)
|
||||
- Strukturierter und übersichtlicher Code (kleine Dateien, Single Responsibility)
|
||||
|
||||
Diese Struktur ermöglicht eine überschaubare Implementierung mit optimaler Unterstützung durch LLMs.
|
||||
|
@ -1,232 +0,0 @@
|
||||
Ich habe Ihre Anfrage analysiert und die bestehende Dokumentation aktualisiert, um Ihre Anforderungen für ein Zeiterfassungstool zu berücksichtigen. Das Tool soll als Multi-Tenant-Lösung fungieren, in Go (Backend) und Next.js (Frontend) mit FPGO und FPTS für funktionale Programmierung umgesetzt werden. PostgreSQL wird als Datenbank mit einem ORM verwendet, und eine Swagger-Dokumentation wird für die API erstellt. Der Code wird in kleinere, übersichtliche Module und Dateien aufgeteilt, um die Implementierung mit LLMs zu erleichtern. Im Folgenden finden Sie die überarbeitete Spezifikation, die alle Anforderungen für Version 1 sowie geplante Erweiterungen für Version 2 abdeckt.
|
||||
|
||||
---
|
||||
|
||||
# Software Requirements Specification
|
||||
## für das Time Tracking and Management System
|
||||
**Version 1.3**
|
||||
**Erstellt von:** Jean Jacques Avril, Masterstudent, Informatik, Technische Hochschule Rosenheim
|
||||
**Modul:** Konzepte der Programmiersprachen
|
||||
**Datum:** 5. Januar 2025
|
||||
|
||||
---
|
||||
|
||||
## 1. Einführung
|
||||
|
||||
### 1.1 Zweck
|
||||
Dieses Software Requirements Specification (SRS) Dokument beschreibt detailliert die Anforderungen für das Time Tracking and Management System, eine Multi-Tenant-Lösung zur präzisen Erfassung und Verwaltung von Arbeitszeiten für mehrere Unternehmen. Jedes Unternehmen kann eigene Kunden, Projekte und Mitarbeiter verwalten. Das System unterstützt verschiedene Benutzerrollen (User, Manager, Auditor, Company, Admin) mit spezifischen Berechtigungen und Funktionen. Ziel ist es, eine klare Grundlage für die Entwicklung zu schaffen, die eine einfache Implementierung und Erweiterbarkeit ermöglicht.
|
||||
|
||||
### 1.2 Zielgruppe und Leseempfehlungen
|
||||
Dieses Dokument richtet sich an:
|
||||
- **Projektbeteiligte:** Für ein umfassendes Verständnis der Funktionen und Einschränkungen.
|
||||
- **Entwickler:** Zur Einsicht in funktionale und nicht-funktionale Anforderungen für eine reibungslose Umsetzung.
|
||||
- **Qualitätssicherungsteams:** Um Tests an den spezifizierten Anforderungen auszurichten.
|
||||
- **Zukünftige Mitwirkende:** Um die Systemarchitektur zu verstehen und Erweiterungen beizutragen.
|
||||
|
||||
Leser sollten zunächst die funktionalen Anforderungen durchsehen, um die Kernfunktionen zu verstehen, gefolgt von den nicht-funktionalen Anforderungen für Einblicke in Sicherheit und Performance. Entwickler mit Fokus auf Backend und Architektur sollten die Abschnitte zur Systemarchitektur und Technologie besonders beachten.
|
||||
|
||||
### 1.3 Projektumfang
|
||||
Das Time Tracking and Management System bietet eine benutzerfreundliche Plattform zur Zeiterfassung, Verwaltung von Projekten und Erstellung von Berichten in einer Multi-Tenant-Umgebung.
|
||||
|
||||
**Version 1 (Initiale Version):**
|
||||
- Multi-Tenant-Unterstützung mit Datenisolierung.
|
||||
- Benutzerverwaltung mit Rollen: User, Manager, Auditor, Company, Admin.
|
||||
- Verwaltung von Kunden, Projekten und Tätigkeiten je Unternehmen.
|
||||
- Zeiterfassung mit Buchungen (Zeit, Ort, Zweck, Mitarbeiter, Tätigkeit, Beschreibung, Verrechnungspreis, abrechenbarer Prozentsatz).
|
||||
- Berichte und Dashboards mit PDF-Export und grafischen Darstellungen.
|
||||
- Tracker auf der Startseite mit Standardwerten der letzten Buchung.
|
||||
|
||||
**Version 2 (Zukünftige Erweiterungen):**
|
||||
- Projekte mit Sprints und Task-Items.
|
||||
- Kanban-Boards zur Aufgabenzuweisung.
|
||||
- Direkte Task-Auswahl bei Buchungen.
|
||||
|
||||
Die modulare Architektur ermöglicht einfache Erweiterungen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Anforderungen
|
||||
|
||||
### 2.1 Funktionale Anforderungen
|
||||
|
||||
#### FA-1: Multi-Tenant-Unterstützung
|
||||
- **Beschreibung:** Das System muss mehrere Unternehmen (Tenants) unterstützen, jedes mit isolierten Daten.
|
||||
- **Details:** Jedes Unternehmen hat eigene Kunden, Projekte, Tätigkeiten, Mitarbeiter und Buchungen.
|
||||
- **Validierung:** Kein Zugriff auf Daten anderer Tenants möglich.
|
||||
|
||||
#### FA-2: Benutzerverwaltung
|
||||
- **Beschreibung:** Benutzer können sich registrieren, anmelden und Rollen zugewiesen bekommen.
|
||||
- **Details:**
|
||||
- Rollen: User (Zeiterfassung), Manager (Projektübersicht), Auditor (Berichte einsehen), Company (Verwaltung), Admin (Systemweit).
|
||||
- Admin verwaltet Unternehmen, Company verwaltet eigene Daten.
|
||||
- **Validierung:** Eindeutige Benutzernamen und E-Mails pro Tenant.
|
||||
|
||||
#### FA-3: Kundenverwaltung
|
||||
- **Beschreibung:** Unternehmen können Kunden anlegen, bearbeiten und löschen.
|
||||
- **Details:** Kunden sind unternehmensspezifisch.
|
||||
- **Validierung:** Nur Company- und Admin-Rollen dürfen Kunden verwalten.
|
||||
|
||||
#### FA-4: Projektverwaltung
|
||||
- **Beschreibung:** Unternehmen können Projekte für Kunden anlegen, bearbeiten und löschen.
|
||||
- **Details:** Projekte sind mit Kunden und Unternehmen verknüpft.
|
||||
- **Validierung:** Nur Company- und Manager-Rollen dürfen Projekte verwalten.
|
||||
|
||||
#### FA-5: Tätigkeitsverwaltung
|
||||
- **Beschreibung:** Unternehmen können Tätigkeiten mit Verrechnungspreisen definieren.
|
||||
- **Details:** Tätigkeiten sind unternehmensspezifisch.
|
||||
- **Validierung:** Verrechnungspreise müssen positiv sein.
|
||||
|
||||
#### FA-6: Mitarbeiterverwaltung
|
||||
- **Beschreibung:** Unternehmen können Mitarbeiter mit Stundensätzen verwalten.
|
||||
- **Details:** Mitarbeiter sind unternehmensspezifisch.
|
||||
- **Validierung:** Stundensätze müssen positiv sein.
|
||||
|
||||
#### FA-7: Buchungsverwaltung
|
||||
- **Beschreibung:** Benutzer können Zeitbuchungen erfassen.
|
||||
- **Details:** Buchungen enthalten Zeit, Ort, Zweck, Mitarbeiter, Tätigkeit, optionale Beschreibung, Verrechnungspreis und abrechenbaren Prozentsatz (0-100%).
|
||||
- **Validierung:** Buchungen müssen gültige Projekte und Tätigkeiten referenzieren.
|
||||
|
||||
#### FA-8: Zeiterfassung
|
||||
- **Beschreibung:** Benutzer können einen Tracker starten und stoppen.
|
||||
- **Details:** Ohne Auswahl werden die Parameter der letzten Buchung übernommen.
|
||||
- **Validierung:** Kein Start möglich, wenn bereits aktiv.
|
||||
|
||||
#### FA-9: Berichte
|
||||
- **Beschreibung:** Berichte können über Projekte, Mitarbeiter, Kunden und Zeiträume erstellt werden.
|
||||
- **Details:** Berichte enthalten Arbeitszeit, abrechenbare Beträge und können als PDF exportiert werden.
|
||||
- **Validierung:** Nur autorisierte Rollen dürfen Berichte generieren.
|
||||
|
||||
#### FA-10: Dashboard
|
||||
- **Beschreibung:** Benutzer sehen auf der Startseite letzte Buchungen und einen Tracker.
|
||||
- **Details:** Dashboards zeigen grafische Zusammenfassungen.
|
||||
|
||||
### 2.2 Nicht-funktionale Anforderungen
|
||||
|
||||
#### NFA-1: Sicherheit
|
||||
- **Beschreibung:** Datenisolierung zwischen Tenants und sicherer Zugriff.
|
||||
- **Details:** RBAC, JWT-Authentifizierung, Verschlüsselung sensibler Daten.
|
||||
|
||||
#### NFA-2: Performance
|
||||
- **Beschreibung:** API-Antwortzeit unter 200 ms.
|
||||
- **Details:** Optimierte Datenbankabfragen und Caching.
|
||||
|
||||
#### NFA-3: Skalierbarkeit
|
||||
- **Beschreibung:** Unterstützung vieler gleichzeitiger Benutzer und Tenants.
|
||||
- **Details:** Horizontale Skalierung möglich.
|
||||
|
||||
#### NFA-4: Benutzbarkeit
|
||||
- **Beschreibung:** Intuitive und responsive Benutzeroberfläche.
|
||||
- **Details:** Kompatibel mit Desktop, Tablet und Mobilgeräten.
|
||||
|
||||
#### NFA-5: Modularität
|
||||
- **Beschreibung:** Code in kleine, überschaubare Module aufteilen.
|
||||
- **Details:** Erleichtert die Entwicklung mit LLMs.
|
||||
|
||||
---
|
||||
|
||||
## 3. Technische Spezifikation
|
||||
|
||||
### 3.1 Gesamtarchitektur
|
||||
Das System ist eine Full-Stack-Webanwendung:
|
||||
- **Frontend:** Next.js mit React und FPTS.
|
||||
- **Backend:** Go mit FPGO.
|
||||
- **Datenbank:** PostgreSQL mit GORM als ORM.
|
||||
- **API-Dokumentation:** Swagger für RESTful APIs.
|
||||
- **Kommunikation:** RESTful APIs, optional WebSockets für Echtzeitfunktionen.
|
||||
|
||||
### 3.2 Technologie-Stack
|
||||
#### 3.2.1 Frontend
|
||||
- **Next.js/React:** Für interaktive Benutzeroberflächen.
|
||||
- **FPTS:** Funktionale Programmierung in TypeScript.
|
||||
- **Axios:** Für HTTP-Anfragen.
|
||||
|
||||
#### 3.2.2 Backend
|
||||
- **Go:** Hauptsprache für Backend.
|
||||
- **FPGO:** Funktionale Programmierung in Go.
|
||||
- **GORM:** ORM für PostgreSQL.
|
||||
- **Gin:** Web-Framework für HTTP-Anfragen.
|
||||
- **JWT:** Für sichere Authentifizierung.
|
||||
|
||||
#### 3.2.3 Datenbank
|
||||
- **PostgreSQL:** Relationale Datenbank.
|
||||
|
||||
### 3.3 Entitäten
|
||||
- **Company:** Tenant-Daten.
|
||||
- **User:** Mit Rollen und Unternehmenszuordnung.
|
||||
- **Client:** Unternehmensspezifisch.
|
||||
- **Project:** Mit Kunden und Unternehmen verknüpft.
|
||||
- **Activity:** Mit Verrechnungspreis.
|
||||
- **Employee:** Mit Stundensatz.
|
||||
- **Booking:** Zeitbuchungen mit Details.
|
||||
|
||||
---
|
||||
|
||||
## 4. Systemdesign
|
||||
|
||||
### 4.1 Übersicht
|
||||
Das System folgt Domain-Driven Design (DDD) und Clean Architecture mit einer Multi-Tenant-Architektur (gemeinsame Datenbank mit Tenant-IDs).
|
||||
|
||||
### 4.2 Architekturdesign
|
||||
- **Domain-Schicht:** Geschäftlogik und Entitäten.
|
||||
- **Application-Schicht:** Services und Anwendungsfälle.
|
||||
- **Infrastructure-Schicht:** Datenbankzugriff via GORM.
|
||||
- **Interface-Schicht:** API-Handler.
|
||||
- **Frontend:** Next.js mit FPTS.
|
||||
|
||||
### 4.3 Moduldesign
|
||||
- **Benutzerverwaltung:** Registrierung, Authentifizierung.
|
||||
- **Unternehmensverwaltung:** Tenant-Daten.
|
||||
- **Projektverwaltung:** Projektzuweisungen.
|
||||
- **Buchungsverwaltung:** Zeiterfassung.
|
||||
- **Berichte:** Generierung und Export.
|
||||
|
||||
### 4.4 Datenbankdesign
|
||||
- **Companies:** id, name, created_at, updated_at.
|
||||
- **Users:** id, company_id, role, name, email, password_hash.
|
||||
- **Clients:** id, company_id, name.
|
||||
- **Projects:** id, client_id, company_id, name.
|
||||
- **Activities:** id, company_id, name, billing_rate.
|
||||
- **Employees:** id, company_id, name, hourly_rate.
|
||||
- **Bookings:** id, user_id, project_id, activity_id, start_time, end_time, description, billable_percentage.
|
||||
|
||||
---
|
||||
|
||||
## 5. API-Endpunkte
|
||||
|
||||
### 5.1 Übersicht
|
||||
RESTful API mit Swagger-Dokumentation, gesichert durch JWT und RBAC.
|
||||
|
||||
### 5.2 Endpunkte
|
||||
#### 5.2.1 Authentifizierung
|
||||
- **POST /api/auth/register:** Neuer Benutzer.
|
||||
- **POST /api/auth/login:** Login mit JWT.
|
||||
- **POST /api/auth/logout:** Logout.
|
||||
- **GET /api/auth/me:** Benutzerdetails.
|
||||
|
||||
#### 5.2.2 Unternehmensverwaltung (Admin)
|
||||
- **POST /api/companies:** Neues Unternehmen.
|
||||
- **GET /api/companies:** Liste aller Unternehmen.
|
||||
- **PUT /api/companies/{id}:** Unternehmen bearbeiten.
|
||||
- **DELETE /api/companies/{id}:** Unternehmen löschen.
|
||||
|
||||
#### 5.2.3 Buchungsverwaltung
|
||||
- **POST /api/bookings:** Neue Buchung.
|
||||
- **GET /api/bookings:** Buchungen auflisten.
|
||||
- **PUT /api/bookings/{id}:** Buchung bearbeiten.
|
||||
- **DELETE /api/bookings/{id}:** Buchung löschen.
|
||||
|
||||
#### 5.2.4 Berichte
|
||||
- **GET /api/reports:** Bericht generieren.
|
||||
- **GET /api/reports/export:** PDF-Export.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sicherheitsaspekte
|
||||
- **Authentifizierung:** JWT-Token.
|
||||
- **RBAC:** Rollenbasierte Zugriffskontrolle.
|
||||
- **Datenisolierung:** Tenant-spezifischer Zugriff via Company-ID.
|
||||
- **Verschlüsselung:** Sensible Daten verschlüsselt.
|
||||
|
||||
---
|
||||
|
||||
Diese Spezifikation bietet eine klare Anleitung für die Entwicklung eines Multi-Tenant-Zeiterfassungstools mit modularer Struktur, das leicht mit LLMs implementiert werden kann. Die Version 2-Anforderungen sind als zukünftige Erweiterungen berücksichtigt.
|
27
docu/dtos.md
Normal file
27
docu/dtos.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Data Transfer Objects (DTOs)
|
||||
|
||||
This document describes the Data Transfer Objects (DTOs) used in the Time Tracker application. DTOs are used to transfer data between the backend and frontend, and between different layers of the backend.
|
||||
|
||||
## ActivityDto
|
||||
|
||||
The `ActivityDto` type represents a specific activity that can be tracked, such as "Development", "Meeting", or "Bug Fixing". It is used to transfer activity data between the backend and frontend.
|
||||
|
||||
## CompanyDto
|
||||
|
||||
The `CompanyDto` type represents a tenant in the multi-tenant application. Each company has its own set of users, customers, projects, and activities. It is used to transfer company data between the backend and frontend.
|
||||
|
||||
## CustomerDto
|
||||
|
||||
The `CustomerDto` type represents a customer of a company. It is used to transfer customer data between the backend and frontend.
|
||||
|
||||
## ProjectDto
|
||||
|
||||
The `ProjectDto` type represents a project for a specific customer. It is used to transfer project data between the backend and frontend.
|
||||
|
||||
## TimeEntryDto
|
||||
|
||||
The `TimeEntryDto` type represents a time booking for a specific user, project, and activity. It is used to transfer time entry data between the backend and frontend.
|
||||
|
||||
## UserDto
|
||||
|
||||
The `UserDto` type represents a user of the application. Each user belongs to a company and has a specific role. It is used to transfer user data between the backend and frontend.
|
@ -43,7 +43,7 @@ Here's a guide to finding information within the project:
|
||||
- **Code Examples:**
|
||||
- `docu/code_examples/react_component.tsx`: Example React component.
|
||||
|
||||
**Important Note about Code Examples:** The files in `docu/code_examples/` are for illustrative purposes *only*. They do *not* represent a runnable project structure. Treat each file as an isolated example. The package declarations within these files (e.g., `package entities`, `package repositories`, `package main`) are conceptual and should be interpreted in the context of the described architecture, *not* as a literal directory structure. Do not attempt to run `go get` or similar commands based on these examples, as the necessary project structure and dependencies are not present.
|
||||
**Important Note about Code Examples:** The files in `docu/code_examples/` are for illustrative purposes *only*. They do *not* represent a runnable project structure. Treat each file as an isolated example. The package declarations within these files (e.g., `package models`, `package repositories`, `package main`) are conceptual and should be interpreted in the context of the described architecture, *not* as a literal directory structure. Do not attempt to run `go get` or similar commands based on these examples, as the necessary project structure and dependencies are not present.
|
||||
|
||||
## Rules and Guidelines
|
||||
|
||||
|
250
docu/permissions_plan.md
Normal file
250
docu/permissions_plan.md
Normal file
@ -0,0 +1,250 @@
|
||||
# Berechtigungssystem Plan
|
||||
|
||||
Dieser Plan beschreibt die Implementierung eines scope-basierten Berechtigungssystems für das TimeTracker-Projekt.
|
||||
|
||||
## Grundkonzept
|
||||
|
||||
- Ein **Benutzer** kann eine **Rolle** annehmen, aber immer nur eine ist aktiv.
|
||||
- Eine **Rolle** besteht aus mehreren **Policies**.
|
||||
- Eine **Policy** hat einen Namen und eine Map, die **Scopes** (z. B. `items/books`) einem **Berechtigungsschlüssel** (Bitflag) zuordnet.
|
||||
- Berechtigungsschlüssel sind Bitflags, die Permissions wie `read`, `write`, `create`, `list`, `delete`, `moderate`, `superadmin` usw. repräsentieren.
|
||||
- Scopes können **Wildcards** enthalten, z. B. `items/*`, das auf `items/books` vererbt wird.
|
||||
- Ziel: Berechtigungen sowohl im Go-Backend (für API-Sicherheit) als auch im TypeScript-Frontend (für UI-Anpassung) evaluieren.
|
||||
|
||||
## Implementierung im Go-Backend
|
||||
|
||||
### 1. Ordnerstruktur
|
||||
|
||||
- Neuer Ordner: `backend/internal/permissions`
|
||||
- Dateien:
|
||||
- `permissions.go`: `Permission`-Konstanten (Bitflags).
|
||||
- `policy.go`: `Policy`-Struktur.
|
||||
- `role.go`: `Role`-Struktur.
|
||||
- `user.go`: Erweiterung der `User`-Struktur.
|
||||
- `matching.go`: `matchScope`-Funktion.
|
||||
- `evaluator.go`: `EffectivePermissions`- und `HasPermission`-Funktionen.
|
||||
|
||||
### 2. Go-Strukturen
|
||||
|
||||
- `permissions.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
type Permission uint64
|
||||
|
||||
const (
|
||||
PermRead Permission = 1 << iota // 1
|
||||
PermWrite // 2
|
||||
PermCreate // 4
|
||||
PermList // 8
|
||||
PermDelete // 16
|
||||
PermModerate // 32
|
||||
PermSuperadmin // 64
|
||||
)
|
||||
```
|
||||
|
||||
- `policy.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
type Policy struct {
|
||||
Name string
|
||||
Scopes map[string]Permission
|
||||
}
|
||||
```
|
||||
|
||||
- `role.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
type Role struct {
|
||||
Name string
|
||||
Policies []Policy
|
||||
}
|
||||
```
|
||||
|
||||
- `user.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
import "github.com/your-org/your-project/backend/internal/models" // Pfad anpassen
|
||||
|
||||
type User struct {
|
||||
models.User // Einbettung
|
||||
ActiveRole *Role
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Funktionen
|
||||
|
||||
- `matching.go`:
|
||||
|
||||
```go
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
- `evaluator.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
func (u *User) EffectivePermissions(scope string) Permission {
|
||||
if u.ActiveRole == nil {
|
||||
return 0
|
||||
}
|
||||
var perm Permission
|
||||
for _, policy := range u.ActiveRole.Policies {
|
||||
for pat, p := range policy.Scopes {
|
||||
if MatchScope(pat, scope) {
|
||||
perm |= p
|
||||
}
|
||||
}
|
||||
}
|
||||
return perm
|
||||
}
|
||||
|
||||
func (u *User) HasPermission(scope string, requiredPerm Permission) bool {
|
||||
effective := u.EffectivePermissions(scope)
|
||||
return (effective & requiredPerm) == requiredPerm
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Integration in die API-Handler
|
||||
|
||||
- Anpassung der `jwt_auth.go` Middleware.
|
||||
- Verwendung von `HasPermission` in den API-Handlern.
|
||||
|
||||
## Persistierung (Datenbank)
|
||||
|
||||
### 1. Datenbankmodell
|
||||
|
||||
- Zwei neue Tabellen: `roles` und `policies`.
|
||||
- `roles`:
|
||||
- `id` (ULID, Primärschlüssel)
|
||||
- `name` (VARCHAR, eindeutig)
|
||||
- `policies`:
|
||||
- `id` (ULID, Primärschlüssel)
|
||||
- `name` (VARCHAR, eindeutig)
|
||||
- `role_id` (ULID, Fremdschlüssel, der auf `roles.id` verweist)
|
||||
- `scopes` (JSONB oder TEXT, speichert die `map[string]Permission` als JSON)
|
||||
- Beziehung: 1:n zwischen `roles` und `policies`.
|
||||
|
||||
### 2. Go-Strukturen (Anpassungen)
|
||||
|
||||
- `role.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"github.com/your-org/your-project/backend/internal/types" // Pfad anpassen
|
||||
)
|
||||
|
||||
type Role struct {
|
||||
ID types.ULID `gorm:"primaryKey;type:bytea"`
|
||||
Name string `gorm:"unique;not null"`
|
||||
Policies []Policy `gorm:"foreignKey:RoleID"`
|
||||
}
|
||||
```
|
||||
|
||||
- `policy.go`:
|
||||
|
||||
```go
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/your-org/your-project/backend/internal/types" // Pfad anpassen
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
ID types.ULID `gorm:"primaryKey;type:bytea"`
|
||||
Name string `gorm:"not null"`
|
||||
RoleID types.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)
|
||||
}
|
||||
```
|
||||
### 3. Migration
|
||||
|
||||
- Verwendung des vorhandenen Migrations-Frameworks (`backend/cmd/migrate/main.go`).
|
||||
|
||||
### 4. Seed-Daten
|
||||
|
||||
- Optionale Seed-Daten (`backend/cmd/seed/main.go`).
|
||||
|
||||
### 5. Anpassung der Funktionen
|
||||
|
||||
- Anpassung von `EffectivePermissions` und `HasPermission` in `evaluator.go` für Datenbankzugriff.
|
||||
|
||||
## Mermaid Diagramm
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Benutzer
|
||||
U[User] --> AR(ActiveRole)
|
||||
end
|
||||
subgraph Rolle
|
||||
AR --> R(Role)
|
||||
R --> P1(Policy 1)
|
||||
R --> P2(Policy 2)
|
||||
R --> Pn(Policy n)
|
||||
end
|
||||
subgraph Policy
|
||||
P1 --> S1(Scope 1: Permissions)
|
||||
P1 --> S2(Scope 2: Permissions)
|
||||
P2 --> S3(Scope 3: Permissions)
|
||||
Pn --> Sm(Scope m: Permissions)
|
||||
end
|
||||
|
||||
S1 -- Permissions --> PR(PermRead)
|
||||
S1 -- Permissions --> PW(PermWrite)
|
||||
S2 -- Permissions --> PL(PermList)
|
||||
Sm -- Permissions --> PD(PermDelete)
|
||||
|
||||
style U fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style R fill:#ccf,stroke:#333,stroke-width:2px
|
||||
style P1,P2,Pn fill:#ddf,stroke:#333,stroke-width:2px
|
||||
style S1,S2,S3,Sm fill:#eef,stroke:#333,stroke-width:1px
|
||||
style PR,PW,PL,PD fill:#ff9,stroke:#333,stroke-width:1px
|
84
docu/swagger_documentation.md
Normal file
84
docu/swagger_documentation.md
Normal file
@ -0,0 +1,84 @@
|
||||
# Swagger Documentation
|
||||
|
||||
This document explains how to access and update the Swagger documentation for the Time Tracker API.
|
||||
|
||||
## Accessing Swagger UI
|
||||
|
||||
After starting the backend server, access the Swagger UI at:
|
||||
```
|
||||
http://localhost:8080/swagger/index.html
|
||||
```
|
||||
|
||||
This interactive interface allows you to:
|
||||
- Browse all available API endpoints
|
||||
- See request parameters and response formats
|
||||
- Test API calls directly from the browser
|
||||
|
||||
## Updating Swagger Documentation
|
||||
|
||||
To update the Swagger documentation for the Time Tracker API, follow these steps:
|
||||
|
||||
1. **Add or update Swagger annotations in your code**
|
||||
- Annotations should be added as comments above handler functions
|
||||
- Use the correct types in annotations (e.g., `dto.ActivityDto` instead of `utils.ActivityResponse`)
|
||||
- Make sure all parameters, responses, and types are properly documented
|
||||
|
||||
2. **Run the Swagger generation command**
|
||||
```bash
|
||||
cd backend && swag init -g cmd/api/main.go --output docs
|
||||
```
|
||||
|
||||
This command:
|
||||
- Uses `swag` CLI tool to parse your code
|
||||
- Looks for the main entry point in `cmd/api/main.go`
|
||||
- Outputs the generated files to the `docs` directory
|
||||
|
||||
3. **Verify the generated files**
|
||||
The command will generate or update three files:
|
||||
- `docs/docs.go` - Go code for the Swagger documentation
|
||||
- `docs/swagger.json` - JSON representation of the API
|
||||
- `docs/swagger.yaml` - YAML representation of the API
|
||||
|
||||
4. **Common issues and solutions**
|
||||
- If you encounter "cannot find type definition" errors, check that you're using the correct type names in your annotations
|
||||
- If endpoints are missing, ensure they have proper Swagger annotations
|
||||
- If you change the base path or other global settings, update them in the `main.go` file annotations
|
||||
|
||||
## Swagger Annotation Examples
|
||||
|
||||
### Main API Information
|
||||
|
||||
In `main.go`:
|
||||
```go
|
||||
// @title Time Tracker API
|
||||
// @version 1.0
|
||||
// @description This is a simple time tracker API.
|
||||
// @host localhost:8080
|
||||
// @BasePath /api
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
```
|
||||
|
||||
### Endpoint Documentation
|
||||
|
||||
Example from a handler function:
|
||||
```go
|
||||
// GetActivities handles GET /activities
|
||||
//
|
||||
// @Summary Get all activities
|
||||
// @Description Get a list of all activities
|
||||
// @Tags activities
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} utils.Response{data=[]dto.ActivityDto}
|
||||
// @Failure 401 {object} utils.Response{error=utils.ErrorInfo}
|
||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||
// @Router /activities [get]
|
||||
func (h *ActivityHandler) GetActivities(c *gin.Context) {
|
||||
// Function implementation
|
||||
}
|
||||
```
|
||||
|
||||
Remember that the Swagger documentation is generated from the annotations in your code, so keeping these annotations up-to-date is essential for accurate API documentation.
|
113
flake.nix
Normal file
113
flake.nix
Normal file
@ -0,0 +1,113 @@
|
||||
{
|
||||
description = "Development environment for Go and Next.js (TypeScript)";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config = {
|
||||
allowUnfree = true;
|
||||
};
|
||||
};
|
||||
|
||||
# Go development tools
|
||||
goPackages = with pkgs; [
|
||||
go
|
||||
gopls
|
||||
golangci-lint
|
||||
delve
|
||||
go-outline
|
||||
gotools
|
||||
go-mockgen
|
||||
gomodifytags
|
||||
impl
|
||||
gotests
|
||||
];
|
||||
|
||||
# TypeScript/Next.js development tools
|
||||
nodePackages = with pkgs; [
|
||||
nodejs_20
|
||||
nodePackages.typescript
|
||||
nodePackages.typescript-language-server
|
||||
nodePackages.yarn
|
||||
nodePackages.pnpm
|
||||
nodePackages.npm
|
||||
nodePackages.prettier
|
||||
nodePackages.eslint
|
||||
nodePackages.next
|
||||
];
|
||||
|
||||
# General development tools
|
||||
commonPackages = with pkgs; [
|
||||
git
|
||||
gh
|
||||
nixpkgs-fmt
|
||||
pre-commit
|
||||
ripgrep
|
||||
jq
|
||||
curl
|
||||
coreutils
|
||||
gnumake
|
||||
];
|
||||
|
||||
# VSCode with extensions
|
||||
vscodeWithExtensions = pkgs.vscode-with-extensions.override {
|
||||
vscodeExtensions = with pkgs.vscode-extensions; [
|
||||
golang.go # Go support
|
||||
esbenp.prettier-vscode # Prettier
|
||||
dbaeumer.vscode-eslint # ESLint
|
||||
ms-vscode.vscode-typescript-tslint-plugin # TypeScript
|
||||
bradlc.vscode-tailwindcss # Tailwind CSS support
|
||||
jnoortheen.nix-ide # Nix support
|
||||
] ++ pkgs.vscode-utils.extensionsFromVscodeMarketplace [
|
||||
{
|
||||
name = "nextjs";
|
||||
publisher = "pulkitgangwar";
|
||||
version = "1.0.6";
|
||||
sha256 = "sha256-L6ZgqNkM0qzSiTKiGfgQB9m3U0HmwLA3NZ9nrslQjeg=";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = goPackages ++ nodePackages ++ commonPackages ++ [ vscodeWithExtensions ];
|
||||
|
||||
shellHook = ''
|
||||
echo "🚀 Welcome to the Go and Next.js (TypeScript) development environment!"
|
||||
echo "📦 Available tools:"
|
||||
echo " Go: $(go version)"
|
||||
echo " Node: $(node --version)"
|
||||
echo " TypeScript: $(tsc --version)"
|
||||
echo " Next.js: $(npx next --version)"
|
||||
echo ""
|
||||
echo "🔧 Use 'code .' to open VSCode with the appropriate extensions"
|
||||
echo "🔄 Run 'nix flake update' to update dependencies"
|
||||
'';
|
||||
|
||||
# Environment variables
|
||||
GOROOT = "${pkgs.go}/share/go";
|
||||
GOPATH = "$(pwd)/.go";
|
||||
GO111MODULE = "on";
|
||||
|
||||
# NodeJS setup
|
||||
NODE_OPTIONS = "--max-old-space-size=4096";
|
||||
};
|
||||
|
||||
# Optional: Add custom packages if needed
|
||||
packages = {
|
||||
# Example of a custom package or script if needed
|
||||
# my-tool = ...
|
||||
};
|
||||
|
||||
# Default package if someone runs `nix build`
|
||||
defaultPackage = self.devShells.${system}.default;
|
||||
});
|
||||
}
|
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
36
frontend/README.md
Normal file
36
frontend/README.md
Normal file
@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
16
frontend/eslint.config.mjs
Normal file
16
frontend/eslint.config.mjs
Normal file
@ -0,0 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
7
frontend/next.config.ts
Normal file
7
frontend/next.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
5468
frontend/package-lock.json
generated
Normal file
5468
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.1",
|
||||
"@eslint/eslintrc": "^3"
|
||||
}
|
||||
}
|
5
frontend/postcss.config.mjs
Normal file
5
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
After Width: | Height: | Size: 1.0 KiB |
1
frontend/public/next.svg
Normal file
1
frontend/public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 128 B |
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user