Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9c900578d | |||
| 294047a2b0 | |||
| 1198b326c1 | |||
| b47c29cf5a | |||
| 4170eb5fbd | |||
| 233f3cdb5c | |||
| da115dc3f6 | |||
| 0379ea4ae4 | |||
| 016078c1c3 | |||
| c3162756ad | |||
| 2e13d775fa | |||
| b545392f27 | |||
| 9057adebdd | |||
| c08da6fc92 | |||
| 165432208c | |||
| 728258caa7 | |||
| e336ff3ba2 | |||
| 2555143c0e | |||
| ec250570a6 | |||
| a0b0b98624 | |||
| 09584efa39 |
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# TimeTracker Project Rules (v2)
|
||||||
|
0. GENERAL
|
||||||
|
DONT OVERENGINEER.
|
||||||
|
USE IN LINE REPLACEMENTS IF POSSIBLE.
|
||||||
|
SOLVE TASKS AS FAST AS POSSIBLE. EACH REQUEST COSTS THE USER MONEY.
|
||||||
|
1. ARCHITECTURE
|
||||||
|
- Multi-tenancy enforced via company_id in all DB queries
|
||||||
|
2. CODING PRACTICES
|
||||||
|
- Type safety enforced (Go 1.21+ generics, TypeScript strict mode)
|
||||||
|
- Domain types must match across backend (Go) and frontend (TypeScript)
|
||||||
|
- All database access through repository interfaces
|
||||||
|
- API handlers must use DTOs for input/output
|
||||||
|
- Use tygo to generate TypeScript types after modifying Go types
|
||||||
|
3. SECURITY
|
||||||
|
- JWT authentication required for all API endpoints
|
||||||
|
- RBAC implemented in middleware/auth.go
|
||||||
|
- Input validation using github.com/go-playground/validator
|
||||||
|
- No raw SQL - use GORM query builder
|
||||||
|
4. DOCUMENTATION
|
||||||
|
- Architecture decisions recorded in docu/ARCHITECTURE.md
|
||||||
|
- Type relationships documented in docu/domain_types.md
|
||||||
|
5. TESTING
|
||||||
|
- 80%+ test coverage for domain logic
|
||||||
|
- Integration tests for API endpoints
|
||||||
|
- Model tests in backend/cmd/modeltest
|
||||||
|
6. FRONTEND
|
||||||
|
- Next.js App Router pattern required
|
||||||
|
8. DEVELOPMENT WORKFLOW
|
||||||
|
- Makefile commands are only available in the backend folder
|
||||||
|
- Common make commands:
|
||||||
|
- make generate: Run code generation (tygo, swagger, etc.)
|
||||||
|
- make test: Run all tests
|
||||||
|
- make build: Build the application
|
||||||
|
- make run: Start the development server
|
||||||
|
9. CUSTOM RULES
|
||||||
|
- Add custom rules to .clinerules if:
|
||||||
|
- Unexpected behavior is encountered
|
||||||
|
- Specific conditions require warnings
|
||||||
|
- New patterns emerge that need documentation
|
||||||
|
- DO NOT FIX UNUSED IMPORTS - this is the job of the linter
|
||||||
|
10.Implement a REST API update handling in Go using Gin that ensures the following behavior:
|
||||||
|
- The update request is received as JSON.
|
||||||
|
- If a field is present in the JSON and set to null, the corresponding value in the database should be removed.
|
||||||
|
- If a field is missing in the JSON, it should not be modified.
|
||||||
|
- If a field is present in the JSON and not null, it should be updated.
|
||||||
|
- Use either a struct or a map to handle the JSON data.
|
||||||
|
- Ensure the update logic is robust and does not unintentionally remove or overwrite fields.
|
||||||
|
- Optional: Handle error cases like invalid JSON and return appropriate HTTP status codes.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=timetracker
|
||||||
|
DB_PASSWORD=password
|
||||||
|
DB_NAME=timetracker
|
||||||
|
DB_SSLMODE=disable
|
||||||
|
API_KEY=
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
#JWT_SECRET=test
|
||||||
|
#JWT_KEY_DIR=keys
|
||||||
|
#JWT_KEY_GENERATE=true
|
||||||
|
JWT_TOKEN_DURATION=24h
|
||||||
|
ENVIRONMENT=production
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
keys
|
||||||
+28
-1
@@ -1,6 +1,6 @@
|
|||||||
# Time Tracker Backend Makefile
|
# Time Tracker Backend Makefile
|
||||||
|
|
||||||
.PHONY: db-start db-stop db-test model-test run build clean migrate seed help
|
.PHONY: db-start db-stop db-test model-test run build clean migrate seed swagger help
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
@@ -23,7 +23,11 @@ help:
|
|||||||
@echo " make clean - Remove build artifacts"
|
@echo " make clean - Remove build artifacts"
|
||||||
@echo " make migrate - Run database migrations"
|
@echo " make migrate - Run database migrations"
|
||||||
@echo " make seed - Seed the database with initial data"
|
@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 help - Show this help message"
|
||||||
|
@echo "" make generate-ts - Generate TypeScript types
|
||||||
|
|
||||||
# Start the database
|
# Start the database
|
||||||
db-start:
|
db-start:
|
||||||
@@ -75,3 +79,26 @@ seed:
|
|||||||
@echo "Seeding the database..."
|
@echo "Seeding the database..."
|
||||||
@go run -mod=mod cmd/seed/main.go
|
@go run -mod=mod cmd/seed/main.go
|
||||||
@echo "Seeding complete"
|
@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"
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
|
||||||
package docs
|
|
||||||
|
|
||||||
import "github.com/swaggo/swag"
|
|
||||||
|
|
||||||
const docTemplate = `{
|
|
||||||
"schemes": {{ marshal .Schemes }},
|
|
||||||
"swagger": "2.0",
|
|
||||||
"info": {
|
|
||||||
"description": "{{escape .Description}}",
|
|
||||||
"title": "{{.Title}}",
|
|
||||||
"contact": {},
|
|
||||||
"version": "{{.Version}}"
|
|
||||||
},
|
|
||||||
"host": "{{.Host}}",
|
|
||||||
"basePath": "{{.BasePath}}",
|
|
||||||
"paths": {
|
|
||||||
"/": {
|
|
||||||
"get": {
|
|
||||||
"description": "Get a hello message",
|
|
||||||
"produces": [
|
|
||||||
"text/plain"
|
|
||||||
],
|
|
||||||
"summary": "Say hello",
|
|
||||||
"operationId": "hello",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Hello from the Time Tracker Backend!",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"securityDefinitions": {
|
|
||||||
"BearerAuth": {
|
|
||||||
"type": "apiKey",
|
|
||||||
"name": "Authorization",
|
|
||||||
"in": "header"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
|
||||||
var SwaggerInfo = &swag.Spec{
|
|
||||||
Version: "1.0",
|
|
||||||
Host: "localhost:8080",
|
|
||||||
BasePath: "/api",
|
|
||||||
Schemes: []string{},
|
|
||||||
Title: "Time Tracker API",
|
|
||||||
Description: "This is a simple time tracker API.",
|
|
||||||
InfoInstanceName: "swagger",
|
|
||||||
SwaggerTemplate: docTemplate,
|
|
||||||
LeftDelim: "{{",
|
|
||||||
RightDelim: "}}",
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"swagger": "2.0",
|
|
||||||
"info": {
|
|
||||||
"description": "This is a simple time tracker API.",
|
|
||||||
"title": "Time Tracker API",
|
|
||||||
"contact": {},
|
|
||||||
"version": "1.0"
|
|
||||||
},
|
|
||||||
"host": "localhost:8080",
|
|
||||||
"basePath": "/api",
|
|
||||||
"paths": {
|
|
||||||
"/": {
|
|
||||||
"get": {
|
|
||||||
"description": "Get a hello message",
|
|
||||||
"produces": [
|
|
||||||
"text/plain"
|
|
||||||
],
|
|
||||||
"summary": "Say hello",
|
|
||||||
"operationId": "hello",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Hello from the Time Tracker Backend!",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"securityDefinitions": {
|
|
||||||
"BearerAuth": {
|
|
||||||
"type": "apiKey",
|
|
||||||
"name": "Authorization",
|
|
||||||
"in": "header"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
basePath: /api
|
|
||||||
host: localhost:8080
|
|
||||||
info:
|
|
||||||
contact: {}
|
|
||||||
description: This is a simple time tracker API.
|
|
||||||
title: Time Tracker API
|
|
||||||
version: "1.0"
|
|
||||||
paths:
|
|
||||||
/:
|
|
||||||
get:
|
|
||||||
description: Get a hello message
|
|
||||||
operationId: hello
|
|
||||||
produces:
|
|
||||||
- text/plain
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: Hello from the Time Tracker Backend!
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
summary: Say hello
|
|
||||||
securityDefinitions:
|
|
||||||
BearerAuth:
|
|
||||||
in: header
|
|
||||||
name: Authorization
|
|
||||||
type: apiKey
|
|
||||||
swagger: "2.0"
|
|
||||||
+11
-40
@@ -13,12 +13,12 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
swaggerFiles "github.com/swaggo/files"
|
swaggerFiles "github.com/swaggo/files"
|
||||||
ginSwagger "github.com/swaggo/gin-swagger"
|
ginSwagger "github.com/swaggo/gin-swagger"
|
||||||
_ "github.com/timetracker/backend/docs" // This line is important for swag to work
|
_ "github.com/timetracker/backend/docs"
|
||||||
|
"github.com/timetracker/backend/internal/api/middleware"
|
||||||
"github.com/timetracker/backend/internal/api/routes"
|
"github.com/timetracker/backend/internal/api/routes"
|
||||||
|
"github.com/timetracker/backend/internal/config"
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
_ "gorm.io/driver/postgres"
|
_ "gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
// GORM IMPORTS MARKER
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// @title Time Tracker API
|
// @title Time Tracker API
|
||||||
@@ -41,41 +41,13 @@ func helloHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Get database configuration with sensible defaults
|
cfg, err := config.LoadConfig()
|
||||||
dbConfig := models.DefaultDatabaseConfig()
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
// Override with environment variables if provided
|
|
||||||
if host := os.Getenv("DB_HOST"); host != "" {
|
|
||||||
dbConfig.Host = host
|
|
||||||
}
|
|
||||||
if port := os.Getenv("DB_PORT"); port != "" {
|
|
||||||
var portInt int
|
|
||||||
if _, err := fmt.Sscanf(port, "%d", &portInt); err == nil && portInt > 0 {
|
|
||||||
dbConfig.Port = portInt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if user := os.Getenv("DB_USER"); user != "" {
|
|
||||||
dbConfig.User = user
|
|
||||||
}
|
|
||||||
if password := os.Getenv("DB_PASSWORD"); password != "" {
|
|
||||||
dbConfig.Password = password
|
|
||||||
}
|
|
||||||
if dbName := os.Getenv("DB_NAME"); dbName != "" {
|
|
||||||
dbConfig.DBName = dbName
|
|
||||||
}
|
|
||||||
if sslMode := os.Getenv("DB_SSLMODE"); sslMode != "" {
|
|
||||||
dbConfig.SSLMode = sslMode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set log level based on environment
|
|
||||||
if gin.Mode() == gin.ReleaseMode {
|
|
||||||
dbConfig.LogLevel = logger.Error // Only log errors in production
|
|
||||||
} else {
|
|
||||||
dbConfig.LogLevel = logger.Info // Log more in development
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
if err := models.InitDB(dbConfig); err != nil {
|
if err := models.InitDB(cfg.Database); err != nil {
|
||||||
log.Fatalf("Error initializing database: %v", err)
|
log.Fatalf("Error initializing database: %v", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -89,10 +61,9 @@ func main() {
|
|||||||
log.Fatalf("Error migrating database: %v", err)
|
log.Fatalf("Error migrating database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed database with initial data if needed
|
// Initialize JWT keys
|
||||||
ctx := context.Background()
|
if err := middleware.InitJWTKeys(); err != nil {
|
||||||
if err := models.SeedDB(ctx); err != nil {
|
log.Fatalf("Error initializing JWT keys: %v", err)
|
||||||
log.Fatalf("Error seeding database: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Gin router
|
// Create Gin router
|
||||||
@@ -102,7 +73,7 @@ func main() {
|
|||||||
r.GET("/api", helloHandler)
|
r.GET("/api", helloHandler)
|
||||||
|
|
||||||
// Setup API routes
|
// Setup API routes
|
||||||
routes.SetupRouter(r)
|
routes.SetupRouter(r, cfg)
|
||||||
|
|
||||||
// Swagger documentation
|
// Swagger documentation
|
||||||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +11,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
dropTable := flag.String("drop_table", "", "Drop the specified table")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
// Get database configuration with sensible defaults
|
// Get database configuration with sensible defaults
|
||||||
dbConfig := models.DefaultDatabaseConfig()
|
dbConfig := models.DefaultDatabaseConfig()
|
||||||
|
|
||||||
@@ -34,7 +38,19 @@ func main() {
|
|||||||
|
|
||||||
// Test database connection with a simple query
|
// Test database connection with a simple query
|
||||||
var result int
|
var result int
|
||||||
err := db.Raw("SELECT 1").Scan(&result).Error
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("Error executing test query: %v", err)
|
log.Fatalf("Error executing test query: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,6 +13,11 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
// Parse command line flags
|
// Parse command line flags
|
||||||
verbose := false
|
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:] {
|
for _, arg := range os.Args[1:] {
|
||||||
if arg == "--verbose" || arg == "-v" {
|
if arg == "--verbose" || arg == "-v" {
|
||||||
verbose = true
|
verbose = true
|
||||||
@@ -53,7 +59,37 @@ func main() {
|
|||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
fmt.Println("Connecting to database...")
|
fmt.Println("Connecting to database...")
|
||||||
if err := models.InitDB(dbConfig); err != nil {
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
gormDB, err := models.GetGormDB(dbConfig, "postgres")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error getting gorm DB: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := gormDB.DB()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error getting sql DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *dropDB {
|
||||||
|
fmt.Printf("Dropping database %s...\n", dbConfig.DBName)
|
||||||
|
_, err = sqlDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbConfig.DBName))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error dropping database %s: %v", dbConfig.DBName, err)
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ Database %s dropped successfully\n", dbConfig.DBName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *createDB {
|
||||||
|
fmt.Printf("Creating database %s...\n", dbConfig.DBName)
|
||||||
|
_, err = sqlDB.Exec(fmt.Sprintf("CREATE DATABASE %s", dbConfig.DBName))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error creating database %s: %v", dbConfig.DBName, err)
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ Database %s created successfully\n", dbConfig.DBName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = models.InitDB(dbConfig); err != nil {
|
||||||
log.Fatalf("Error initializing database: %v", err)
|
log.Fatalf("Error initializing database: %v", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -65,7 +101,7 @@ func main() {
|
|||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
fmt.Println("Running database migrations...")
|
fmt.Println("Running database migrations...")
|
||||||
if err := models.MigrateDB(); err != nil {
|
if err = models.MigrateDB(); err != nil {
|
||||||
log.Fatalf("Error migrating database: %v", err)
|
log.Fatalf("Error migrating database: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Println("✓ Database migrations completed successfully")
|
fmt.Println("✓ Database migrations completed successfully")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/oklog/ulid/v2"
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
|
"github.com/timetracker/backend/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -101,7 +102,7 @@ func testUserModel(ctx context.Context) {
|
|||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
Password: "Test@123456",
|
Password: "Test@123456",
|
||||||
Role: models.RoleUser,
|
Role: models.RoleUser,
|
||||||
CompanyID: companyID,
|
CompanyID: &companyID,
|
||||||
HourlyRate: 50.0,
|
HourlyRate: 50.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ func testRelationships(ctx context.Context) {
|
|||||||
|
|
||||||
// Test invalid ID
|
// Test invalid ID
|
||||||
invalidID := ulid.MustNew(ulid.Timestamp(time.Now()), ulid.DefaultEntropy())
|
invalidID := ulid.MustNew(ulid.Timestamp(time.Now()), ulid.DefaultEntropy())
|
||||||
invalidUser, err := models.GetUserByID(ctx, invalidID)
|
invalidUser, err := models.GetUserByID(ctx, types.FromULID(invalidID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error getting user with invalid ID: %v", err)
|
log.Fatalf("Error getting user with invalid ID: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-63
@@ -2,56 +2,28 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"github.com/timetracker/backend/internal/config"
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Parse command line flags
|
// Parse CLI flags
|
||||||
force := false
|
_ = flag.String("config", "", "Path to .env config file")
|
||||||
for _, arg := range os.Args[1:] {
|
flag.Parse()
|
||||||
if arg == "--force" || arg == "-f" {
|
|
||||||
force = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get database configuration with sensible defaults
|
// Load configuration
|
||||||
dbConfig := models.DefaultDatabaseConfig()
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
// Override with environment variables if provided
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
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
|
// Initialize database
|
||||||
fmt.Println("Connecting to database...")
|
if err := models.InitDB(cfg.Database); err != nil {
|
||||||
if err := models.InitDB(dbConfig); err != nil {
|
|
||||||
log.Fatalf("Error initializing database: %v", err)
|
log.Fatalf("Error initializing database: %v", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -59,31 +31,62 @@ func main() {
|
|||||||
log.Printf("Error closing database connection: %v", err)
|
log.Printf("Error closing database connection: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
fmt.Println("✓ Database connection successful")
|
|
||||||
|
|
||||||
// Create context with timeout
|
// Execute seed operation
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
if err := seedDatabase(context.Background()); err != nil {
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Check if we need to seed (e.g., no companies exist)
|
|
||||||
if !force {
|
|
||||||
var count int64
|
|
||||||
db := models.GetEngine(ctx)
|
|
||||||
if err := db.Model(&models.Company{}).Count(&count).Error; err != nil {
|
|
||||||
log.Fatalf("Error checking if seeding is needed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If data already exists, skip seeding
|
|
||||||
if count > 0 {
|
|
||||||
fmt.Println("Database already contains data. Use --force to override.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed the database
|
|
||||||
fmt.Println("Seeding database with initial data...")
|
|
||||||
if err := models.SeedDB(ctx); err != nil {
|
|
||||||
log.Fatalf("Error seeding database: %v", err)
|
log.Fatalf("Error seeding database: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Println("✓ Database seeding completed successfully")
|
|
||||||
|
log.Println("Database seeding completed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedDatabase performs the database seeding operation
|
||||||
|
func seedDatabase(ctx context.Context) error {
|
||||||
|
// Check if seeding is needed
|
||||||
|
var count int64
|
||||||
|
if err := models.GetEngine(ctx).Model(&models.Company{}).Count(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("error checking if seeding is needed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If data exists, skip seeding
|
||||||
|
if count > 0 {
|
||||||
|
log.Println("Database already contains data, skipping seeding")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Seeding database with initial data...")
|
||||||
|
|
||||||
|
// Start transaction
|
||||||
|
return models.GetEngine(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Create default company
|
||||||
|
defaultCompany := models.Company{
|
||||||
|
Name: "Default Company",
|
||||||
|
}
|
||||||
|
if err := tx.Create(&defaultCompany).Error; err != nil {
|
||||||
|
return fmt.Errorf("error creating default company: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
adminUser := models.User{
|
||||||
|
Email: "admin@example.com",
|
||||||
|
Role: models.RoleAdmin,
|
||||||
|
CompanyID: &defaultCompany.ID,
|
||||||
|
HourlyRate: 100.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
pwData, err := models.HashPassword("Admin@123456")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error hashing password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUser.Salt = pwData.Salt
|
||||||
|
adminUser.Hash = pwData.Hash
|
||||||
|
|
||||||
|
if err := tx.Create(&adminUser).Error; err != nil {
|
||||||
|
return fmt.Errorf("error creating admin user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+214
-107
@@ -4112,10 +4112,12 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"billingRate": {
|
"billingRate": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"example": 100
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Development"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4123,22 +4125,28 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"billingRate": {
|
"billingRate": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"example": 100
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "a1b2c3d4e5f6"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "u1v2w3x4y5z6"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Development"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4146,22 +4154,28 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"billingRate": {
|
"billingRate": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"example": 100
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "a1b2c3d4e5f6"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "u1v2w3x4y5z6"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Development"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4169,7 +4183,8 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Acme Corp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4177,19 +4192,24 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Acme Corp"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4197,19 +4217,24 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Acme Corp"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4217,10 +4242,12 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"companyId": {
|
"companyId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "John Doe"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4228,22 +4255,28 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"companyId": {
|
"companyId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "John Doe"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4251,22 +4284,28 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"companyId": {
|
"companyId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "John Doe"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4274,10 +4313,12 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "admin@example.com"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Admin@123456"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4285,10 +4326,12 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"customerId": {
|
"customerId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Time Tracking App"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4296,22 +4339,28 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"customerId": {
|
"customerId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Time Tracking App"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4319,22 +4368,28 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"customerId": {
|
"customerId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Time Tracking App"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4342,26 +4397,33 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"activityId": {
|
"activityId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"billable": {
|
"billable": {
|
||||||
"description": "Percentage (0-100)",
|
"description": "Percentage (0-100)",
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"example": 100
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Working on the Time Tracking App"
|
||||||
},
|
},
|
||||||
"end": {
|
"end": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T17:00:00Z"
|
||||||
},
|
},
|
||||||
"projectId": {
|
"projectId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"start": {
|
"start": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T08:00:00Z"
|
||||||
},
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4369,38 +4431,49 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"activityId": {
|
"activityId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"billable": {
|
"billable": {
|
||||||
"description": "Percentage (0-100)",
|
"description": "Percentage (0-100)",
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"example": 100
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Working on the Time Tracking App"
|
||||||
},
|
},
|
||||||
"end": {
|
"end": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T17:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"projectId": {
|
"projectId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"start": {
|
"start": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T08:00:00Z"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4408,38 +4481,49 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"activityId": {
|
"activityId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"billable": {
|
"billable": {
|
||||||
"description": "Percentage (0-100)",
|
"description": "Percentage (0-100)",
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"example": 100
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Working on the Time Tracking App"
|
||||||
},
|
},
|
||||||
"end": {
|
"end": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T17:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"projectId": {
|
"projectId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"start": {
|
"start": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T08:00:00Z"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4447,7 +4531,8 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"token": {
|
"token": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"$ref": "#/definitions/dto.UserDto"
|
"$ref": "#/definitions/dto.UserDto"
|
||||||
@@ -4458,19 +4543,24 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"companyId": {
|
"companyId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "test@example.com"
|
||||||
},
|
},
|
||||||
"hourlyRate": {
|
"hourlyRate": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"example": 50
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "password123"
|
||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "admin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4478,28 +4568,36 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"companyId": {
|
"companyId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "test@example.com"
|
||||||
},
|
},
|
||||||
"hourlyRate": {
|
"hourlyRate": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"example": 50
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "admin"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4507,31 +4605,40 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"companyId": {
|
"companyId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "test@example.com"
|
||||||
},
|
},
|
||||||
"hourlyRate": {
|
"hourlyRate": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"example": 50
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "password123"
|
||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "admin"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+214
-107
@@ -4106,10 +4106,12 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"billingRate": {
|
"billingRate": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"example": 100
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Development"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4117,22 +4119,28 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"billingRate": {
|
"billingRate": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"example": 100
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "a1b2c3d4e5f6"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "u1v2w3x4y5z6"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Development"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4140,22 +4148,28 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"billingRate": {
|
"billingRate": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"example": 100
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "a1b2c3d4e5f6"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "u1v2w3x4y5z6"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Development"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4163,7 +4177,8 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Acme Corp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4171,19 +4186,24 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Acme Corp"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4191,19 +4211,24 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Acme Corp"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4211,10 +4236,12 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"companyId": {
|
"companyId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "John Doe"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4222,22 +4249,28 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"companyId": {
|
"companyId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "John Doe"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4245,22 +4278,28 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"companyId": {
|
"companyId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "John Doe"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4268,10 +4307,12 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "admin@example.com"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Admin@123456"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4279,10 +4320,12 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"customerId": {
|
"customerId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Time Tracking App"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4290,22 +4333,28 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"customerId": {
|
"customerId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Time Tracking App"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4313,22 +4362,28 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"customerId": {
|
"customerId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Time Tracking App"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4336,26 +4391,33 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"activityId": {
|
"activityId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"billable": {
|
"billable": {
|
||||||
"description": "Percentage (0-100)",
|
"description": "Percentage (0-100)",
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"example": 100
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Working on the Time Tracking App"
|
||||||
},
|
},
|
||||||
"end": {
|
"end": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T17:00:00Z"
|
||||||
},
|
},
|
||||||
"projectId": {
|
"projectId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"start": {
|
"start": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T08:00:00Z"
|
||||||
},
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4363,38 +4425,49 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"activityId": {
|
"activityId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"billable": {
|
"billable": {
|
||||||
"description": "Percentage (0-100)",
|
"description": "Percentage (0-100)",
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"example": 100
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Working on the Time Tracking App"
|
||||||
},
|
},
|
||||||
"end": {
|
"end": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T17:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"projectId": {
|
"projectId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"start": {
|
"start": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T08:00:00Z"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4402,38 +4475,49 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"activityId": {
|
"activityId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"billable": {
|
"billable": {
|
||||||
"description": "Percentage (0-100)",
|
"description": "Percentage (0-100)",
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"example": 100
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "Working on the Time Tracking App"
|
||||||
},
|
},
|
||||||
"end": {
|
"end": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T17:00:00Z"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"projectId": {
|
"projectId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"start": {
|
"start": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T08:00:00Z"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4441,7 +4525,8 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"token": {
|
"token": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"$ref": "#/definitions/dto.UserDto"
|
"$ref": "#/definitions/dto.UserDto"
|
||||||
@@ -4452,19 +4537,24 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"companyId": {
|
"companyId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "test@example.com"
|
||||||
},
|
},
|
||||||
"hourlyRate": {
|
"hourlyRate": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"example": 50
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "password123"
|
||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "admin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4472,28 +4562,36 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"companyId": {
|
"companyId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "test@example.com"
|
||||||
},
|
},
|
||||||
"hourlyRate": {
|
"hourlyRate": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"example": 50
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "admin"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4501,31 +4599,40 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"companyId": {
|
"companyId": {
|
||||||
"type": "integer"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "test@example.com"
|
||||||
},
|
},
|
||||||
"hourlyRate": {
|
"hourlyRate": {
|
||||||
"type": "number"
|
"type": "number",
|
||||||
|
"example": 50
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"lastEditorID": {
|
"lastEditorID": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "01HGW2BBG0000000000000000"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "password123"
|
||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "admin"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"example": "2024-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+125
-18
@@ -3,225 +3,310 @@ definitions:
|
|||||||
dto.ActivityCreateDto:
|
dto.ActivityCreateDto:
|
||||||
properties:
|
properties:
|
||||||
billingRate:
|
billingRate:
|
||||||
|
example: 100
|
||||||
type: number
|
type: number
|
||||||
name:
|
name:
|
||||||
|
example: Development
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.ActivityDto:
|
dto.ActivityDto:
|
||||||
properties:
|
properties:
|
||||||
billingRate:
|
billingRate:
|
||||||
|
example: 100
|
||||||
type: number
|
type: number
|
||||||
createdAt:
|
createdAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
id:
|
id:
|
||||||
|
example: a1b2c3d4e5f6
|
||||||
type: string
|
type: string
|
||||||
lastEditorID:
|
lastEditorID:
|
||||||
|
example: u1v2w3x4y5z6
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
|
example: Development
|
||||||
type: string
|
type: string
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.ActivityUpdateDto:
|
dto.ActivityUpdateDto:
|
||||||
properties:
|
properties:
|
||||||
billingRate:
|
billingRate:
|
||||||
|
example: 100
|
||||||
type: number
|
type: number
|
||||||
createdAt:
|
createdAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
id:
|
id:
|
||||||
|
example: a1b2c3d4e5f6
|
||||||
type: string
|
type: string
|
||||||
lastEditorID:
|
lastEditorID:
|
||||||
|
example: u1v2w3x4y5z6
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
|
example: Development
|
||||||
type: string
|
type: string
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.CompanyCreateDto:
|
dto.CompanyCreateDto:
|
||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
|
example: Acme Corp
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.CompanyDto:
|
dto.CompanyDto:
|
||||||
properties:
|
properties:
|
||||||
createdAt:
|
createdAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
id:
|
id:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
lastEditorID:
|
lastEditorID:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
|
example: Acme Corp
|
||||||
type: string
|
type: string
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.CompanyUpdateDto:
|
dto.CompanyUpdateDto:
|
||||||
properties:
|
properties:
|
||||||
createdAt:
|
createdAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
id:
|
id:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
lastEditorID:
|
lastEditorID:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
|
example: Acme Corp
|
||||||
type: string
|
type: string
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.CustomerCreateDto:
|
dto.CustomerCreateDto:
|
||||||
properties:
|
properties:
|
||||||
companyId:
|
companyId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
name:
|
name:
|
||||||
|
example: John Doe
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.CustomerDto:
|
dto.CustomerDto:
|
||||||
properties:
|
properties:
|
||||||
companyId:
|
companyId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
createdAt:
|
createdAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
id:
|
id:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
lastEditorID:
|
lastEditorID:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
|
example: John Doe
|
||||||
type: string
|
type: string
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.CustomerUpdateDto:
|
dto.CustomerUpdateDto:
|
||||||
properties:
|
properties:
|
||||||
companyId:
|
companyId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
createdAt:
|
createdAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
id:
|
id:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
lastEditorID:
|
lastEditorID:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
|
example: John Doe
|
||||||
type: string
|
type: string
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.LoginDto:
|
dto.LoginDto:
|
||||||
properties:
|
properties:
|
||||||
email:
|
email:
|
||||||
|
example: admin@example.com
|
||||||
type: string
|
type: string
|
||||||
password:
|
password:
|
||||||
|
example: Admin@123456
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.ProjectCreateDto:
|
dto.ProjectCreateDto:
|
||||||
properties:
|
properties:
|
||||||
customerId:
|
customerId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
name:
|
name:
|
||||||
|
example: Time Tracking App
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.ProjectDto:
|
dto.ProjectDto:
|
||||||
properties:
|
properties:
|
||||||
createdAt:
|
createdAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
customerId:
|
customerId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
id:
|
id:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
lastEditorID:
|
lastEditorID:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
|
example: Time Tracking App
|
||||||
type: string
|
type: string
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.ProjectUpdateDto:
|
dto.ProjectUpdateDto:
|
||||||
properties:
|
properties:
|
||||||
createdAt:
|
createdAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
customerId:
|
customerId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
id:
|
id:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
lastEditorID:
|
lastEditorID:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
|
example: Time Tracking App
|
||||||
type: string
|
type: string
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.TimeEntryCreateDto:
|
dto.TimeEntryCreateDto:
|
||||||
properties:
|
properties:
|
||||||
activityId:
|
activityId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
billable:
|
billable:
|
||||||
description: Percentage (0-100)
|
description: Percentage (0-100)
|
||||||
|
example: 100
|
||||||
type: integer
|
type: integer
|
||||||
description:
|
description:
|
||||||
|
example: Working on the Time Tracking App
|
||||||
type: string
|
type: string
|
||||||
end:
|
end:
|
||||||
|
example: "2024-01-01T17:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
projectId:
|
projectId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
start:
|
start:
|
||||||
|
example: "2024-01-01T08:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
userId:
|
userId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.TimeEntryDto:
|
dto.TimeEntryDto:
|
||||||
properties:
|
properties:
|
||||||
activityId:
|
activityId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
billable:
|
billable:
|
||||||
description: Percentage (0-100)
|
description: Percentage (0-100)
|
||||||
|
example: 100
|
||||||
type: integer
|
type: integer
|
||||||
createdAt:
|
createdAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
description:
|
description:
|
||||||
|
example: Working on the Time Tracking App
|
||||||
type: string
|
type: string
|
||||||
end:
|
end:
|
||||||
|
example: "2024-01-01T17:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
id:
|
id:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
lastEditorID:
|
lastEditorID:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
projectId:
|
projectId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
start:
|
start:
|
||||||
|
example: "2024-01-01T08:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
userId:
|
userId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.TimeEntryUpdateDto:
|
dto.TimeEntryUpdateDto:
|
||||||
properties:
|
properties:
|
||||||
activityId:
|
activityId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
billable:
|
billable:
|
||||||
description: Percentage (0-100)
|
description: Percentage (0-100)
|
||||||
|
example: 100
|
||||||
type: integer
|
type: integer
|
||||||
createdAt:
|
createdAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
description:
|
description:
|
||||||
|
example: Working on the Time Tracking App
|
||||||
type: string
|
type: string
|
||||||
end:
|
end:
|
||||||
|
example: "2024-01-01T17:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
id:
|
id:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
lastEditorID:
|
lastEditorID:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
projectId:
|
projectId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
start:
|
start:
|
||||||
|
example: "2024-01-01T08:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
userId:
|
userId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.TokenDto:
|
dto.TokenDto:
|
||||||
properties:
|
properties:
|
||||||
token:
|
token:
|
||||||
|
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
|
||||||
type: string
|
type: string
|
||||||
user:
|
user:
|
||||||
$ref: '#/definitions/dto.UserDto'
|
$ref: '#/definitions/dto.UserDto'
|
||||||
@@ -229,54 +314,76 @@ definitions:
|
|||||||
dto.UserCreateDto:
|
dto.UserCreateDto:
|
||||||
properties:
|
properties:
|
||||||
companyId:
|
companyId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
email:
|
email:
|
||||||
|
example: test@example.com
|
||||||
type: string
|
type: string
|
||||||
hourlyRate:
|
hourlyRate:
|
||||||
|
example: 50
|
||||||
type: number
|
type: number
|
||||||
password:
|
password:
|
||||||
|
example: password123
|
||||||
type: string
|
type: string
|
||||||
role:
|
role:
|
||||||
|
example: admin
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.UserDto:
|
dto.UserDto:
|
||||||
properties:
|
properties:
|
||||||
companyId:
|
companyId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
createdAt:
|
createdAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
email:
|
email:
|
||||||
|
example: test@example.com
|
||||||
type: string
|
type: string
|
||||||
hourlyRate:
|
hourlyRate:
|
||||||
|
example: 50
|
||||||
type: number
|
type: number
|
||||||
id:
|
id:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
lastEditorID:
|
lastEditorID:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
role:
|
role:
|
||||||
|
example: admin
|
||||||
type: string
|
type: string
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
dto.UserUpdateDto:
|
dto.UserUpdateDto:
|
||||||
properties:
|
properties:
|
||||||
companyId:
|
companyId:
|
||||||
type: integer
|
example: 01HGW2BBG0000000000000000
|
||||||
|
type: string
|
||||||
createdAt:
|
createdAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
email:
|
email:
|
||||||
|
example: test@example.com
|
||||||
type: string
|
type: string
|
||||||
hourlyRate:
|
hourlyRate:
|
||||||
|
example: 50
|
||||||
type: number
|
type: number
|
||||||
id:
|
id:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
lastEditorID:
|
lastEditorID:
|
||||||
|
example: 01HGW2BBG0000000000000000
|
||||||
type: string
|
type: string
|
||||||
password:
|
password:
|
||||||
|
example: password123
|
||||||
type: string
|
type: string
|
||||||
role:
|
role:
|
||||||
|
example: admin
|
||||||
type: string
|
type: string
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
example: "2024-01-01T00:00:00Z"
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
utils.ErrorInfo:
|
utils.ErrorInfo:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ go 1.23.6
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/oklog/ulid/v2 v2.1.0
|
github.com/oklog/ulid/v2 v2.1.0
|
||||||
github.com/swaggo/files v1.0.1
|
github.com/swaggo/files v1.0.1
|
||||||
github.com/swaggo/gin-swagger v1.6.0
|
github.com/swaggo/gin-swagger v1.6.0
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
|||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActivityDto struct {
|
||||||
|
ID string `json:"id" example:"a1b2c3d4e5f6"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
LastEditorID string `json:"lastEditorID" example:"u1v2w3x4y5z6"`
|
||||||
|
Name string `json:"name" example:"Development"`
|
||||||
|
BillingRate float64 `json:"billingRate" example:"100.00"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityCreateDto struct {
|
||||||
|
Name string `json:"name" example:"Development"`
|
||||||
|
BillingRate float64 `json:"billingRate" example:"100.00"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityUpdateDto struct {
|
||||||
|
ID string `json:"id" example:"a1b2c3d4e5f6"`
|
||||||
|
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
LastEditorID *string `json:"lastEditorID" example:"u1v2w3x4y5z6"`
|
||||||
|
Name *string `json:"name" example:"Development"`
|
||||||
|
BillingRate *float64 `json:"billingRate" example:"100.00"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// LoginDto represents the login request
|
||||||
|
type LoginDto struct {
|
||||||
|
Email string `json:"email" example:"admin@example.com"`
|
||||||
|
Password string `json:"password" example:"Admin@123456"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenDto represents the response after successful authentication
|
||||||
|
type TokenDto struct {
|
||||||
|
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"`
|
||||||
|
User UserDto `json:"user"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CompanyDto struct {
|
||||||
|
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||||
|
Name string `json:"name" example:"Acme Corp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompanyCreateDto struct {
|
||||||
|
Name string `json:"name" example:"Acme Corp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompanyUpdateDto struct {
|
||||||
|
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
Name *string `json:"name" example:"Acme Corp"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/timetracker/backend/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CustomerDto struct {
|
||||||
|
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||||
|
Name string `json:"name" example:"John Doe"`
|
||||||
|
CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
OwnerUserID *string `json:"owningUserID" example:"01HGW2BBG0000000000000000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomerCreateDto struct {
|
||||||
|
Name string `json:"name" example:"John Doe"`
|
||||||
|
CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomerUpdateDto struct {
|
||||||
|
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||||
|
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
LastEditorID *string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||||
|
Name *string `json:"name" example:"John Doe"`
|
||||||
|
CompanyID types.Nullable[string] `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
OwnerUserID types.Nullable[string] `json:"owningUserID" example:"01HGW2BBG0000000000000000"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/timetracker/backend/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProjectDto struct {
|
||||||
|
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||||
|
Name string `json:"name" example:"Time Tracking App"`
|
||||||
|
CustomerID *string `json:"customerId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectCreateDto struct {
|
||||||
|
Name string `json:"name" example:"Time Tracking App"`
|
||||||
|
CustomerID *string `json:"customerId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectUpdateDto struct {
|
||||||
|
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
Name *string `json:"name" example:"Time Tracking App"`
|
||||||
|
CustomerID types.Nullable[string] `json:"customerId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimeEntryDto struct {
|
||||||
|
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||||
|
UserID string `json:"userId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
ProjectID string `json:"projectId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
ActivityID string `json:"activityId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
Start time.Time `json:"start" example:"2024-01-01T08:00:00Z"`
|
||||||
|
End time.Time `json:"end" example:"2024-01-01T17:00:00Z"`
|
||||||
|
Description string `json:"description" example:"Working on the Time Tracking App"`
|
||||||
|
Billable int `json:"billable" example:"100"` // Percentage (0-100)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeEntryCreateDto struct {
|
||||||
|
UserID string `json:"userId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
ProjectID string `json:"projectId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
ActivityID string `json:"activityId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
Start time.Time `json:"start" example:"2024-01-01T08:00:00Z"`
|
||||||
|
End time.Time `json:"end" example:"2024-01-01T17:00:00Z"`
|
||||||
|
Description string `json:"description" example:"Working on the Time Tracking App"`
|
||||||
|
Billable int `json:"billable" example:"100"` // Percentage (0-100)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeEntryUpdateDto struct {
|
||||||
|
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
UserID *string `json:"userId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
ProjectID *string `json:"projectId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
ActivityID *string `json:"activityId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
Start *time.Time `json:"start" example:"2024-01-01T08:00:00Z"`
|
||||||
|
End *time.Time `json:"end" example:"2024-01-01T17:00:00Z"`
|
||||||
|
Description *string `json:"description" example:"Working on the Time Tracking App"`
|
||||||
|
Billable *int `json:"billable" example:"100"` // Percentage (0-100)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/timetracker/backend/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserDto struct {
|
||||||
|
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||||
|
Email string `json:"email" example:"test@example.com"`
|
||||||
|
Role string `json:"role" example:"admin"`
|
||||||
|
CompanyID *string `json:"companyId,omitempty" example:"01HGW2BBG0000000000000000"`
|
||||||
|
HourlyRate float64 `json:"hourlyRate" example:"50.00"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserCreateDto struct {
|
||||||
|
Email string `json:"email" example:"test@example.com"`
|
||||||
|
Password string `json:"password" example:"password123"`
|
||||||
|
Role string `json:"role" example:"admin"`
|
||||||
|
CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
HourlyRate float64 `json:"hourlyRate" example:"50.00"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserUpdateDto struct {
|
||||||
|
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||||
|
LastEditorID *string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||||
|
Email *string `json:"email" example:"test@example.com"`
|
||||||
|
Password *string `json:"password" example:"password123"`
|
||||||
|
Role *string `json:"role" example:"admin"`
|
||||||
|
CompanyID types.Nullable[string] `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||||
|
HourlyRate *float64 `json:"hourlyRate" example:"50.00"`
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"context"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/oklog/ulid/v2"
|
"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/api/utils"
|
||||||
dto "github.com/timetracker/backend/internal/dtos"
|
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
|
"github.com/timetracker/backend/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ActivityHandler handles activity-related API endpoints
|
// ActivityHandler handles activity-related API endpoints
|
||||||
@@ -31,20 +33,7 @@ func NewActivityHandler() *ActivityHandler {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /activities [get]
|
// @Router /activities [get]
|
||||||
func (h *ActivityHandler) GetActivities(c *gin.Context) {
|
func (h *ActivityHandler) GetActivities(c *gin.Context) {
|
||||||
// Get activities from the database
|
utils.HandleGetAll(c, models.GetAllActivities, convertActivityToDTO, "activities")
|
||||||
activities, err := models.GetAllActivities(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving activities: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTOs
|
|
||||||
activityDTOs := make([]dto.ActivityDto, len(activities))
|
|
||||||
for i, activity := range activities {
|
|
||||||
activityDTOs[i] = convertActivityToDTO(&activity)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, activityDTOs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActivityByID handles GET /activities/:id
|
// GetActivityByID handles GET /activities/:id
|
||||||
@@ -63,30 +52,7 @@ func (h *ActivityHandler) GetActivities(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /activities/{id} [get]
|
// @Router /activities/{id} [get]
|
||||||
func (h *ActivityHandler) GetActivityByID(c *gin.Context) {
|
func (h *ActivityHandler) GetActivityByID(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleGetByID(c, models.GetActivityByID, convertActivityToDTO, "activity")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid activity ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get activity from the database
|
|
||||||
activity, err := models.GetActivityByID(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving activity: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if activity == nil {
|
|
||||||
utils.NotFoundResponse(c, "Activity not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
activityDTO := convertActivityToDTO(activity)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, activityDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateActivity handles POST /activities
|
// CreateActivity handles POST /activities
|
||||||
@@ -104,27 +70,7 @@ func (h *ActivityHandler) GetActivityByID(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /activities [post]
|
// @Router /activities [post]
|
||||||
func (h *ActivityHandler) CreateActivity(c *gin.Context) {
|
func (h *ActivityHandler) CreateActivity(c *gin.Context) {
|
||||||
// Parse request body
|
utils.HandleCreate(c, createActivityWrapper, convertActivityToDTO, "activity")
|
||||||
var activityCreateDTO dto.ActivityCreateDto
|
|
||||||
if err := c.ShouldBindJSON(&activityCreateDTO); err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert DTO to model
|
|
||||||
activityCreate := convertCreateActivityDTOToModel(activityCreateDTO)
|
|
||||||
|
|
||||||
// Create activity in the database
|
|
||||||
activity, err := models.CreateActivity(c.Request.Context(), activityCreate)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error creating activity: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
activityDTO := convertActivityToDTO(activity)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusCreated, activityDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateActivity handles PUT /activities/:id
|
// UpdateActivity handles PUT /activities/:id
|
||||||
@@ -144,43 +90,7 @@ func (h *ActivityHandler) CreateActivity(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /activities/{id} [put]
|
// @Router /activities/{id} [put]
|
||||||
func (h *ActivityHandler) UpdateActivity(c *gin.Context) {
|
func (h *ActivityHandler) UpdateActivity(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleUpdate(c, models.UpdateActivity, convertActivityToDTO, prepareActivityUpdate, "activity")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid activity ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse request body
|
|
||||||
var activityUpdateDTO dto.ActivityUpdateDto
|
|
||||||
if err := c.ShouldBindJSON(&activityUpdateDTO); err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set ID from URL
|
|
||||||
activityUpdateDTO.ID = id.String()
|
|
||||||
|
|
||||||
// Convert DTO to model
|
|
||||||
activityUpdate := convertUpdateActivityDTOToModel(activityUpdateDTO)
|
|
||||||
|
|
||||||
// Update activity in the database
|
|
||||||
activity, err := models.UpdateActivity(c.Request.Context(), activityUpdate)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error updating activity: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if activity == nil {
|
|
||||||
utils.NotFoundResponse(c, "Activity not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
activityDTO := convertActivityToDTO(activity)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, activityDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteActivity handles DELETE /activities/:id
|
// DeleteActivity handles DELETE /activities/:id
|
||||||
@@ -198,22 +108,7 @@ func (h *ActivityHandler) UpdateActivity(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /activities/{id} [delete]
|
// @Router /activities/{id} [delete]
|
||||||
func (h *ActivityHandler) DeleteActivity(c *gin.Context) {
|
func (h *ActivityHandler) DeleteActivity(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleDelete(c, models.DeleteActivity, "activity")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid activity ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete activity from the database
|
|
||||||
err = models.DeleteActivity(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error deleting activity: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusNoContent, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for DTO conversion
|
// Helper functions for DTO conversion
|
||||||
@@ -238,7 +133,7 @@ func convertCreateActivityDTOToModel(dto dto.ActivityCreateDto) models.ActivityC
|
|||||||
func convertUpdateActivityDTOToModel(dto dto.ActivityUpdateDto) models.ActivityUpdate {
|
func convertUpdateActivityDTOToModel(dto dto.ActivityUpdateDto) models.ActivityUpdate {
|
||||||
id, _ := ulid.Parse(dto.ID)
|
id, _ := ulid.Parse(dto.ID)
|
||||||
update := models.ActivityUpdate{
|
update := models.ActivityUpdate{
|
||||||
ID: id,
|
ID: types.FromULID(id),
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.Name != nil {
|
if dto.Name != nil {
|
||||||
@@ -251,3 +146,35 @@ func convertUpdateActivityDTOToModel(dto dto.ActivityUpdateDto) models.ActivityU
|
|||||||
|
|
||||||
return update
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"context"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/api/utils"
|
||||||
dto "github.com/timetracker/backend/internal/dtos"
|
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
|
"github.com/timetracker/backend/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CompanyHandler handles company-related API endpoints
|
// CompanyHandler handles company-related API endpoints
|
||||||
@@ -31,20 +32,7 @@ func NewCompanyHandler() *CompanyHandler {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /companies [get]
|
// @Router /companies [get]
|
||||||
func (h *CompanyHandler) GetCompanies(c *gin.Context) {
|
func (h *CompanyHandler) GetCompanies(c *gin.Context) {
|
||||||
// Get companies from the database
|
utils.HandleGetAll(c, models.GetAllCompanies, convertCompanyToDTO, "companies")
|
||||||
companies, err := models.GetAllCompanies(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving companies: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTOs
|
|
||||||
companyDTOs := make([]dto.CompanyDto, len(companies))
|
|
||||||
for i, company := range companies {
|
|
||||||
companyDTOs[i] = convertCompanyToDTO(&company)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, companyDTOs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCompanyByID handles GET /companies/:id
|
// GetCompanyByID handles GET /companies/:id
|
||||||
@@ -63,30 +51,7 @@ func (h *CompanyHandler) GetCompanies(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /companies/{id} [get]
|
// @Router /companies/{id} [get]
|
||||||
func (h *CompanyHandler) GetCompanyByID(c *gin.Context) {
|
func (h *CompanyHandler) GetCompanyByID(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleGetByID(c, models.GetCompanyByID, convertCompanyToDTO, "company")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid company ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get company from the database
|
|
||||||
company, err := models.GetCompanyByID(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving company: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if company == nil {
|
|
||||||
utils.NotFoundResponse(c, "Company not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
companyDTO := convertCompanyToDTO(company)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, companyDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCompany handles POST /companies
|
// CreateCompany handles POST /companies
|
||||||
@@ -104,27 +69,7 @@ func (h *CompanyHandler) GetCompanyByID(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /companies [post]
|
// @Router /companies [post]
|
||||||
func (h *CompanyHandler) CreateCompany(c *gin.Context) {
|
func (h *CompanyHandler) CreateCompany(c *gin.Context) {
|
||||||
// Parse request body
|
utils.HandleCreate(c, createCompanyWrapper, convertCompanyToDTO, "company")
|
||||||
var companyCreateDTO dto.CompanyCreateDto
|
|
||||||
if err := c.ShouldBindJSON(&companyCreateDTO); err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert DTO to model
|
|
||||||
companyCreate := convertCreateCompanyDTOToModel(companyCreateDTO)
|
|
||||||
|
|
||||||
// Create company in the database
|
|
||||||
company, err := models.CreateCompany(c.Request.Context(), companyCreate)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error creating company: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
companyDTO := convertCompanyToDTO(company)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusCreated, companyDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCompany handles PUT /companies/:id
|
// UpdateCompany handles PUT /companies/:id
|
||||||
@@ -144,43 +89,7 @@ func (h *CompanyHandler) CreateCompany(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /companies/{id} [put]
|
// @Router /companies/{id} [put]
|
||||||
func (h *CompanyHandler) UpdateCompany(c *gin.Context) {
|
func (h *CompanyHandler) UpdateCompany(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleUpdate(c, models.UpdateCompany, convertCompanyToDTO, prepareCompanyUpdate, "company")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid company ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse request body
|
|
||||||
var companyUpdateDTO dto.CompanyUpdateDto
|
|
||||||
if err := c.ShouldBindJSON(&companyUpdateDTO); err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set ID from URL
|
|
||||||
companyUpdateDTO.ID = id.String()
|
|
||||||
|
|
||||||
// Convert DTO to model
|
|
||||||
companyUpdate := convertUpdateCompanyDTOToModel(companyUpdateDTO)
|
|
||||||
|
|
||||||
// Update company in the database
|
|
||||||
company, err := models.UpdateCompany(c.Request.Context(), companyUpdate)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error updating company: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if company == nil {
|
|
||||||
utils.NotFoundResponse(c, "Company not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
companyDTO := convertCompanyToDTO(company)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, companyDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteCompany handles DELETE /companies/:id
|
// DeleteCompany handles DELETE /companies/:id
|
||||||
@@ -198,22 +107,7 @@ func (h *CompanyHandler) UpdateCompany(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /companies/{id} [delete]
|
// @Router /companies/{id} [delete]
|
||||||
func (h *CompanyHandler) DeleteCompany(c *gin.Context) {
|
func (h *CompanyHandler) DeleteCompany(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleDelete(c, models.DeleteCompany, "company")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid company ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete company from the database
|
|
||||||
err = models.DeleteCompany(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error deleting company: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusNoContent, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for DTO conversion
|
// Helper functions for DTO conversion
|
||||||
@@ -233,8 +127,7 @@ func convertCreateCompanyDTOToModel(dto dto.CompanyCreateDto) models.CompanyCrea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUpdateCompanyDTOToModel(dto dto.CompanyUpdateDto) models.CompanyUpdate {
|
func convertUpdateCompanyDTOToModel(dto dto.CompanyUpdateDto, id types.ULID) models.CompanyUpdate {
|
||||||
id, _ := ulid.Parse(dto.ID)
|
|
||||||
update := models.CompanyUpdate{
|
update := models.CompanyUpdate{
|
||||||
ID: id,
|
ID: id,
|
||||||
}
|
}
|
||||||
@@ -245,3 +138,32 @@ func convertUpdateCompanyDTOToModel(dto dto.CompanyUpdateDto) models.CompanyUpda
|
|||||||
|
|
||||||
return update
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/oklog/ulid/v2"
|
"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/api/utils"
|
||||||
dto "github.com/timetracker/backend/internal/dtos"
|
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
|
"github.com/timetracker/backend/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CustomerHandler handles customer-related API endpoints
|
// CustomerHandler handles customer-related API endpoints
|
||||||
@@ -32,20 +35,7 @@ func NewCustomerHandler() *CustomerHandler {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /customers [get]
|
// @Router /customers [get]
|
||||||
func (h *CustomerHandler) GetCustomers(c *gin.Context) {
|
func (h *CustomerHandler) GetCustomers(c *gin.Context) {
|
||||||
// Get customers from the database
|
utils.HandleGetAll(c, models.GetAllCustomers, convertCustomerToDTO, "customers")
|
||||||
customers, err := models.GetAllCustomers(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving customers: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTOs
|
|
||||||
customerDTOs := make([]dto.CustomerDto, len(customers))
|
|
||||||
for i, customer := range customers {
|
|
||||||
customerDTOs[i] = convertCustomerToDTO(&customer)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, customerDTOs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCustomerByID handles GET /customers/:id
|
// GetCustomerByID handles GET /customers/:id
|
||||||
@@ -64,30 +54,7 @@ func (h *CustomerHandler) GetCustomers(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /customers/{id} [get]
|
// @Router /customers/{id} [get]
|
||||||
func (h *CustomerHandler) GetCustomerByID(c *gin.Context) {
|
func (h *CustomerHandler) GetCustomerByID(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleGetByID(c, models.GetCustomerByID, convertCustomerToDTO, "customer")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid customer ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get customer from the database
|
|
||||||
customer, err := models.GetCustomerByID(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving customer: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if customer == nil {
|
|
||||||
utils.NotFoundResponse(c, "Customer not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
customerDTO := convertCustomerToDTO(customer)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, customerDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCustomersByCompanyID handles GET /customers/company/:companyId
|
// GetCustomersByCompanyID handles GET /customers/company/:companyId
|
||||||
@@ -109,24 +76,26 @@ func (h *CustomerHandler) GetCustomersByCompanyID(c *gin.Context) {
|
|||||||
companyIDStr := c.Param("companyId")
|
companyIDStr := c.Param("companyId")
|
||||||
companyID, err := parseCompanyID(companyIDStr)
|
companyID, err := parseCompanyID(companyIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.BadRequestResponse(c, "Invalid company ID format")
|
responses.BadRequestResponse(c, "Invalid company ID format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get customers from the database
|
// Create a wrapper function that takes a ULID but calls the original function with an int
|
||||||
customers, err := models.GetCustomersByCompanyID(c.Request.Context(), companyID)
|
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 {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error retrieving customers: "+err.Error())
|
responses.InternalErrorResponse(c, "Error retrieving customers: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
customerDTOs := make([]dto.CustomerDto, len(customers))
|
customerDTOs := utils.ConvertToDTO(customers, convertCustomerToDTO)
|
||||||
for i, customer := range customers {
|
|
||||||
customerDTOs[i] = convertCustomerToDTO(&customer)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, customerDTOs)
|
responses.SuccessResponse(c, http.StatusOK, customerDTOs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCustomer handles POST /customers
|
// CreateCustomer handles POST /customers
|
||||||
@@ -144,27 +113,19 @@ func (h *CustomerHandler) GetCustomersByCompanyID(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /customers [post]
|
// @Router /customers [post]
|
||||||
func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
|
func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
|
||||||
// Parse request body
|
// We need to use a custom wrapper for CreateCustomer because we need to get the user ID from the context
|
||||||
var customerCreateDTO dto.CustomerCreateDto
|
userID, err := middleware.GetUserIDFromContext(c)
|
||||||
if err := c.ShouldBindJSON(&customerCreateDTO); err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert DTO to model
|
|
||||||
customerCreate := convertCreateCustomerDTOToModel(customerCreateDTO)
|
|
||||||
|
|
||||||
// Create customer in the database
|
|
||||||
customer, err := models.CreateCustomer(c.Request.Context(), customerCreate)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error creating customer: "+err.Error())
|
responses.UnauthorizedResponse(c, "User not authenticated")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to DTO
|
// Use a closure to capture the userID
|
||||||
customerDTO := convertCustomerToDTO(customer)
|
createFn := func(ctx context.Context, createDTO dto.CustomerCreateDto) (*models.Customer, error) {
|
||||||
|
return createCustomerWrapper(ctx, createDTO, userID)
|
||||||
|
}
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusCreated, customerDTO)
|
utils.HandleCreate(c, createFn, convertCustomerToDTO, "customer")
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCustomer handles PUT /customers/:id
|
// UpdateCustomer handles PUT /customers/:id
|
||||||
@@ -184,43 +145,7 @@ func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /customers/{id} [put]
|
// @Router /customers/{id} [put]
|
||||||
func (h *CustomerHandler) UpdateCustomer(c *gin.Context) {
|
func (h *CustomerHandler) UpdateCustomer(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleUpdate(c, models.UpdateCustomer, convertCustomerToDTO, prepareCustomerUpdate, "customer")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid customer ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse request body
|
|
||||||
var customerUpdateDTO dto.CustomerUpdateDto
|
|
||||||
if err := c.ShouldBindJSON(&customerUpdateDTO); err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set ID from URL
|
|
||||||
customerUpdateDTO.ID = id.String()
|
|
||||||
|
|
||||||
// Convert DTO to model
|
|
||||||
customerUpdate := convertUpdateCustomerDTOToModel(customerUpdateDTO)
|
|
||||||
|
|
||||||
// Update customer in the database
|
|
||||||
customer, err := models.UpdateCustomer(c.Request.Context(), customerUpdate)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error updating customer: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if customer == nil {
|
|
||||||
utils.NotFoundResponse(c, "Customer not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
customerDTO := convertCustomerToDTO(customer)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, customerDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteCustomer handles DELETE /customers/:id
|
// DeleteCustomer handles DELETE /customers/:id
|
||||||
@@ -238,45 +163,48 @@ func (h *CustomerHandler) UpdateCustomer(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /customers/{id} [delete]
|
// @Router /customers/{id} [delete]
|
||||||
func (h *CustomerHandler) DeleteCustomer(c *gin.Context) {
|
func (h *CustomerHandler) DeleteCustomer(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleDelete(c, models.DeleteCustomer, "customer")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid customer ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete customer from the database
|
|
||||||
err = models.DeleteCustomer(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error deleting customer: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusNoContent, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for DTO conversion
|
// Helper functions for DTO conversion
|
||||||
|
|
||||||
func convertCustomerToDTO(customer *models.Customer) dto.CustomerDto {
|
func convertCustomerToDTO(customer *models.Customer) dto.CustomerDto {
|
||||||
|
var companyID *string
|
||||||
|
if customer.CompanyID != nil {
|
||||||
|
s := customer.CompanyID.String()
|
||||||
|
companyID = &s
|
||||||
|
}
|
||||||
return dto.CustomerDto{
|
return dto.CustomerDto{
|
||||||
ID: customer.ID.String(),
|
ID: customer.ID.String(),
|
||||||
CreatedAt: customer.CreatedAt,
|
CreatedAt: customer.CreatedAt,
|
||||||
UpdatedAt: customer.UpdatedAt,
|
UpdatedAt: customer.UpdatedAt,
|
||||||
Name: customer.Name,
|
Name: customer.Name,
|
||||||
CompanyID: customer.CompanyID,
|
CompanyID: companyID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertCreateCustomerDTOToModel(dto dto.CustomerCreateDto) models.CustomerCreate {
|
func convertCreateCustomerDTOToModel(dto dto.CustomerCreateDto) (models.CustomerCreate, error) {
|
||||||
return models.CustomerCreate{
|
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,
|
Name: dto.Name,
|
||||||
CompanyID: dto.CompanyID,
|
CompanyID: companyID,
|
||||||
}
|
}
|
||||||
|
return create, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) models.CustomerUpdate {
|
func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) (models.CustomerUpdate, error) {
|
||||||
id, _ := ulid.Parse(dto.ID)
|
id, err := types.ULIDFromString(dto.ID)
|
||||||
|
if err != nil {
|
||||||
|
return models.CustomerUpdate{}, fmt.Errorf("invalid customer ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
update := models.CustomerUpdate{
|
update := models.CustomerUpdate{
|
||||||
ID: id,
|
ID: id,
|
||||||
}
|
}
|
||||||
@@ -285,11 +213,17 @@ func convertUpdateCustomerDTOToModel(dto dto.CustomerUpdateDto) models.CustomerU
|
|||||||
update.Name = dto.Name
|
update.Name = dto.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.CompanyID != nil {
|
if dto.CompanyID.Valid {
|
||||||
update.CompanyID = dto.CompanyID
|
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
|
return update, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to parse company ID from string
|
// Helper function to parse company ID from string
|
||||||
@@ -298,3 +232,47 @@ func parseCompanyID(idStr string) (int, error) {
|
|||||||
_, err := fmt.Sscanf(idStr, "%d", &id)
|
_, err := fmt.Sscanf(idStr, "%d", &id)
|
||||||
return id, err
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/api/utils"
|
||||||
dto "github.com/timetracker/backend/internal/dtos"
|
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
|
"github.com/timetracker/backend/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProjectHandler handles project-related API endpoints
|
// ProjectHandler handles project-related API endpoints
|
||||||
@@ -32,20 +33,7 @@ func NewProjectHandler() *ProjectHandler {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /projects [get]
|
// @Router /projects [get]
|
||||||
func (h *ProjectHandler) GetProjects(c *gin.Context) {
|
func (h *ProjectHandler) GetProjects(c *gin.Context) {
|
||||||
// Get projects from the database
|
utils.HandleGetAll(c, models.GetAllProjects, convertProjectToDTO, "projects")
|
||||||
projects, err := models.GetAllProjects(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving projects: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTOs
|
|
||||||
projectDTOs := make([]dto.ProjectDto, len(projects))
|
|
||||||
for i, project := range projects {
|
|
||||||
projectDTOs[i] = convertProjectToDTO(&project)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, projectDTOs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProjectsWithCustomers handles GET /projects/with-customers
|
// GetProjectsWithCustomers handles GET /projects/with-customers
|
||||||
@@ -61,20 +49,7 @@ func (h *ProjectHandler) GetProjects(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /projects/with-customers [get]
|
// @Router /projects/with-customers [get]
|
||||||
func (h *ProjectHandler) GetProjectsWithCustomers(c *gin.Context) {
|
func (h *ProjectHandler) GetProjectsWithCustomers(c *gin.Context) {
|
||||||
// Get projects with customers from the database
|
utils.HandleGetAll(c, models.GetAllProjectsWithCustomers, convertProjectToDTO, "projects with customers")
|
||||||
projects, err := models.GetAllProjectsWithCustomers(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving projects: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTOs
|
|
||||||
projectDTOs := make([]dto.ProjectDto, len(projects))
|
|
||||||
for i, project := range projects {
|
|
||||||
projectDTOs[i] = convertProjectToDTO(&project)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, projectDTOs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProjectByID handles GET /projects/:id
|
// GetProjectByID handles GET /projects/:id
|
||||||
@@ -93,30 +68,7 @@ func (h *ProjectHandler) GetProjectsWithCustomers(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /projects/{id} [get]
|
// @Router /projects/{id} [get]
|
||||||
func (h *ProjectHandler) GetProjectByID(c *gin.Context) {
|
func (h *ProjectHandler) GetProjectByID(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleGetByID(c, models.GetProjectByID, convertProjectToDTO, "project")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid project ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get project from the database
|
|
||||||
project, err := models.GetProjectByID(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving project: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if project == nil {
|
|
||||||
utils.NotFoundResponse(c, "Project not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
projectDTO := convertProjectToDTO(project)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, projectDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProjectsByCustomerID handles GET /projects/customer/:customerId
|
// GetProjectsByCustomerID handles GET /projects/customer/:customerId
|
||||||
@@ -134,28 +86,7 @@ func (h *ProjectHandler) GetProjectByID(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /projects/customer/{customerId} [get]
|
// @Router /projects/customer/{customerId} [get]
|
||||||
func (h *ProjectHandler) GetProjectsByCustomerID(c *gin.Context) {
|
func (h *ProjectHandler) GetProjectsByCustomerID(c *gin.Context) {
|
||||||
// Parse customer ID from URL
|
utils.HandleGetByFilter(c, models.GetProjectsByCustomerID, convertProjectToDTO, "projects", "customerId")
|
||||||
customerIDStr := c.Param("customerId")
|
|
||||||
customerID, err := ulid.Parse(customerIDStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid customer ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get projects from the database
|
|
||||||
projects, err := models.GetProjectsByCustomerID(c.Request.Context(), customerID)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving projects: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTOs
|
|
||||||
projectDTOs := make([]dto.ProjectDto, len(projects))
|
|
||||||
for i, project := range projects {
|
|
||||||
projectDTOs[i] = convertProjectToDTO(&project)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, projectDTOs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateProject handles POST /projects
|
// CreateProject handles POST /projects
|
||||||
@@ -173,31 +104,7 @@ func (h *ProjectHandler) GetProjectsByCustomerID(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /projects [post]
|
// @Router /projects [post]
|
||||||
func (h *ProjectHandler) CreateProject(c *gin.Context) {
|
func (h *ProjectHandler) CreateProject(c *gin.Context) {
|
||||||
// Parse request body
|
utils.HandleCreate(c, createProjectWrapper, convertProjectToDTO, "project")
|
||||||
var projectCreateDTO dto.ProjectCreateDto
|
|
||||||
if err := c.ShouldBindJSON(&projectCreateDTO); err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert DTO to model
|
|
||||||
projectCreate, err := convertCreateProjectDTOToModel(projectCreateDTO)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create project in the database
|
|
||||||
project, err := models.CreateProject(c.Request.Context(), projectCreate)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error creating project: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
projectDTO := convertProjectToDTO(project)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusCreated, projectDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProject handles PUT /projects/:id
|
// UpdateProject handles PUT /projects/:id
|
||||||
@@ -217,47 +124,7 @@ func (h *ProjectHandler) CreateProject(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /projects/{id} [put]
|
// @Router /projects/{id} [put]
|
||||||
func (h *ProjectHandler) UpdateProject(c *gin.Context) {
|
func (h *ProjectHandler) UpdateProject(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleUpdate(c, models.UpdateProject, convertProjectToDTO, prepareProjectUpdate, "project")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid project ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse request body
|
|
||||||
var projectUpdateDTO dto.ProjectUpdateDto
|
|
||||||
if err := c.ShouldBindJSON(&projectUpdateDTO); err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set ID from URL
|
|
||||||
projectUpdateDTO.ID = id.String()
|
|
||||||
|
|
||||||
// Convert DTO to model
|
|
||||||
projectUpdate, err := convertUpdateProjectDTOToModel(projectUpdateDTO)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update project in the database
|
|
||||||
project, err := models.UpdateProject(c.Request.Context(), projectUpdate)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error updating project: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if project == nil {
|
|
||||||
utils.NotFoundResponse(c, "Project not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
projectDTO := convertProjectToDTO(project)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, projectDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteProject handles DELETE /projects/:id
|
// DeleteProject handles DELETE /projects/:id
|
||||||
@@ -275,57 +142,36 @@ func (h *ProjectHandler) UpdateProject(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /projects/{id} [delete]
|
// @Router /projects/{id} [delete]
|
||||||
func (h *ProjectHandler) DeleteProject(c *gin.Context) {
|
func (h *ProjectHandler) DeleteProject(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleDelete(c, models.DeleteProject, "project")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid project ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete project from the database
|
|
||||||
err = models.DeleteProject(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error deleting project: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusNoContent, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for DTO conversion
|
// Helper functions for DTO conversion
|
||||||
|
|
||||||
func convertProjectToDTO(project *models.Project) dto.ProjectDto {
|
func convertProjectToDTO(project *models.Project) dto.ProjectDto {
|
||||||
customerID := 0
|
customerId := project.CustomerID.String()
|
||||||
if project.CustomerID.Compare(ulid.ULID{}) != 0 {
|
|
||||||
// This is a simplification, adjust as needed
|
|
||||||
customerID = int(project.CustomerID.Time())
|
|
||||||
}
|
|
||||||
|
|
||||||
return dto.ProjectDto{
|
return dto.ProjectDto{
|
||||||
ID: project.ID.String(),
|
ID: project.ID.String(),
|
||||||
CreatedAt: project.CreatedAt,
|
CreatedAt: project.CreatedAt,
|
||||||
UpdatedAt: project.UpdatedAt,
|
UpdatedAt: project.UpdatedAt,
|
||||||
Name: project.Name,
|
Name: project.Name,
|
||||||
CustomerID: customerID,
|
CustomerID: &customerId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertCreateProjectDTOToModel(dto dto.ProjectCreateDto) (models.ProjectCreate, error) {
|
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)
|
// Convert CustomerID from int to ULID (this is a simplification, adjust as needed)
|
||||||
customerID, err := customerIDToULID(dto.CustomerID)
|
if dto.CustomerID != nil {
|
||||||
|
customerID, err := types.ULIDFromString(*dto.CustomerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.ProjectCreate{}, fmt.Errorf("invalid customer ID: %w", err)
|
return models.ProjectCreate{}, fmt.Errorf("invalid customer ID: %w", err)
|
||||||
}
|
}
|
||||||
|
create.CustomerID = &customerID
|
||||||
return models.ProjectCreate{
|
}
|
||||||
Name: dto.Name,
|
return create, nil
|
||||||
CustomerID: customerID,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto) (models.ProjectUpdate, error) {
|
func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto, id types.ULID) (models.ProjectUpdate, error) {
|
||||||
id, _ := ulid.Parse(dto.ID)
|
|
||||||
update := models.ProjectUpdate{
|
update := models.ProjectUpdate{
|
||||||
ID: id,
|
ID: id,
|
||||||
}
|
}
|
||||||
@@ -334,26 +180,55 @@ func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto) (models.ProjectUpd
|
|||||||
update.Name = dto.Name
|
update.Name = dto.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.CustomerID != nil {
|
if dto.CustomerID.Valid {
|
||||||
// Convert CustomerID from int to ULID (this is a simplification, adjust as needed)
|
if dto.CustomerID.Value == nil {
|
||||||
customerID, err := customerIDToULID(*dto.CustomerID)
|
update.CustomerID = nil
|
||||||
|
} else {
|
||||||
|
customerID, err := types.ULIDFromString(*dto.CustomerID.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.ProjectUpdate{}, fmt.Errorf("invalid customer ID: %w", err)
|
return models.ProjectUpdate{}, fmt.Errorf("invalid customer ID: %w", err)
|
||||||
}
|
}
|
||||||
update.CustomerID = &customerID
|
update.CustomerID = &customerID
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return update, nil
|
return update, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to convert customer ID from int to ULID
|
// prepareProjectUpdate prepares the project update object by parsing the ID, binding the JSON, and converting the DTO to a model
|
||||||
func customerIDToULID(id int) (ulid.ULID, error) {
|
func prepareProjectUpdate(c *gin.Context) (models.ProjectUpdate, error) {
|
||||||
// This is a simplification, in a real application you would need to
|
// Parse ID from URL
|
||||||
// fetch the actual ULID from the database or use a proper conversion method
|
id, err := utils.ParseID(c, "id")
|
||||||
// For now, we'll create a deterministic ULID based on the int value
|
if err != nil {
|
||||||
entropy := ulid.Monotonic(nil, 0)
|
responses.BadRequestResponse(c, "Invalid project ID format")
|
||||||
timestamp := uint64(id)
|
return models.ProjectUpdate{}, err
|
||||||
|
}
|
||||||
|
|
||||||
// Create a new ULID with the timestamp and entropy
|
// Parse request body
|
||||||
return ulid.MustNew(timestamp, entropy), nil
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/oklog/ulid/v2"
|
"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/api/utils"
|
||||||
dto "github.com/timetracker/backend/internal/dtos"
|
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
|
"github.com/timetracker/backend/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TimeEntryHandler handles time entry-related API endpoints
|
// TimeEntryHandler handles time entry-related API endpoints
|
||||||
@@ -34,20 +33,7 @@ func NewTimeEntryHandler() *TimeEntryHandler {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /time-entries [get]
|
// @Router /time-entries [get]
|
||||||
func (h *TimeEntryHandler) GetTimeEntries(c *gin.Context) {
|
func (h *TimeEntryHandler) GetTimeEntries(c *gin.Context) {
|
||||||
// Get time entries from the database
|
utils.HandleGetAll(c, models.GetAllTimeEntries, convertTimeEntryToDTO, "time entries")
|
||||||
timeEntries, err := models.GetAllTimeEntries(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTOs
|
|
||||||
timeEntryDTOs := make([]dto.TimeEntryDto, len(timeEntries))
|
|
||||||
for i, timeEntry := range timeEntries {
|
|
||||||
timeEntryDTOs[i] = convertTimeEntryToDTO(&timeEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, timeEntryDTOs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTimeEntryByID handles GET /time-entries/:id
|
// GetTimeEntryByID handles GET /time-entries/:id
|
||||||
@@ -66,30 +52,7 @@ func (h *TimeEntryHandler) GetTimeEntries(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /time-entries/{id} [get]
|
// @Router /time-entries/{id} [get]
|
||||||
func (h *TimeEntryHandler) GetTimeEntryByID(c *gin.Context) {
|
func (h *TimeEntryHandler) GetTimeEntryByID(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleGetByID(c, models.GetTimeEntryByID, convertTimeEntryToDTO, "time entry")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid time entry ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get time entry from the database
|
|
||||||
timeEntry, err := models.GetTimeEntryByID(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving time entry: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if timeEntry == nil {
|
|
||||||
utils.NotFoundResponse(c, "Time entry not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
timeEntryDTO := convertTimeEntryToDTO(timeEntry)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, timeEntryDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTimeEntriesByUserID handles GET /time-entries/user/:userId
|
// GetTimeEntriesByUserID handles GET /time-entries/user/:userId
|
||||||
@@ -107,28 +70,7 @@ func (h *TimeEntryHandler) GetTimeEntryByID(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /time-entries/user/{userId} [get]
|
// @Router /time-entries/user/{userId} [get]
|
||||||
func (h *TimeEntryHandler) GetTimeEntriesByUserID(c *gin.Context) {
|
func (h *TimeEntryHandler) GetTimeEntriesByUserID(c *gin.Context) {
|
||||||
// Parse user ID from URL
|
utils.HandleGetByFilter(c, models.GetTimeEntriesByUserID, convertTimeEntryToDTO, "time entries", "userId")
|
||||||
userIDStr := c.Param("userId")
|
|
||||||
userID, err := ulid.Parse(userIDStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid user ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get time entries from the database
|
|
||||||
timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTOs
|
|
||||||
timeEntryDTOs := make([]dto.TimeEntryDto, len(timeEntries))
|
|
||||||
for i, timeEntry := range timeEntries {
|
|
||||||
timeEntryDTOs[i] = convertTimeEntryToDTO(&timeEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, timeEntryDTOs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMyTimeEntries handles GET /time-entries/me
|
// GetMyTimeEntries handles GET /time-entries/me
|
||||||
@@ -144,27 +86,7 @@ func (h *TimeEntryHandler) GetTimeEntriesByUserID(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /time-entries/me [get]
|
// @Router /time-entries/me [get]
|
||||||
func (h *TimeEntryHandler) GetMyTimeEntries(c *gin.Context) {
|
func (h *TimeEntryHandler) GetMyTimeEntries(c *gin.Context) {
|
||||||
// Get user ID from context (set by AuthMiddleware)
|
utils.HandleGetByUserID(c, models.GetTimeEntriesByUserID, convertTimeEntryToDTO, "time entries")
|
||||||
userID, err := middleware.GetUserIDFromContext(c)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnauthorizedResponse(c, "User not authenticated")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get time entries from the database
|
|
||||||
timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTOs
|
|
||||||
timeEntryDTOs := make([]dto.TimeEntryDto, len(timeEntries))
|
|
||||||
for i, timeEntry := range timeEntries {
|
|
||||||
timeEntryDTOs[i] = convertTimeEntryToDTO(&timeEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, timeEntryDTOs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTimeEntriesByProjectID handles GET /time-entries/project/:projectId
|
// GetTimeEntriesByProjectID handles GET /time-entries/project/:projectId
|
||||||
@@ -182,28 +104,7 @@ func (h *TimeEntryHandler) GetMyTimeEntries(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /time-entries/project/{projectId} [get]
|
// @Router /time-entries/project/{projectId} [get]
|
||||||
func (h *TimeEntryHandler) GetTimeEntriesByProjectID(c *gin.Context) {
|
func (h *TimeEntryHandler) GetTimeEntriesByProjectID(c *gin.Context) {
|
||||||
// Parse project ID from URL
|
utils.HandleGetByFilter(c, models.GetTimeEntriesByProjectID, convertTimeEntryToDTO, "time entries", "projectId")
|
||||||
projectIDStr := c.Param("projectId")
|
|
||||||
projectID, err := ulid.Parse(projectIDStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid project ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get time entries from the database
|
|
||||||
timeEntries, err := models.GetTimeEntriesByProjectID(c.Request.Context(), projectID)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTOs
|
|
||||||
timeEntryDTOs := make([]dto.TimeEntryDto, len(timeEntries))
|
|
||||||
for i, timeEntry := range timeEntries {
|
|
||||||
timeEntryDTOs[i] = convertTimeEntryToDTO(&timeEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, timeEntryDTOs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTimeEntriesByDateRange handles GET /time-entries/range
|
// GetTimeEntriesByDateRange handles GET /time-entries/range
|
||||||
@@ -222,46 +123,7 @@ func (h *TimeEntryHandler) GetTimeEntriesByProjectID(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /time-entries/range [get]
|
// @Router /time-entries/range [get]
|
||||||
func (h *TimeEntryHandler) GetTimeEntriesByDateRange(c *gin.Context) {
|
func (h *TimeEntryHandler) GetTimeEntriesByDateRange(c *gin.Context) {
|
||||||
// Parse date range from query parameters
|
utils.HandleGetByDateRange(c, models.GetTimeEntriesByDateRange, convertTimeEntryToDTO, "time entries")
|
||||||
startStr := c.Query("start")
|
|
||||||
endStr := c.Query("end")
|
|
||||||
|
|
||||||
if startStr == "" || endStr == "" {
|
|
||||||
utils.BadRequestResponse(c, "Start and end dates are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
start, err := time.Parse(time.RFC3339, startStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.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 {
|
|
||||||
utils.BadRequestResponse(c, "Invalid end date format. Use ISO 8601 format (e.g., 2023-01-01T00:00:00Z)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if end.Before(start) {
|
|
||||||
utils.BadRequestResponse(c, "End date cannot be before start date")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get time entries from the database
|
|
||||||
timeEntries, err := models.GetTimeEntriesByDateRange(c.Request.Context(), start, end)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTOs
|
|
||||||
timeEntryDTOs := make([]dto.TimeEntryDto, len(timeEntries))
|
|
||||||
for i, timeEntry := range timeEntries {
|
|
||||||
timeEntryDTOs[i] = convertTimeEntryToDTO(&timeEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, timeEntryDTOs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTimeEntry handles POST /time-entries
|
// CreateTimeEntry handles POST /time-entries
|
||||||
@@ -279,31 +141,7 @@ func (h *TimeEntryHandler) GetTimeEntriesByDateRange(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /time-entries [post]
|
// @Router /time-entries [post]
|
||||||
func (h *TimeEntryHandler) CreateTimeEntry(c *gin.Context) {
|
func (h *TimeEntryHandler) CreateTimeEntry(c *gin.Context) {
|
||||||
// Parse request body
|
utils.HandleCreate(c, createTimeEntryWrapper, convertTimeEntryToDTO, "time entry")
|
||||||
var timeEntryCreateDTO dto.TimeEntryCreateDto
|
|
||||||
if err := c.ShouldBindJSON(&timeEntryCreateDTO); err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert DTO to model
|
|
||||||
timeEntryCreate, err := convertCreateTimeEntryDTOToModel(timeEntryCreateDTO)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create time entry in the database
|
|
||||||
timeEntry, err := models.CreateTimeEntry(c.Request.Context(), timeEntryCreate)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error creating time entry: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
timeEntryDTO := convertTimeEntryToDTO(timeEntry)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusCreated, timeEntryDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTimeEntry handles PUT /time-entries/:id
|
// UpdateTimeEntry handles PUT /time-entries/:id
|
||||||
@@ -323,47 +161,7 @@ func (h *TimeEntryHandler) CreateTimeEntry(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /time-entries/{id} [put]
|
// @Router /time-entries/{id} [put]
|
||||||
func (h *TimeEntryHandler) UpdateTimeEntry(c *gin.Context) {
|
func (h *TimeEntryHandler) UpdateTimeEntry(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleUpdate(c, models.UpdateTimeEntry, convertTimeEntryToDTO, prepareTimeEntryUpdate, "time entry")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid time entry ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse request body
|
|
||||||
var timeEntryUpdateDTO dto.TimeEntryUpdateDto
|
|
||||||
if err := c.ShouldBindJSON(&timeEntryUpdateDTO); err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set ID from URL
|
|
||||||
timeEntryUpdateDTO.ID = id.String()
|
|
||||||
|
|
||||||
// Convert DTO to model
|
|
||||||
timeEntryUpdate, err := convertUpdateTimeEntryDTOToModel(timeEntryUpdateDTO)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update time entry in the database
|
|
||||||
timeEntry, err := models.UpdateTimeEntry(c.Request.Context(), timeEntryUpdate)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error updating time entry: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if timeEntry == nil {
|
|
||||||
utils.NotFoundResponse(c, "Time entry not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
timeEntryDTO := convertTimeEntryToDTO(timeEntry)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, timeEntryDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteTimeEntry handles DELETE /time-entries/:id
|
// DeleteTimeEntry handles DELETE /time-entries/:id
|
||||||
@@ -381,22 +179,7 @@ func (h *TimeEntryHandler) UpdateTimeEntry(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /time-entries/{id} [delete]
|
// @Router /time-entries/{id} [delete]
|
||||||
func (h *TimeEntryHandler) DeleteTimeEntry(c *gin.Context) {
|
func (h *TimeEntryHandler) DeleteTimeEntry(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleDelete(c, models.DeleteTimeEntry, "time entry")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid time entry ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete time entry from the database
|
|
||||||
err = models.DeleteTimeEntry(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error deleting time entry: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusNoContent, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for DTO conversion
|
// Helper functions for DTO conversion
|
||||||
@@ -406,9 +189,9 @@ func convertTimeEntryToDTO(timeEntry *models.TimeEntry) dto.TimeEntryDto {
|
|||||||
ID: timeEntry.ID.String(),
|
ID: timeEntry.ID.String(),
|
||||||
CreatedAt: timeEntry.CreatedAt,
|
CreatedAt: timeEntry.CreatedAt,
|
||||||
UpdatedAt: timeEntry.UpdatedAt,
|
UpdatedAt: timeEntry.UpdatedAt,
|
||||||
UserID: int(timeEntry.UserID.Time()), // Simplified conversion
|
UserID: timeEntry.UserID.String(), // Simplified conversion
|
||||||
ProjectID: int(timeEntry.ProjectID.Time()), // Simplified conversion
|
ProjectID: timeEntry.ProjectID.String(), // Simplified conversion
|
||||||
ActivityID: int(timeEntry.ActivityID.Time()), // Simplified conversion
|
ActivityID: timeEntry.ActivityID.String(), // Simplified conversion
|
||||||
Start: timeEntry.Start,
|
Start: timeEntry.Start,
|
||||||
End: timeEntry.End,
|
End: timeEntry.End,
|
||||||
Description: timeEntry.Description,
|
Description: timeEntry.Description,
|
||||||
@@ -418,17 +201,17 @@ func convertTimeEntryToDTO(timeEntry *models.TimeEntry) dto.TimeEntryDto {
|
|||||||
|
|
||||||
func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEntryCreate, error) {
|
func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEntryCreate, error) {
|
||||||
// Convert IDs from int to ULID (this is a simplification, adjust as needed)
|
// Convert IDs from int to ULID (this is a simplification, adjust as needed)
|
||||||
userID, err := idToULID(dto.UserID)
|
userID, err := types.ULIDFromString(dto.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryCreate{}, fmt.Errorf("invalid user ID: %w", err)
|
return models.TimeEntryCreate{}, fmt.Errorf("invalid user ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
projectID, err := idToULID(dto.ProjectID)
|
projectID, err := types.ULIDFromString(dto.ProjectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryCreate{}, fmt.Errorf("invalid project ID: %w", err)
|
return models.TimeEntryCreate{}, fmt.Errorf("invalid project ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activityID, err := idToULID(dto.ActivityID)
|
activityID, err := types.ULIDFromString(dto.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryCreate{}, fmt.Errorf("invalid activity ID: %w", err)
|
return models.TimeEntryCreate{}, fmt.Errorf("invalid activity ID: %w", err)
|
||||||
}
|
}
|
||||||
@@ -444,14 +227,14 @@ func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEn
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEntryUpdate, error) {
|
func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto, id types.ULID) (models.TimeEntryUpdate, error) {
|
||||||
id, _ := ulid.Parse(dto.ID)
|
|
||||||
update := models.TimeEntryUpdate{
|
update := models.TimeEntryUpdate{
|
||||||
ID: id,
|
ID: id,
|
||||||
}
|
}
|
||||||
|
|
||||||
if dto.UserID != nil {
|
if dto.UserID != nil {
|
||||||
userID, err := idToULID(*dto.UserID)
|
userID, err := types.ULIDFromString(*dto.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryUpdate{}, fmt.Errorf("invalid user ID: %w", err)
|
return models.TimeEntryUpdate{}, fmt.Errorf("invalid user ID: %w", err)
|
||||||
}
|
}
|
||||||
@@ -459,7 +242,7 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn
|
|||||||
}
|
}
|
||||||
|
|
||||||
if dto.ProjectID != nil {
|
if dto.ProjectID != nil {
|
||||||
projectID, err := idToULID(*dto.ProjectID)
|
projectID, err := types.ULIDFromString(*dto.ProjectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryUpdate{}, fmt.Errorf("invalid project ID: %w", err)
|
return models.TimeEntryUpdate{}, fmt.Errorf("invalid project ID: %w", err)
|
||||||
}
|
}
|
||||||
@@ -467,7 +250,7 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn
|
|||||||
}
|
}
|
||||||
|
|
||||||
if dto.ActivityID != nil {
|
if dto.ActivityID != nil {
|
||||||
activityID, err := idToULID(*dto.ActivityID)
|
activityID, err := types.ULIDFromString(*dto.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.TimeEntryUpdate{}, fmt.Errorf("invalid activity ID: %w", err)
|
return models.TimeEntryUpdate{}, fmt.Errorf("invalid activity ID: %w", err)
|
||||||
}
|
}
|
||||||
@@ -493,12 +276,41 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEn
|
|||||||
return update, nil
|
return update, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to convert ID from int to ULID
|
// prepareTimeEntryUpdate prepares the time entry update object by parsing the ID, binding the JSON, and converting the DTO to a model
|
||||||
func idToULID(id int) (ulid.ULID, error) {
|
func prepareTimeEntryUpdate(c *gin.Context) (models.TimeEntryUpdate, error) {
|
||||||
// This is a simplification, in a real application you would need to
|
// Parse ID from URL
|
||||||
// fetch the actual ULID from the database or use a proper conversion method
|
idStr := c.Param("id")
|
||||||
// For now, we'll create a deterministic ULID based on the int value
|
id, err := types.ULIDFromString(idStr)
|
||||||
entropy := ulid.Monotonic(nil, 0)
|
if err != nil {
|
||||||
timestamp := uint64(id)
|
responses.BadRequestResponse(c, "Invalid time entry ID format")
|
||||||
return ulid.MustNew(timestamp, entropy), nil
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/timetracker/backend/internal/api/dto"
|
||||||
"github.com/timetracker/backend/internal/api/middleware"
|
"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/api/utils"
|
||||||
dto "github.com/timetracker/backend/internal/dtos"
|
|
||||||
"github.com/timetracker/backend/internal/models"
|
"github.com/timetracker/backend/internal/models"
|
||||||
|
"github.com/timetracker/backend/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserHandler handles user-related API endpoints
|
// UserHandler handles user-related API endpoints
|
||||||
@@ -32,20 +34,7 @@ func NewUserHandler() *UserHandler {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /users [get]
|
// @Router /users [get]
|
||||||
func (h *UserHandler) GetUsers(c *gin.Context) {
|
func (h *UserHandler) GetUsers(c *gin.Context) {
|
||||||
// Get users from the database
|
utils.HandleGetAll(c, models.GetAllUsers, convertUserToDTO, "users")
|
||||||
users, err := models.GetAllUsers(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error retrieving users: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTOs
|
|
||||||
userDTOs := make([]dto.UserDto, len(users))
|
|
||||||
for i, user := range users {
|
|
||||||
userDTOs[i] = convertUserToDTO(&user)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, userDTOs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByID handles GET /users/:id
|
// GetUserByID handles GET /users/:id
|
||||||
@@ -64,30 +53,29 @@ func (h *UserHandler) GetUsers(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /users/{id} [get]
|
// @Router /users/{id} [get]
|
||||||
func (h *UserHandler) GetUserByID(c *gin.Context) {
|
func (h *UserHandler) GetUserByID(c *gin.Context) {
|
||||||
// Parse ID from URL
|
// We need a custom wrapper for GetUserByID because the ID parameter is parsed differently
|
||||||
idStr := c.Param("id")
|
id, err := utils.ParseID(c, "id")
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.BadRequestResponse(c, "Invalid user ID format")
|
responses.BadRequestResponse(c, "Invalid user ID format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user from the database
|
// Get user from the database
|
||||||
user, err := models.GetUserByID(c.Request.Context(), id)
|
user, err := models.GetUserByID(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error())
|
responses.InternalErrorResponse(c, "Error retrieving user: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.NotFoundResponse(c, "User not found")
|
responses.NotFoundResponse(c, "User not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to DTO
|
// Convert to DTO
|
||||||
userDTO := convertUserToDTO(user)
|
userDTO := convertUserToDTO(user)
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, userDTO)
|
responses.SuccessResponse(c, http.StatusOK, userDTO)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser handles POST /users
|
// CreateUser handles POST /users
|
||||||
@@ -105,27 +93,7 @@ func (h *UserHandler) GetUserByID(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /users [post]
|
// @Router /users [post]
|
||||||
func (h *UserHandler) CreateUser(c *gin.Context) {
|
func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||||
// Parse request body
|
utils.HandleCreate(c, createUserWrapper, convertUserToDTO, "user")
|
||||||
var userCreateDTO dto.UserCreateDto
|
|
||||||
if err := c.ShouldBindJSON(&userCreateDTO); err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert DTO to model
|
|
||||||
userCreate := convertCreateDTOToModel(userCreateDTO)
|
|
||||||
|
|
||||||
// Create user in the database
|
|
||||||
user, err := models.CreateUser(c.Request.Context(), userCreate)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error creating user: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
userDTO := convertUserToDTO(user)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusCreated, userDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUser handles PUT /users/:id
|
// UpdateUser handles PUT /users/:id
|
||||||
@@ -145,43 +113,7 @@ func (h *UserHandler) CreateUser(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /users/{id} [put]
|
// @Router /users/{id} [put]
|
||||||
func (h *UserHandler) UpdateUser(c *gin.Context) {
|
func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleUpdate(c, models.UpdateUser, convertUserToDTO, prepareUserUpdate, "user")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid user ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse request body
|
|
||||||
var userUpdateDTO dto.UserUpdateDto
|
|
||||||
if err := c.ShouldBindJSON(&userUpdateDTO); err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set ID from URL
|
|
||||||
userUpdateDTO.ID = id.String()
|
|
||||||
|
|
||||||
// Convert DTO to model
|
|
||||||
userUpdate := convertUpdateDTOToModel(userUpdateDTO)
|
|
||||||
|
|
||||||
// Update user in the database
|
|
||||||
user, err := models.UpdateUser(c.Request.Context(), userUpdate)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error updating user: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if user == nil {
|
|
||||||
utils.NotFoundResponse(c, "User not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to DTO
|
|
||||||
userDTO := convertUserToDTO(user)
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, userDTO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser handles DELETE /users/:id
|
// DeleteUser handles DELETE /users/:id
|
||||||
@@ -199,22 +131,7 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
|||||||
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
// @Failure 500 {object} utils.Response{error=utils.ErrorInfo}
|
||||||
// @Router /users/{id} [delete]
|
// @Router /users/{id} [delete]
|
||||||
func (h *UserHandler) DeleteUser(c *gin.Context) {
|
func (h *UserHandler) DeleteUser(c *gin.Context) {
|
||||||
// Parse ID from URL
|
utils.HandleDelete(c, models.DeleteUser, "user")
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := ulid.Parse(idStr)
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequestResponse(c, "Invalid user ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete user from the database
|
|
||||||
err = models.DeleteUser(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
utils.InternalErrorResponse(c, "Error deleting user: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusNoContent, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login handles POST /auth/login
|
// Login handles POST /auth/login
|
||||||
@@ -234,21 +151,21 @@ func (h *UserHandler) Login(c *gin.Context) {
|
|||||||
// Parse request body
|
// Parse request body
|
||||||
var loginDTO dto.LoginDto
|
var loginDTO dto.LoginDto
|
||||||
if err := c.ShouldBindJSON(&loginDTO); err != nil {
|
if err := c.ShouldBindJSON(&loginDTO); err != nil {
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
responses.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate user
|
// Authenticate user
|
||||||
user, err := models.AuthenticateUser(c.Request.Context(), loginDTO.Email, loginDTO.Password)
|
user, err := models.AuthenticateUser(c.Request.Context(), loginDTO.Email, loginDTO.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.UnauthorizedResponse(c, "Invalid login credentials")
|
responses.UnauthorizedResponse(c, "Invalid login credentials")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
token, err := middleware.GenerateToken(user)
|
token, err := middleware.GenerateToken(user, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error generating token: "+err.Error())
|
responses.InternalErrorResponse(c, "Error generating token: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +175,7 @@ func (h *UserHandler) Login(c *gin.Context) {
|
|||||||
User: convertUserToDTO(user),
|
User: convertUserToDTO(user),
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, tokenDTO)
|
responses.SuccessResponse(c, http.StatusOK, tokenDTO)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register handles POST /auth/register
|
// Register handles POST /auth/register
|
||||||
@@ -277,7 +194,7 @@ func (h *UserHandler) Register(c *gin.Context) {
|
|||||||
// Parse request body
|
// Parse request body
|
||||||
var userCreateDTO dto.UserCreateDto
|
var userCreateDTO dto.UserCreateDto
|
||||||
if err := c.ShouldBindJSON(&userCreateDTO); err != nil {
|
if err := c.ShouldBindJSON(&userCreateDTO); err != nil {
|
||||||
utils.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
responses.BadRequestResponse(c, "Invalid request body: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,14 +204,14 @@ func (h *UserHandler) Register(c *gin.Context) {
|
|||||||
// Create user in the database
|
// Create user in the database
|
||||||
user, err := models.CreateUser(c.Request.Context(), userCreate)
|
user, err := models.CreateUser(c.Request.Context(), userCreate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error creating user: "+err.Error())
|
responses.InternalErrorResponse(c, "Error creating user: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
token, err := middleware.GenerateToken(user)
|
token, err := middleware.GenerateToken(user, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error generating token: "+err.Error())
|
responses.InternalErrorResponse(c, "Error generating token: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +221,7 @@ func (h *UserHandler) Register(c *gin.Context) {
|
|||||||
User: convertUserToDTO(user),
|
User: convertUserToDTO(user),
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusCreated, tokenDTO)
|
responses.SuccessResponse(c, http.StatusCreated, tokenDTO)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentUser handles GET /auth/me
|
// GetCurrentUser handles GET /auth/me
|
||||||
@@ -323,45 +240,105 @@ func (h *UserHandler) GetCurrentUser(c *gin.Context) {
|
|||||||
// Get user ID from context (set by AuthMiddleware)
|
// Get user ID from context (set by AuthMiddleware)
|
||||||
userID, err := middleware.GetUserIDFromContext(c)
|
userID, err := middleware.GetUserIDFromContext(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.UnauthorizedResponse(c, "User not authenticated")
|
responses.UnauthorizedResponse(c, "User not authenticated")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user from the database
|
// Get user from the database
|
||||||
user, err := models.GetUserByID(c.Request.Context(), userID)
|
user, err := models.GetUserByID(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error())
|
responses.InternalErrorResponse(c, "Error retrieving user: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if user == nil {
|
if user == nil {
|
||||||
utils.NotFoundResponse(c, "User not found")
|
responses.NotFoundResponse(c, "User not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to DTO
|
// Convert to DTO
|
||||||
userDTO := convertUserToDTO(user)
|
userDTO := convertUserToDTO(user)
|
||||||
|
|
||||||
utils.SuccessResponse(c, http.StatusOK, userDTO)
|
responses.SuccessResponse(c, http.StatusOK, userDTO)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for DTO conversion
|
// Helper functions for DTO conversion
|
||||||
|
|
||||||
func convertUserToDTO(user *models.User) dto.UserDto {
|
func convertUserToDTO(user *models.User) dto.UserDto {
|
||||||
|
var companyID *string
|
||||||
|
if user.CompanyID != nil {
|
||||||
|
s := user.CompanyID.String()
|
||||||
|
companyID = &s
|
||||||
|
}
|
||||||
return dto.UserDto{
|
return dto.UserDto{
|
||||||
ID: user.ID.String(),
|
ID: user.ID.String(),
|
||||||
CreatedAt: user.CreatedAt,
|
CreatedAt: user.CreatedAt,
|
||||||
UpdatedAt: user.UpdatedAt,
|
UpdatedAt: user.UpdatedAt,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
CompanyID: int(user.CompanyID.Time()), // This is a simplification, adjust as needed
|
CompanyID: companyID,
|
||||||
HourlyRate: user.HourlyRate,
|
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 {
|
func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate {
|
||||||
// Convert CompanyID from int to ULID (this is a simplification, adjust as needed)
|
var companyID *types.ULID
|
||||||
companyID, _ := ulid.Parse("01H1VECTJQXS1RVWJT6QG3QJCJ")
|
if dto.CompanyID != nil {
|
||||||
|
wrapper, _ := types.ULIDFromString(*dto.CompanyID) // Ignoring error, validation happens in the model
|
||||||
|
companyID = &wrapper
|
||||||
|
}
|
||||||
|
|
||||||
return models.UserCreate{
|
return models.UserCreate{
|
||||||
Email: dto.Email,
|
Email: dto.Email,
|
||||||
@@ -372,33 +349,11 @@ func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUpdateDTOToModel(dto dto.UserUpdateDto) models.UserUpdate {
|
// createUserWrapper is a wrapper function for models.CreateUser that takes a DTO as input
|
||||||
id, _ := ulid.Parse(dto.ID)
|
func createUserWrapper(ctx context.Context, createDTO dto.UserCreateDto) (*models.User, error) {
|
||||||
update := models.UserUpdate{
|
// Convert DTO to model
|
||||||
ID: id,
|
userCreate := convertCreateDTOToModel(createDTO)
|
||||||
}
|
|
||||||
|
|
||||||
if dto.Email != nil {
|
// Call the original function
|
||||||
update.Email = dto.Email
|
return models.CreateUser(ctx, userCreate)
|
||||||
}
|
|
||||||
|
|
||||||
if dto.Password != nil {
|
|
||||||
update.Password = dto.Password
|
|
||||||
}
|
|
||||||
|
|
||||||
if dto.Role != nil {
|
|
||||||
update.Role = dto.Role
|
|
||||||
}
|
|
||||||
|
|
||||||
if dto.CompanyID != nil {
|
|
||||||
// Convert CompanyID from int to ULID (this is a simplification, adjust as needed)
|
|
||||||
companyID, _ := ulid.Parse("01H1VECTJQXS1RVWJT6QG3QJCJ")
|
|
||||||
update.CompanyID = &companyID
|
|
||||||
}
|
|
||||||
|
|
||||||
if dto.HourlyRate != nil {
|
|
||||||
update.HourlyRate = dto.HourlyRate
|
|
||||||
}
|
|
||||||
|
|
||||||
return update
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"github.com/oklog/ulid/v2"
|
|
||||||
"github.com/timetracker/backend/internal/api/utils"
|
|
||||||
"github.com/timetracker/backend/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JWT configuration
|
|
||||||
const (
|
|
||||||
// This should be moved to environment variables in production
|
|
||||||
jwtSecret = "your-secret-key-change-in-production"
|
|
||||||
tokenDuration = 24 * time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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 Authorization header
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
utils.UnauthorizedResponse(c, "Authorization header is required")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the header has the Bearer prefix
|
|
||||||
parts := strings.Split(authHeader, " ")
|
|
||||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
||||||
utils.UnauthorizedResponse(c, "Invalid authorization format, expected 'Bearer TOKEN'")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenString := parts[1]
|
|
||||||
claims, err := validateToken(tokenString)
|
|
||||||
if err != nil {
|
|
||||||
utils.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 {
|
|
||||||
utils.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 {
|
|
||||||
utils.InternalErrorResponse(c, "Invalid role type in context")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
allowed := false
|
|
||||||
for _, role := range roles {
|
|
||||||
if roleStr == role {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !allowed {
|
|
||||||
utils.ForbiddenResponse(c, "Insufficient permissions")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateToken creates a new JWT token for a user
|
|
||||||
func GenerateToken(user *models.User) (string, error) {
|
|
||||||
// Create the claims
|
|
||||||
claims := Claims{
|
|
||||||
UserID: user.ID.String(),
|
|
||||||
Email: user.Email,
|
|
||||||
Role: user.Role,
|
|
||||||
CompanyID: user.CompanyID.String(),
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenDuration)),
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the token
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
|
|
||||||
// Sign the token
|
|
||||||
tokenString, err := token.SignedString([]byte(jwtSecret))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokenString, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateToken validates a JWT token and returns the claims
|
|
||||||
func validateToken(tokenString string) (*Claims, error) {
|
|
||||||
// Parse the token
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
|
||||||
// Validate the signing method
|
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
||||||
return nil, errors.New("unexpected signing method")
|
|
||||||
}
|
|
||||||
return []byte(jwtSecret), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
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) (ulid.ULID, error) {
|
|
||||||
userID, exists := c.Get("userID")
|
|
||||||
if !exists {
|
|
||||||
return ulid.ULID{}, errors.New("user ID not found in context")
|
|
||||||
}
|
|
||||||
|
|
||||||
userIDStr, ok := userID.(string)
|
|
||||||
if !ok {
|
|
||||||
return ulid.ULID{}, errors.New("invalid user ID type in context")
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := ulid.Parse(userIDStr)
|
|
||||||
if err != nil {
|
|
||||||
return ulid.ULID{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCompanyIDFromContext extracts the company ID from the context
|
|
||||||
func GetCompanyIDFromContext(c *gin.Context) (ulid.ULID, error) {
|
|
||||||
companyID, exists := c.Get("companyID")
|
|
||||||
if !exists {
|
|
||||||
return ulid.ULID{}, errors.New("company ID not found in context")
|
|
||||||
}
|
|
||||||
|
|
||||||
companyIDStr, ok := companyID.(string)
|
|
||||||
if !ok {
|
|
||||||
return ulid.ULID{}, errors.New("invalid company ID type in context")
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := ulid.Parse(companyIDStr)
|
|
||||||
if err != nil {
|
|
||||||
return ulid.ULID{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
"github.com/timetracker/backend/internal/api/responses"
|
||||||
|
"github.com/timetracker/backend/internal/config"
|
||||||
|
"github.com/timetracker/backend/internal/models"
|
||||||
|
"github.com/timetracker/backend/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
signKey *rsa.PrivateKey
|
||||||
|
verifyKey *rsa.PublicKey
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitJWTKeys initializes the JWT keys
|
||||||
|
func InitJWTKeys() error {
|
||||||
|
cfg := config.MustLoadConfig()
|
||||||
|
|
||||||
|
// If a secret is provided, we'll use HMAC-SHA256, so no need for certificates
|
||||||
|
if cfg.JWTConfig.Secret != "" {
|
||||||
|
println("Using HMAC-SHA256 for JWT")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if keys exist
|
||||||
|
privKeyPath := filepath.Join(cfg.JWTConfig.KeyDir, cfg.JWTConfig.PrivKeyFile)
|
||||||
|
pubKeyPath := filepath.Join(cfg.JWTConfig.KeyDir, cfg.JWTConfig.PubKeyFile)
|
||||||
|
|
||||||
|
keysExist := fileExists(privKeyPath) && fileExists(pubKeyPath)
|
||||||
|
|
||||||
|
// Generate keys if they don't exist and KeyGenerate is true
|
||||||
|
if !keysExist && cfg.JWTConfig.KeyGenerate {
|
||||||
|
println("Generating RSA keys")
|
||||||
|
if err := generateRSAKeys(cfg.JWTConfig); err != nil {
|
||||||
|
return fmt.Errorf("failed to generate RSA keys: %w", err)
|
||||||
|
}
|
||||||
|
} else if !keysExist {
|
||||||
|
return errors.New("JWT keys not found and key generation is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load keys
|
||||||
|
var err error
|
||||||
|
signKey, err = loadPrivateKey(privKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyKey, err = loadPublicKey(pubKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileExists checks if a file exists
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return !os.IsNotExist(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRSAKeys generates RSA keys and saves them to disk
|
||||||
|
func generateRSAKeys(cfg models.JWTConfig) error {
|
||||||
|
// Create key directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(cfg.KeyDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create key directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate private key
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, cfg.KeyBits)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save private key
|
||||||
|
privKeyPath := filepath.Join(cfg.KeyDir, cfg.PrivKeyFile)
|
||||||
|
privKeyFile, err := os.OpenFile(privKeyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create private key file: %w", err)
|
||||||
|
}
|
||||||
|
defer privKeyFile.Close()
|
||||||
|
|
||||||
|
privKeyPEM := &pem.Block{
|
||||||
|
Type: "RSA PRIVATE KEY",
|
||||||
|
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pem.Encode(privKeyFile, privKeyPEM); err != nil {
|
||||||
|
return fmt.Errorf("failed to encode private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save public key
|
||||||
|
pubKeyPath := filepath.Join(cfg.KeyDir, cfg.PubKeyFile)
|
||||||
|
pubKeyFile, err := os.OpenFile(pubKeyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create public key file: %w", err)
|
||||||
|
}
|
||||||
|
defer pubKeyFile.Close()
|
||||||
|
|
||||||
|
pubKeyPEM := &pem.Block{
|
||||||
|
Type: "RSA PUBLIC KEY",
|
||||||
|
Bytes: x509.MarshalPKCS1PublicKey(&privateKey.PublicKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pem.Encode(pubKeyFile, pubKeyPEM); err != nil {
|
||||||
|
return fmt.Errorf("failed to encode public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPrivateKey loads a private key from a file
|
||||||
|
func loadPrivateKey(path string) (*rsa.PrivateKey, error) {
|
||||||
|
keyData, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read private key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, _ := pem.Decode(keyData)
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("failed to parse PEM block containing the private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return privateKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPublicKey loads a public key from a file
|
||||||
|
func loadPublicKey(path string) (*rsa.PublicKey, error) {
|
||||||
|
keyData, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read public key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, _ := pem.Decode(keyData)
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("failed to parse PEM block containing the public key")
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey, err := x509.ParsePKCS1PublicKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claims represents the JWT claims
|
||||||
|
type Claims struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
CompanyID *string `json:"companyId"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware checks if the user is authenticated
|
||||||
|
func AuthMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Get the token from cookie
|
||||||
|
tokenString, err := c.Cookie("jwt")
|
||||||
|
if err != nil {
|
||||||
|
responses.UnauthorizedResponse(c, "Authentication cookie is required")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := validateToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
responses.UnauthorizedResponse(c, "Invalid or expired token")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store user information in the context
|
||||||
|
c.Set("userID", claims.UserID)
|
||||||
|
c.Set("email", claims.Email)
|
||||||
|
c.Set("role", claims.Role)
|
||||||
|
c.Set("companyID", claims.CompanyID)
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleMiddleware checks if the user has the required role
|
||||||
|
func RoleMiddleware(roles ...string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userRole, exists := c.Get("role")
|
||||||
|
if !exists {
|
||||||
|
responses.UnauthorizedResponse(c, "User role not found in context")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user's role is in the allowed roles
|
||||||
|
roleStr, ok := userRole.(string)
|
||||||
|
if !ok {
|
||||||
|
responses.InternalErrorResponse(c, "Invalid role type in context")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := false
|
||||||
|
for _, role := range roles {
|
||||||
|
if roleStr == role {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowed {
|
||||||
|
responses.ForbiddenResponse(c, "Insufficient permissions")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken creates a new JWT token for a user
|
||||||
|
func GenerateToken(user *models.User, c *gin.Context) (string, error) {
|
||||||
|
// Create the claims
|
||||||
|
var companyId *string
|
||||||
|
if user.CompanyID != nil {
|
||||||
|
wrapper := user.CompanyID.String()
|
||||||
|
companyId = &wrapper
|
||||||
|
}
|
||||||
|
claims := Claims{
|
||||||
|
UserID: user.ID.String(),
|
||||||
|
Email: user.Email,
|
||||||
|
Role: user.Role,
|
||||||
|
CompanyID: companyId,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(config.MustLoadConfig().JWTConfig.TokenDuration)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.MustLoadConfig()
|
||||||
|
var token *jwt.Token
|
||||||
|
var tokenString string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Choose signing method based on configuration
|
||||||
|
if cfg.JWTConfig.Secret != "" {
|
||||||
|
// Use HMAC-SHA256 if a secret is provided
|
||||||
|
token = jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenString, err = token.SignedString([]byte(cfg.JWTConfig.Secret))
|
||||||
|
} else {
|
||||||
|
// Use RSA if no secret is provided
|
||||||
|
token = jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
tokenString, err = token.SignedString(signKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the cookie
|
||||||
|
c.SetCookie("jwt", tokenString, int(cfg.JWTConfig.TokenDuration.Seconds()), "/", "", true, true)
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateToken validates a JWT token and returns the claims
|
||||||
|
func validateToken(tokenString string) (*Claims, error) {
|
||||||
|
cfg := config.MustLoadConfig()
|
||||||
|
|
||||||
|
// Parse the token
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||||
|
// Check which signing method was used
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); ok {
|
||||||
|
// HMAC method was used, validate with secret
|
||||||
|
if cfg.JWTConfig.Secret == "" {
|
||||||
|
return nil, errors.New("HMAC signing method used but no secret configured")
|
||||||
|
}
|
||||||
|
return []byte(cfg.JWTConfig.Secret), nil
|
||||||
|
} else if _, ok := token.Method.(*jwt.SigningMethodRSA); ok {
|
||||||
|
// RSA method was used, validate with public key
|
||||||
|
if verifyKey == nil {
|
||||||
|
return nil, errors.New("RSA signing method used but no public key loaded")
|
||||||
|
}
|
||||||
|
return verifyKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("unexpected signing method")
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the token is valid
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the claims
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserIDFromContext extracts the user ID from the context
|
||||||
|
func GetUserIDFromContext(c *gin.Context) (types.ULID, error) {
|
||||||
|
userID, exists := c.Get("userID")
|
||||||
|
if !exists {
|
||||||
|
return types.ULID{}, errors.New("user ID not found in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDStr, ok := userID.(string)
|
||||||
|
if !ok {
|
||||||
|
return types.ULID{}, errors.New("invalid user ID type in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := ulid.Parse(userIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return types.ULID{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.FromULID(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCompanyIDFromContext extracts the company ID from the context
|
||||||
|
func GetCompanyIDFromContext(c *gin.Context) (types.ULID, error) {
|
||||||
|
companyID, exists := c.Get("companyID")
|
||||||
|
if !exists {
|
||||||
|
return types.ULID{}, errors.New("company ID not found in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
companyIDStr, ok := companyID.(string)
|
||||||
|
if !ok {
|
||||||
|
return types.ULID{}, errors.New("invalid company ID type in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := ulid.Parse(companyIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return types.ULID{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.FromULID(id), nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package utils
|
package responses
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -4,11 +4,14 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/timetracker/backend/internal/api/handlers"
|
"github.com/timetracker/backend/internal/api/handlers"
|
||||||
"github.com/timetracker/backend/internal/api/middleware"
|
"github.com/timetracker/backend/internal/api/middleware"
|
||||||
|
"github.com/timetracker/backend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetupRouter configures all the routes for the API
|
// SetupRouter configures all the routes for the API
|
||||||
func SetupRouter(r *gin.Engine) {
|
func SetupRouter(r *gin.Engine, cfg *config.Config) {
|
||||||
// Create handlers
|
// Create handlers
|
||||||
|
// Apply API key middleware to all API routes
|
||||||
|
r.Use(middleware.APIKeyMiddleware(cfg))
|
||||||
userHandler := handlers.NewUserHandler()
|
userHandler := handlers.NewUserHandler()
|
||||||
activityHandler := handlers.NewActivityHandler()
|
activityHandler := handlers.NewActivityHandler()
|
||||||
companyHandler := handlers.NewCompanyHandler()
|
companyHandler := handlers.NewCompanyHandler()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
dto "github.com/timetracker/backend/internal/dtos"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This file contains type definitions for Swagger documentation
|
|
||||||
|
|
||||||
// LoginRequest is a Swagger representation of LoginDto
|
|
||||||
type LoginRequest struct {
|
|
||||||
Email string `json:"email" example:"user@example.com"`
|
|
||||||
Password string `json:"password" example:"SecurePassword123!"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenResponse is a Swagger representation of TokenDto
|
|
||||||
type TokenResponse struct {
|
|
||||||
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
|
|
||||||
User dto.UserDto `json:"user"`
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/timetracker/backend/internal/models"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the application configuration
|
||||||
|
type Config struct {
|
||||||
|
Database models.DatabaseConfig
|
||||||
|
JWTConfig models.JWTConfig
|
||||||
|
APIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig loads configuration from environment variables and .env file
|
||||||
|
func LoadConfig() (*Config, error) {
|
||||||
|
// Try loading .env file, but don't fail if it doesn't exist
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Database: models.DefaultDatabaseConfig(),
|
||||||
|
JWTConfig: models.JWTConfig{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load database configuration
|
||||||
|
if err := loadDatabaseConfig(cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load database config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load JWT configuration
|
||||||
|
if err := loadJWTConfig(cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load JWT config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load API key
|
||||||
|
cfg.APIKey = getEnv("API_KEY", "")
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadJWTConfig loads JWT configuration from environment
|
||||||
|
func loadJWTConfig(cfg *Config) error {
|
||||||
|
cfg.JWTConfig.Secret = getEnv("JWT_SECRET", "")
|
||||||
|
defaultDuration := 24 * time.Hour
|
||||||
|
durationStr := getEnv("JWT_TOKEN_DURATION", defaultDuration.String())
|
||||||
|
|
||||||
|
duration, err := time.ParseDuration(durationStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid JWT_TOKEN_DURATION: %w", err)
|
||||||
|
}
|
||||||
|
cfg.JWTConfig.TokenDuration = duration
|
||||||
|
|
||||||
|
keyGenerateStr := getEnv("JWT_KEY_GENERATE", "true")
|
||||||
|
keyGenerate, err := strconv.ParseBool(keyGenerateStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid JWT_KEY_GENERATE: %w", err)
|
||||||
|
}
|
||||||
|
cfg.JWTConfig.KeyGenerate = keyGenerate
|
||||||
|
|
||||||
|
cfg.JWTConfig.KeyDir = getEnv("JWT_KEY_DIR", "./keys")
|
||||||
|
cfg.JWTConfig.PrivKeyFile = getEnv("JWT_PRIV_KEY_FILE", "jwt.key")
|
||||||
|
cfg.JWTConfig.PubKeyFile = getEnv("JWT_PUB_KEY_FILE", "jwt.key.pub")
|
||||||
|
|
||||||
|
keyBitsStr := getEnv("JWT_KEY_BITS", "2048")
|
||||||
|
keyBits, err := strconv.Atoi(keyBitsStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid JWT_KEY_BITS: %w", err)
|
||||||
|
}
|
||||||
|
cfg.JWTConfig.KeyBits = keyBits
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDatabaseConfig loads database configuration from environment
|
||||||
|
func loadDatabaseConfig(cfg *Config) error {
|
||||||
|
// Required fields
|
||||||
|
cfg.Database.Host = getEnv("DB_HOST", cfg.Database.Host)
|
||||||
|
cfg.Database.User = getEnv("DB_USER", cfg.Database.User)
|
||||||
|
cfg.Database.Password = getEnv("DB_PASSWORD", cfg.Database.Password)
|
||||||
|
cfg.Database.DBName = getEnv("DB_NAME", cfg.Database.DBName)
|
||||||
|
cfg.Database.SSLMode = getEnv("DB_SSLMODE", cfg.Database.SSLMode)
|
||||||
|
|
||||||
|
// Optional fields with parsing
|
||||||
|
if port := getEnv("DB_PORT", ""); port != "" {
|
||||||
|
portInt, err := strconv.Atoi(port)
|
||||||
|
if err != nil || portInt <= 0 {
|
||||||
|
return errors.New("invalid DB_PORT value")
|
||||||
|
}
|
||||||
|
cfg.Database.Port = portInt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log level based on environment
|
||||||
|
if os.Getenv("ENVIRONMENT") == "production" {
|
||||||
|
cfg.Database.LogLevel = logger.Error
|
||||||
|
} else {
|
||||||
|
cfg.Database.LogLevel = logger.Info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if cfg.Database.Host == "" || cfg.Database.User == "" ||
|
||||||
|
cfg.Database.Password == "" || cfg.Database.DBName == "" {
|
||||||
|
return errors.New("missing required database configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnv gets an environment variable with fallback
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if value, exists := os.LookupEnv(key); exists {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustLoadConfig loads configuration or panics on failure
|
||||||
|
func MustLoadConfig() *Config {
|
||||||
|
cfg, err := LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ActivityDto struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
LastEditorID string `json:"lastEditorID"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
BillingRate float64 `json:"billingRate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActivityCreateDto struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
BillingRate float64 `json:"billingRate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActivityUpdateDto struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CreatedAt *time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt *time.Time `json:"updatedAt"`
|
|
||||||
LastEditorID *string `json:"lastEditorID"`
|
|
||||||
Name *string `json:"name"`
|
|
||||||
BillingRate *float64 `json:"billingRate"`
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
// LoginDto represents the login request
|
|
||||||
type LoginDto struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenDto represents the response after successful authentication
|
|
||||||
type TokenDto struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
User UserDto `json:"user"`
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CompanyDto struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
LastEditorID string `json:"lastEditorID"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CompanyCreateDto struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CompanyUpdateDto struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CreatedAt *time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt *time.Time `json:"updatedAt"`
|
|
||||||
LastEditorID *string `json:"lastEditorID"`
|
|
||||||
Name *string `json:"name"`
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CustomerDto struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
LastEditorID string `json:"lastEditorID"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
CompanyID int `json:"companyId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CustomerCreateDto struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
CompanyID int `json:"companyId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CustomerUpdateDto struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CreatedAt *time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt *time.Time `json:"updatedAt"`
|
|
||||||
LastEditorID *string `json:"lastEditorID"`
|
|
||||||
Name *string `json:"name"`
|
|
||||||
CompanyID *int `json:"companyId"`
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ProjectDto struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
LastEditorID string `json:"lastEditorID"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
CustomerID int `json:"customerId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProjectCreateDto struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
CustomerID int `json:"customerId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProjectUpdateDto struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CreatedAt *time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt *time.Time `json:"updatedAt"`
|
|
||||||
LastEditorID *string `json:"lastEditorID"`
|
|
||||||
Name *string `json:"name"`
|
|
||||||
CustomerID *int `json:"customerId"`
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TimeEntryDto struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
LastEditorID string `json:"lastEditorID"`
|
|
||||||
UserID int `json:"userId"`
|
|
||||||
ProjectID int `json:"projectId"`
|
|
||||||
ActivityID int `json:"activityId"`
|
|
||||||
Start time.Time `json:"start"`
|
|
||||||
End time.Time `json:"end"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Billable int `json:"billable"` // Percentage (0-100)
|
|
||||||
}
|
|
||||||
|
|
||||||
type TimeEntryCreateDto struct {
|
|
||||||
UserID int `json:"userId"`
|
|
||||||
ProjectID int `json:"projectId"`
|
|
||||||
ActivityID int `json:"activityId"`
|
|
||||||
Start time.Time `json:"start"`
|
|
||||||
End time.Time `json:"end"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Billable int `json:"billable"` // Percentage (0-100)
|
|
||||||
}
|
|
||||||
|
|
||||||
type TimeEntryUpdateDto struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CreatedAt *time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt *time.Time `json:"updatedAt"`
|
|
||||||
LastEditorID *string `json:"lastEditorID"`
|
|
||||||
UserID *int `json:"userId"`
|
|
||||||
ProjectID *int `json:"projectId"`
|
|
||||||
ActivityID *int `json:"activityId"`
|
|
||||||
Start *time.Time `json:"start"`
|
|
||||||
End *time.Time `json:"end"`
|
|
||||||
Description *string `json:"description"`
|
|
||||||
Billable *int `json:"billable"` // Percentage (0-100)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserDto struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
LastEditorID string `json:"lastEditorID"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
CompanyID int `json:"companyId"`
|
|
||||||
HourlyRate float64 `json:"hourlyRate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserCreateDto struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
CompanyID int `json:"companyId"`
|
|
||||||
HourlyRate float64 `json:"hourlyRate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserUpdateDto struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CreatedAt *time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt *time.Time `json:"updatedAt"`
|
|
||||||
LastEditorID *string `json:"lastEditorID"`
|
|
||||||
Email *string `json:"email"`
|
|
||||||
Password *string `json:"password"`
|
|
||||||
Role *string `json:"role"`
|
|
||||||
CompanyID *int `json:"companyId"`
|
|
||||||
HourlyRate *float64 `json:"hourlyRate"`
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/timetracker/backend/internal/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ func (Activity) TableName() string {
|
|||||||
|
|
||||||
// ActivityUpdate contains the updatable fields of an Activity
|
// ActivityUpdate contains the updatable fields of an Activity
|
||||||
type ActivityUpdate struct {
|
type ActivityUpdate struct {
|
||||||
ID ulid.ULID `gorm:"-"` // Use "-" to indicate that this field should be ignored
|
ID types.ULID `gorm:"-"` // Use "-" to indicate that this field should be ignored
|
||||||
Name *string `gorm:"column:name"`
|
Name *string `gorm:"column:name"`
|
||||||
BillingRate *float64 `gorm:"column:billing_rate"`
|
BillingRate *float64 `gorm:"column:billing_rate"`
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ type ActivityCreate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetActivityByID finds an Activity by its ID
|
// GetActivityByID finds an Activity by its ID
|
||||||
func GetActivityByID(ctx context.Context, id ulid.ULID) (*Activity, error) {
|
func GetActivityByID(ctx context.Context, id types.ULID) (*Activity, error) {
|
||||||
var activity Activity
|
var activity Activity
|
||||||
result := GetEngine(ctx).Where("id = ?", id).First(&activity)
|
result := GetEngine(ctx).Where("id = ?", id).First(&activity)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -90,7 +90,7 @@ func UpdateActivity(ctx context.Context, update ActivityUpdate) (*Activity, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteActivity deletes an Activity by its ID
|
// DeleteActivity deletes an Activity by its ID
|
||||||
func DeleteActivity(ctx context.Context, id ulid.ULID) error {
|
func DeleteActivity(ctx context.Context, id types.ULID) error {
|
||||||
result := GetEngine(ctx).Delete(&Activity{}, id)
|
result := GetEngine(ctx).Delete(&Activity{}, id)
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/oklog/ulid/v2"
|
||||||
|
"github.com/timetracker/backend/internal/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EntityBase struct {
|
type EntityBase struct {
|
||||||
ID ulid.ULID `gorm:"type:uuid;primaryKey"`
|
ID types.ULID `gorm:"type:bytea;primaryKey"`
|
||||||
CreatedAt time.Time `gorm:"index"`
|
CreatedAt time.Time `gorm:"index"`
|
||||||
UpdatedAt time.Time `gorm:"index"`
|
UpdatedAt time.Time `gorm:"index"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||||
@@ -17,10 +18,11 @@ type EntityBase struct {
|
|||||||
|
|
||||||
// BeforeCreate is called by GORM before creating a record
|
// BeforeCreate is called by GORM before creating a record
|
||||||
func (eb *EntityBase) BeforeCreate(tx *gorm.DB) error {
|
func (eb *EntityBase) BeforeCreate(tx *gorm.DB) error {
|
||||||
if eb.ID.Compare(ulid.ULID{}) == 0 { // If ID is empty
|
if eb.ID.Compare(types.ULID{}) == 0 { // If ID is empty
|
||||||
// Generate a new ULID
|
// Generate a new types.ULID
|
||||||
entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0)
|
entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0)
|
||||||
eb.ID = ulid.MustNew(ulid.Timestamp(time.Now()), entropy)
|
newID := ulid.MustNew(ulid.Timestamp(time.Now()), entropy)
|
||||||
|
eb.ID = types.ULID{ULID: newID}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/timetracker/backend/internal/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,12 +26,12 @@ type CompanyCreate struct {
|
|||||||
|
|
||||||
// CompanyUpdate contains the updatable fields of a company
|
// CompanyUpdate contains the updatable fields of a company
|
||||||
type CompanyUpdate struct {
|
type CompanyUpdate struct {
|
||||||
ID ulid.ULID `gorm:"-"` // Exclude from updates
|
ID types.ULID `gorm:"-"` // Exclude from updates
|
||||||
Name *string `gorm:"column:name"`
|
Name *string `gorm:"column:name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCompanyByID finds a company by its ID
|
// GetCompanyByID finds a company by its ID
|
||||||
func GetCompanyByID(ctx context.Context, id ulid.ULID) (*Company, error) {
|
func GetCompanyByID(ctx context.Context, id types.ULID) (*Company, error) {
|
||||||
var company Company
|
var company Company
|
||||||
result := GetEngine(ctx).Where("id = ?", id).First(&company)
|
result := GetEngine(ctx).Where("id = ?", id).First(&company)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -95,7 +95,7 @@ func UpdateCompany(ctx context.Context, update CompanyUpdate) (*Company, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteCompany deletes a company by its ID
|
// DeleteCompany deletes a company by its ID
|
||||||
func DeleteCompany(ctx context.Context, id ulid.ULID) error {
|
func DeleteCompany(ctx context.Context, id types.ULID) error {
|
||||||
result := GetEngine(ctx).Delete(&Company{}, id)
|
result := GetEngine(ctx).Delete(&Company{}, id)
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/timetracker/backend/internal/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,7 +12,8 @@ import (
|
|||||||
type Customer struct {
|
type Customer struct {
|
||||||
EntityBase
|
EntityBase
|
||||||
Name string `gorm:"column:name"`
|
Name string `gorm:"column:name"`
|
||||||
CompanyID int `gorm:"column:company_id"`
|
CompanyID *types.ULID `gorm:"type:bytea;column:company_id"`
|
||||||
|
OwnerUserID *types.ULID `gorm:"type:bytea;column:owner_user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName specifies the table name for GORM
|
// TableName specifies the table name for GORM
|
||||||
@@ -23,18 +24,20 @@ func (Customer) TableName() string {
|
|||||||
// CustomerCreate contains the fields for creating a new customer
|
// CustomerCreate contains the fields for creating a new customer
|
||||||
type CustomerCreate struct {
|
type CustomerCreate struct {
|
||||||
Name string
|
Name string
|
||||||
CompanyID int
|
CompanyID *types.ULID
|
||||||
|
OwnerUserID *types.ULID
|
||||||
}
|
}
|
||||||
|
|
||||||
// CustomerUpdate contains the updatable fields of a customer
|
// CustomerUpdate contains the updatable fields of a customer
|
||||||
type CustomerUpdate struct {
|
type CustomerUpdate struct {
|
||||||
ID ulid.ULID `gorm:"-"` // Exclude from updates
|
ID types.ULID `gorm:"-"` // Exclude from updates
|
||||||
Name *string `gorm:"column:name"`
|
Name *string `gorm:"column:name"`
|
||||||
CompanyID *int `gorm:"column:company_id"`
|
CompanyID *types.ULID `gorm:"column:company_id"`
|
||||||
|
OwnerUserID *types.ULID `gorm:"column:owner_user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCustomerByID finds a customer by its ID
|
// GetCustomerByID finds a customer by its ID
|
||||||
func GetCustomerByID(ctx context.Context, id ulid.ULID) (*Customer, error) {
|
func GetCustomerByID(ctx context.Context, id types.ULID) (*Customer, error) {
|
||||||
var customer Customer
|
var customer Customer
|
||||||
result := GetEngine(ctx).Where("id = ?", id).First(&customer)
|
result := GetEngine(ctx).Where("id = ?", id).First(&customer)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -90,7 +93,7 @@ func UpdateCustomer(ctx context.Context, update CustomerUpdate) (*Customer, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteCustomer deletes a customer by its ID
|
// DeleteCustomer deletes a customer by its ID
|
||||||
func DeleteCustomer(ctx context.Context, id ulid.ULID) error {
|
func DeleteCustomer(ctx context.Context, id types.ULID) error {
|
||||||
result := GetEngine(ctx).Delete(&Customer{}, id)
|
result := GetEngine(ctx).Delete(&Customer{}, id)
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,64 +114,6 @@ func MigrateDB() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedDB seeds the database with initial data if needed
|
|
||||||
func SeedDB(ctx context.Context) error {
|
|
||||||
if defaultDB == nil {
|
|
||||||
return errors.New("database not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Checking if database seeding is needed...")
|
|
||||||
|
|
||||||
// Check if we need to seed (e.g., no companies exist)
|
|
||||||
var count int64
|
|
||||||
if err := defaultDB.Model(&Company{}).Count(&count).Error; err != nil {
|
|
||||||
return fmt.Errorf("error checking if seeding is needed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If data already exists, skip seeding
|
|
||||||
if count > 0 {
|
|
||||||
log.Println("Database already contains data, skipping seeding")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Seeding database with initial data...")
|
|
||||||
|
|
||||||
// Start a transaction for all seed operations
|
|
||||||
return defaultDB.Transaction(func(tx *gorm.DB) error {
|
|
||||||
// Create a default company
|
|
||||||
defaultCompany := Company{
|
|
||||||
Name: "Default Company",
|
|
||||||
}
|
|
||||||
if err := tx.Create(&defaultCompany).Error; err != nil {
|
|
||||||
return fmt.Errorf("error creating default company: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an admin user
|
|
||||||
adminUser := User{
|
|
||||||
Email: "admin@example.com",
|
|
||||||
Role: RoleAdmin,
|
|
||||||
CompanyID: defaultCompany.ID,
|
|
||||||
HourlyRate: 100.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash a default password
|
|
||||||
pwData, err := HashPassword("Admin@123456")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error hashing password: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
adminUser.Salt = pwData.Salt
|
|
||||||
adminUser.Hash = pwData.Hash
|
|
||||||
|
|
||||||
if err := tx.Create(&adminUser).Error; err != nil {
|
|
||||||
return fmt.Errorf("error creating admin user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Database seeding completed successfully")
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEngine returns the DB instance, possibly with context
|
// GetEngine returns the DB instance, possibly with context
|
||||||
func GetEngine(ctx context.Context) *gorm.DB {
|
func GetEngine(ctx context.Context) *gorm.DB {
|
||||||
if defaultDB == nil {
|
if defaultDB == nil {
|
||||||
@@ -199,6 +141,31 @@ func CloseDB() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetGormDB(dbConfig DatabaseConfig, dbName string) (*gorm.DB, error) {
|
||||||
|
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||||
|
dbConfig.Host, dbConfig.Port, dbConfig.User, dbConfig.Password, dbName, dbConfig.SSLMode)
|
||||||
|
|
||||||
|
// Configure GORM logger
|
||||||
|
gormLogger := logger.New(
|
||||||
|
log.New(log.Writer(), "\r\n", log.LstdFlags), // io writer
|
||||||
|
logger.Config{
|
||||||
|
SlowThreshold: 200 * time.Millisecond, // Slow SQL threshold
|
||||||
|
LogLevel: dbConfig.LogLevel, // Log level
|
||||||
|
IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
|
||||||
|
Colorful: true, // Enable color
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||||
|
Logger: gormLogger,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error connecting to the database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateModel updates a model based on the set pointer fields
|
// UpdateModel updates a model based on the set pointer fields
|
||||||
func UpdateModel(ctx context.Context, model any, updates any) error {
|
func UpdateModel(ctx context.Context, model any, updates any) error {
|
||||||
updateValue := reflect.ValueOf(updates)
|
updateValue := reflect.ValueOf(updates)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type JWTConfig struct {
|
||||||
|
Secret string `env:"JWT_SECRET" default:""`
|
||||||
|
TokenDuration time.Duration `env:"JWT_TOKEN_DURATION" default:"24h"`
|
||||||
|
KeyGenerate bool `env:"JWT_KEY_GENERATE" default:"true"`
|
||||||
|
KeyDir string `env:"JWT_KEY_DIR" default:"./keys"`
|
||||||
|
PrivKeyFile string `env:"JWT_PRIV_KEY_FILE" default:"jwt.key"`
|
||||||
|
PubKeyFile string `env:"JWT_PUB_KEY_FILE" default:"jwt.key.pub"`
|
||||||
|
KeyBits int `env:"JWT_KEY_BITS" default:"2048"`
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/timetracker/backend/internal/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
type Project struct {
|
type Project struct {
|
||||||
EntityBase
|
EntityBase
|
||||||
Name string `gorm:"column:name;not null"`
|
Name string `gorm:"column:name;not null"`
|
||||||
CustomerID ulid.ULID `gorm:"column:customer_id;type:uuid;not null"`
|
CustomerID *types.ULID `gorm:"column:customer_id;type:bytea;not null"`
|
||||||
|
|
||||||
// Relationships (for Eager Loading)
|
// Relationships (for Eager Loading)
|
||||||
Customer *Customer `gorm:"foreignKey:CustomerID"`
|
Customer *Customer `gorm:"foreignKey:CustomerID"`
|
||||||
@@ -27,14 +27,14 @@ func (Project) TableName() string {
|
|||||||
// ProjectCreate contains the fields for creating a new project
|
// ProjectCreate contains the fields for creating a new project
|
||||||
type ProjectCreate struct {
|
type ProjectCreate struct {
|
||||||
Name string
|
Name string
|
||||||
CustomerID ulid.ULID
|
CustomerID *types.ULID
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectUpdate contains the updatable fields of a project
|
// ProjectUpdate contains the updatable fields of a project
|
||||||
type ProjectUpdate struct {
|
type ProjectUpdate struct {
|
||||||
ID ulid.ULID `gorm:"-"` // Exclude from updates
|
ID types.ULID `gorm:"-"` // Exclude from updates
|
||||||
Name *string `gorm:"column:name"`
|
Name *string `gorm:"column:name"`
|
||||||
CustomerID *ulid.ULID `gorm:"column:customer_id"`
|
CustomerID *types.ULID `gorm:"column:customer_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the Create struct contains valid data
|
// Validate checks if the Create struct contains valid data
|
||||||
@@ -43,7 +43,7 @@ func (pc *ProjectCreate) Validate() error {
|
|||||||
return errors.New("project name cannot be empty")
|
return errors.New("project name cannot be empty")
|
||||||
}
|
}
|
||||||
// Check for valid CustomerID
|
// Check for valid CustomerID
|
||||||
if pc.CustomerID.Compare(ulid.ULID{}) == 0 {
|
if pc.CustomerID.Compare(types.ULID{}) == 0 {
|
||||||
return errors.New("customerID cannot be empty")
|
return errors.New("customerID cannot be empty")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -58,7 +58,7 @@ func (pu *ProjectUpdate) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetProjectByID finds a project by its ID
|
// GetProjectByID finds a project by its ID
|
||||||
func GetProjectByID(ctx context.Context, id ulid.ULID) (*Project, error) {
|
func GetProjectByID(ctx context.Context, id types.ULID) (*Project, error) {
|
||||||
var project Project
|
var project Project
|
||||||
result := GetEngine(ctx).Where("id = ?", id).First(&project)
|
result := GetEngine(ctx).Where("id = ?", id).First(&project)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -71,7 +71,7 @@ func GetProjectByID(ctx context.Context, id ulid.ULID) (*Project, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetProjectWithCustomer loads a project with the associated customer information
|
// GetProjectWithCustomer loads a project with the associated customer information
|
||||||
func GetProjectWithCustomer(ctx context.Context, id ulid.ULID) (*Project, error) {
|
func GetProjectWithCustomer(ctx context.Context, id types.ULID) (*Project, error) {
|
||||||
var project Project
|
var project Project
|
||||||
result := GetEngine(ctx).Preload("Customer").Where("id = ?", id).First(&project)
|
result := GetEngine(ctx).Preload("Customer").Where("id = ?", id).First(&project)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -104,9 +104,9 @@ func GetAllProjectsWithCustomers(ctx context.Context) ([]Project, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetProjectsByCustomerID returns all projects of a specific customer
|
// GetProjectsByCustomerID returns all projects of a specific customer
|
||||||
func GetProjectsByCustomerID(ctx context.Context, customerID ulid.ULID) ([]Project, error) {
|
func GetProjectsByCustomerID(ctx context.Context, customerId types.ULID) ([]Project, error) {
|
||||||
var projects []Project
|
var projects []Project
|
||||||
result := GetEngine(ctx).Where("customer_id = ?", customerID).Find(&projects)
|
result := GetEngine(ctx).Where("customer_id = ?", customerId.ULID).Find(&projects)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@@ -121,13 +121,15 @@ func CreateProject(ctx context.Context, create ProjectCreate) (*Project, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the customer exists
|
// Check if the customer exists
|
||||||
customer, err := GetCustomerByID(ctx, create.CustomerID)
|
if create.CustomerID == nil {
|
||||||
|
customer, err := GetCustomerByID(ctx, *create.CustomerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error checking the customer: %w", err)
|
return nil, fmt.Errorf("error checking the customer: %w", err)
|
||||||
}
|
}
|
||||||
if customer == nil {
|
if customer == nil {
|
||||||
return nil, errors.New("the specified customer does not exist")
|
return nil, errors.New("the specified customer does not exist")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
project := Project{
|
project := Project{
|
||||||
Name: create.Name,
|
Name: create.Name,
|
||||||
@@ -177,7 +179,7 @@ func UpdateProject(ctx context.Context, update ProjectUpdate) (*Project, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteProject deletes a project by its ID
|
// DeleteProject deletes a project by its ID
|
||||||
func DeleteProject(ctx context.Context, id ulid.ULID) error {
|
func DeleteProject(ctx context.Context, id types.ULID) error {
|
||||||
// Here you could check if dependent entities exist
|
// Here you could check if dependent entities exist
|
||||||
result := GetEngine(ctx).Delete(&Project{}, id)
|
result := GetEngine(ctx).Delete(&Project{}, id)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/timetracker/backend/internal/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TimeEntry represents a time entry in the system
|
// TimeEntry represents a time entry in the system
|
||||||
type TimeEntry struct {
|
type TimeEntry struct {
|
||||||
EntityBase
|
EntityBase
|
||||||
UserID ulid.ULID `gorm:"column:user_id;type:uuid;not null;index"`
|
UserID types.ULID `gorm:"column:user_id;type:bytea;not null;index"`
|
||||||
ProjectID ulid.ULID `gorm:"column:project_id;type:uuid;not null;index"`
|
ProjectID types.ULID `gorm:"column:project_id;type:bytea;not null;index"`
|
||||||
ActivityID ulid.ULID `gorm:"column:activity_id;type:uuid;not null;index"`
|
ActivityID types.ULID `gorm:"column:activity_id;type:bytea;not null;index"`
|
||||||
Start time.Time `gorm:"column:start;not null"`
|
Start time.Time `gorm:"column:start;not null"`
|
||||||
End time.Time `gorm:"column:end;not null"`
|
End time.Time `gorm:"column:end;not null"`
|
||||||
Description string `gorm:"column:description"`
|
Description string `gorm:"column:description"`
|
||||||
@@ -34,9 +34,9 @@ func (TimeEntry) TableName() string {
|
|||||||
|
|
||||||
// TimeEntryCreate contains the fields for creating a new time entry
|
// TimeEntryCreate contains the fields for creating a new time entry
|
||||||
type TimeEntryCreate struct {
|
type TimeEntryCreate struct {
|
||||||
UserID ulid.ULID
|
UserID types.ULID
|
||||||
ProjectID ulid.ULID
|
ProjectID types.ULID
|
||||||
ActivityID ulid.ULID
|
ActivityID types.ULID
|
||||||
Start time.Time
|
Start time.Time
|
||||||
End time.Time
|
End time.Time
|
||||||
Description string
|
Description string
|
||||||
@@ -45,10 +45,10 @@ type TimeEntryCreate struct {
|
|||||||
|
|
||||||
// TimeEntryUpdate contains the updatable fields of a time entry
|
// TimeEntryUpdate contains the updatable fields of a time entry
|
||||||
type TimeEntryUpdate struct {
|
type TimeEntryUpdate struct {
|
||||||
ID ulid.ULID `gorm:"-"` // Exclude from updates
|
ID types.ULID `gorm:"-"` // Exclude from updates
|
||||||
UserID *ulid.ULID `gorm:"column:user_id"`
|
UserID *types.ULID `gorm:"column:user_id"`
|
||||||
ProjectID *ulid.ULID `gorm:"column:project_id"`
|
ProjectID *types.ULID `gorm:"column:project_id"`
|
||||||
ActivityID *ulid.ULID `gorm:"column:activity_id"`
|
ActivityID *types.ULID `gorm:"column:activity_id"`
|
||||||
Start *time.Time `gorm:"column:start"`
|
Start *time.Time `gorm:"column:start"`
|
||||||
End *time.Time `gorm:"column:end"`
|
End *time.Time `gorm:"column:end"`
|
||||||
Description *string `gorm:"column:description"`
|
Description *string `gorm:"column:description"`
|
||||||
@@ -58,13 +58,13 @@ type TimeEntryUpdate struct {
|
|||||||
// Validate checks if the Create struct contains valid data
|
// Validate checks if the Create struct contains valid data
|
||||||
func (tc *TimeEntryCreate) Validate() error {
|
func (tc *TimeEntryCreate) Validate() error {
|
||||||
// Check for empty IDs
|
// Check for empty IDs
|
||||||
if tc.UserID.Compare(ulid.ULID{}) == 0 {
|
if tc.UserID.Compare(types.ULID{}) == 0 {
|
||||||
return errors.New("userID cannot be empty")
|
return errors.New("userID cannot be empty")
|
||||||
}
|
}
|
||||||
if tc.ProjectID.Compare(ulid.ULID{}) == 0 {
|
if tc.ProjectID.Compare(types.ULID{}) == 0 {
|
||||||
return errors.New("projectID cannot be empty")
|
return errors.New("projectID cannot be empty")
|
||||||
}
|
}
|
||||||
if tc.ActivityID.Compare(ulid.ULID{}) == 0 {
|
if tc.ActivityID.Compare(types.ULID{}) == 0 {
|
||||||
return errors.New("activityID cannot be empty")
|
return errors.New("activityID cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ func (tu *TimeEntryUpdate) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetTimeEntryByID finds a time entry by its ID
|
// GetTimeEntryByID finds a time entry by its ID
|
||||||
func GetTimeEntryByID(ctx context.Context, id ulid.ULID) (*TimeEntry, error) {
|
func GetTimeEntryByID(ctx context.Context, id types.ULID) (*TimeEntry, error) {
|
||||||
var timeEntry TimeEntry
|
var timeEntry TimeEntry
|
||||||
result := GetEngine(ctx).Where("id = ?", id).First(&timeEntry)
|
result := GetEngine(ctx).Where("id = ?", id).First(&timeEntry)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -116,7 +116,7 @@ func GetTimeEntryByID(ctx context.Context, id ulid.ULID) (*TimeEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetTimeEntryWithRelations loads a time entry with all associated data
|
// GetTimeEntryWithRelations loads a time entry with all associated data
|
||||||
func GetTimeEntryWithRelations(ctx context.Context, id ulid.ULID) (*TimeEntry, error) {
|
func GetTimeEntryWithRelations(ctx context.Context, id types.ULID) (*TimeEntry, error) {
|
||||||
var timeEntry TimeEntry
|
var timeEntry TimeEntry
|
||||||
result := GetEngine(ctx).
|
result := GetEngine(ctx).
|
||||||
Preload("User").
|
Preload("User").
|
||||||
@@ -146,7 +146,7 @@ func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetTimeEntriesByUserID returns all time entries of a user
|
// GetTimeEntriesByUserID returns all time entries of a user
|
||||||
func GetTimeEntriesByUserID(ctx context.Context, userID ulid.ULID) ([]TimeEntry, error) {
|
func GetTimeEntriesByUserID(ctx context.Context, userID types.ULID) ([]TimeEntry, error) {
|
||||||
var timeEntries []TimeEntry
|
var timeEntries []TimeEntry
|
||||||
result := GetEngine(ctx).Where("user_id = ?", userID).Find(&timeEntries)
|
result := GetEngine(ctx).Where("user_id = ?", userID).Find(&timeEntries)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -156,7 +156,7 @@ func GetTimeEntriesByUserID(ctx context.Context, userID ulid.ULID) ([]TimeEntry,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetTimeEntriesByProjectID returns all time entries of a project
|
// GetTimeEntriesByProjectID returns all time entries of a project
|
||||||
func GetTimeEntriesByProjectID(ctx context.Context, projectID ulid.ULID) ([]TimeEntry, error) {
|
func GetTimeEntriesByProjectID(ctx context.Context, projectID types.ULID) ([]TimeEntry, error) {
|
||||||
var timeEntries []TimeEntry
|
var timeEntries []TimeEntry
|
||||||
result := GetEngine(ctx).Where("project_id = ?", projectID).Find(&timeEntries)
|
result := GetEngine(ctx).Where("project_id = ?", projectID).Find(&timeEntries)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -181,7 +181,7 @@ func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]Tim
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SumBillableHoursByProject calculates the billable hours per project
|
// SumBillableHoursByProject calculates the billable hours per project
|
||||||
func SumBillableHoursByProject(ctx context.Context, projectID ulid.ULID) (float64, error) {
|
func SumBillableHoursByProject(ctx context.Context, projectID types.ULID) (float64, error) {
|
||||||
type Result struct {
|
type Result struct {
|
||||||
TotalHours float64
|
TotalHours float64
|
||||||
}
|
}
|
||||||
@@ -247,7 +247,7 @@ func CreateTimeEntry(ctx context.Context, create TimeEntryCreate) (*TimeEntry, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validateReferences checks if all referenced entities exist
|
// validateReferences checks if all referenced entities exist
|
||||||
func validateReferences(tx *gorm.DB, userID, projectID, activityID ulid.ULID) error {
|
func validateReferences(tx *gorm.DB, userID, projectID, activityID types.ULID) error {
|
||||||
// Check user
|
// Check user
|
||||||
var userCount int64
|
var userCount int64
|
||||||
if err := tx.Model(&User{}).Where("id = ?", userID).Count(&userCount).Error; err != nil {
|
if err := tx.Model(&User{}).Where("id = ?", userID).Count(&userCount).Error; err != nil {
|
||||||
@@ -351,7 +351,7 @@ func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteTimeEntry deletes a time entry by its ID
|
// DeleteTimeEntry deletes a time entry by its ID
|
||||||
func DeleteTimeEntry(ctx context.Context, id ulid.ULID) error {
|
func DeleteTimeEntry(ctx context.Context, id types.ULID) error {
|
||||||
result := GetEngine(ctx).Delete(&TimeEntry{}, id)
|
result := GetEngine(ctx).Delete(&TimeEntry{}, id)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return fmt.Errorf("error deleting the time entry: %w", result.Error)
|
return fmt.Errorf("error deleting the time entry: %w", result.Error)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
"github.com/timetracker/backend/internal/types"
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -40,7 +40,7 @@ type User struct {
|
|||||||
Salt string `gorm:"column:salt;not null;type:varchar(64)"` // Base64-encoded Salt
|
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
|
Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Base64-encoded Hash
|
||||||
Role string `gorm:"column:role;not null;default:'user'"`
|
Role string `gorm:"column:role;not null;default:'user'"`
|
||||||
CompanyID ulid.ULID `gorm:"column:company_id;type:uuid;not null;index"`
|
CompanyID *types.ULID `gorm:"column:company_id;type:bytea;index"`
|
||||||
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
|
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
|
||||||
|
|
||||||
// Relationship for Eager Loading
|
// Relationship for Eager Loading
|
||||||
@@ -57,17 +57,17 @@ type UserCreate struct {
|
|||||||
Email string
|
Email string
|
||||||
Password string
|
Password string
|
||||||
Role string
|
Role string
|
||||||
CompanyID ulid.ULID
|
CompanyID *types.ULID
|
||||||
HourlyRate float64
|
HourlyRate float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserUpdate contains the updatable fields of a user
|
// UserUpdate contains the updatable fields of a user
|
||||||
type UserUpdate struct {
|
type UserUpdate struct {
|
||||||
ID ulid.ULID `gorm:"-"` // Exclude from updates
|
ID types.ULID `gorm:"-"` // Exclude from updates
|
||||||
Email *string `gorm:"column:email"`
|
Email *string `gorm:"column:email"`
|
||||||
Password *string `gorm:"-"` // Not stored directly in DB
|
Password *string `gorm:"-"` // Not stored directly in DB
|
||||||
Role *string `gorm:"column:role"`
|
Role *string `gorm:"column:role"`
|
||||||
CompanyID *ulid.ULID `gorm:"column:company_id"`
|
CompanyID types.Nullable[types.ULID] `gorm:"column:company_id"`
|
||||||
HourlyRate *float64 `gorm:"column:hourly_rate"`
|
HourlyRate *float64 `gorm:"column:hourly_rate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ func (uc *UserCreate) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if uc.CompanyID.Compare(ulid.ULID{}) == 0 {
|
if uc.CompanyID.Compare(types.ULID{}) == 0 {
|
||||||
return errors.New("companyID cannot be empty")
|
return errors.New("companyID cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ func (uu *UserUpdate) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByID finds a user by their ID
|
// GetUserByID finds a user by their ID
|
||||||
func GetUserByID(ctx context.Context, id ulid.ULID) (*User, error) {
|
func GetUserByID(ctx context.Context, id types.ULID) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
result := GetEngine(ctx).Where("id = ?", id).First(&user)
|
result := GetEngine(ctx).Where("id = ?", id).First(&user)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -314,7 +314,7 @@ func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUserWithCompany loads a user with their company
|
// GetUserWithCompany loads a user with their company
|
||||||
func GetUserWithCompany(ctx context.Context, id ulid.ULID) (*User, error) {
|
func GetUserWithCompany(ctx context.Context, id types.ULID) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
result := GetEngine(ctx).Preload("Company").Where("id = ?", id).First(&user)
|
result := GetEngine(ctx).Preload("Company").Where("id = ?", id).First(&user)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -336,10 +336,22 @@ func GetAllUsers(ctx context.Context) ([]User, error) {
|
|||||||
return users, nil
|
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
|
// GetUsersByCompanyID returns all users of a company
|
||||||
func GetUsersByCompanyID(ctx context.Context, companyID ulid.ULID) ([]User, error) {
|
func GetUsersByCompanyID(ctx context.Context, companyID types.ULID) ([]User, error) {
|
||||||
var users []User
|
var users []User
|
||||||
result := GetEngine(ctx).Where("company_id = ?", companyID).Find(&users)
|
// Apply the dynamic company condition
|
||||||
|
condition := getCompanyCondition(&companyID)
|
||||||
|
result := GetEngine(ctx).Scopes(condition).Find(&users)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
@@ -436,15 +448,17 @@ func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If CompanyID is updated, check if it exists
|
// If CompanyID is updated, check if it exists
|
||||||
if update.CompanyID != nil && update.CompanyID.Compare(user.CompanyID) != 0 {
|
if update.CompanyID.Valid && update.CompanyID.Value != nil {
|
||||||
|
if user.CompanyID == nil || *update.CompanyID.Value != *user.CompanyID {
|
||||||
var companyCount int64
|
var companyCount int64
|
||||||
if err := tx.Model(&Company{}).Where("id = ?", *update.CompanyID).Count(&companyCount).Error; err != nil {
|
if err := tx.Model(&Company{}).Where("id = ?", *update.CompanyID.Value).Count(&companyCount).Error; err != nil {
|
||||||
return fmt.Errorf("error checking company: %w", err)
|
return fmt.Errorf("error checking company: %w", err)
|
||||||
}
|
}
|
||||||
if companyCount == 0 {
|
if companyCount == 0 {
|
||||||
return errors.New("the specified company does not exist")
|
return errors.New("the specified company does not exist")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If password is updated, rehash with new salt
|
// If password is updated, rehash with new salt
|
||||||
if update.Password != nil {
|
if update.Password != nil {
|
||||||
@@ -472,8 +486,13 @@ func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) {
|
|||||||
if update.Role != nil {
|
if update.Role != nil {
|
||||||
updates["role"] = *update.Role
|
updates["role"] = *update.Role
|
||||||
}
|
}
|
||||||
if update.CompanyID != nil {
|
if update.CompanyID.Valid {
|
||||||
updates["company_id"] = *update.CompanyID
|
if update.CompanyID.Value == nil {
|
||||||
|
updates["company_id"] = nil
|
||||||
|
} else {
|
||||||
|
updates["company_id"] = *update.CompanyID.Value
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
if update.HourlyRate != nil {
|
if update.HourlyRate != nil {
|
||||||
updates["hourly_rate"] = *update.HourlyRate
|
updates["hourly_rate"] = *update.HourlyRate
|
||||||
@@ -498,7 +517,7 @@ func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser deletes a user by their ID
|
// DeleteUser deletes a user by their ID
|
||||||
func DeleteUser(ctx context.Context, id ulid.ULID) error {
|
func DeleteUser(ctx context.Context, id types.ULID) error {
|
||||||
// Here one could check if dependent entities exist
|
// Here one could check if dependent entities exist
|
||||||
// e.g., don't delete if time entries still exist
|
// e.g., don't delete if time entries still exist
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nullable[T] - Generischer Typ für optionale Werte (nullable fields)
|
||||||
|
type Nullable[T any] struct {
|
||||||
|
Value *T // Der tatsächliche Wert (kann nil sein)
|
||||||
|
Valid bool // Gibt an, ob der Wert gesetzt wurde
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNullable erstellt eine gültige Nullable-Instanz
|
||||||
|
func NewNullable[T any](value T) Nullable[T] {
|
||||||
|
return Nullable[T]{Value: &value, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Null erstellt eine leere Nullable-Instanz (ungesetzt)
|
||||||
|
func Null[T any]() Nullable[T] {
|
||||||
|
return Nullable[T]{Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Undefined[T any]() Nullable[T] {
|
||||||
|
return Nullable[T]{Valid: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON - Serialisiert `Nullable[T]` korrekt ins JSON-Format
|
||||||
|
func (n Nullable[T]) MarshalJSON() ([]byte, error) {
|
||||||
|
if !n.Valid {
|
||||||
|
return []byte("null"), nil // Wenn nicht valid, dann NULL
|
||||||
|
}
|
||||||
|
return json.Marshal(n.Value) // Serialisiert den tatsächlichen Wert
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON - Deserialisiert JSON in `Nullable[T]`
|
||||||
|
func (n *Nullable[T]) UnmarshalJSON(data []byte) error {
|
||||||
|
if string(data) == "null" {
|
||||||
|
n.Valid = true // Wert wurde gesetzt, aber auf NULL
|
||||||
|
n.Value = nil // Explizit NULL setzen
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var v T
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return fmt.Errorf("invalid JSON for Nullable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n.Value = &v
|
||||||
|
n.Valid = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ULID wraps ulid.ULID to make it work nicely with GORM
|
||||||
|
type ULID struct {
|
||||||
|
ulid.ULID
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewULIDWrapper creates a new ULID with a new ULID
|
||||||
|
func NewULIDWrapper() ULID {
|
||||||
|
return ULID{ULID: ulid.Make()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromULID creates a ULID from a ulid.ULID
|
||||||
|
func FromULID(id ulid.ULID) ULID {
|
||||||
|
return ULID{ULID: id}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ULIDWrapperFromString creates a ULID from a string
|
||||||
|
func ULIDFromString(id string) (ULID, error) {
|
||||||
|
parsed, err := ulid.Parse(id)
|
||||||
|
if err != nil {
|
||||||
|
return ULID{}, fmt.Errorf("failed to parse ULID string: %w", err)
|
||||||
|
}
|
||||||
|
return ULID{ULID: parsed}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements the sql.Scanner interface for ULID
|
||||||
|
func (u *ULID) Scan(src any) error {
|
||||||
|
switch v := src.(type) {
|
||||||
|
case []byte:
|
||||||
|
// If it's exactly 16 bytes, it's the binary representation
|
||||||
|
if len(v) == 16 {
|
||||||
|
copy(u.ULID[:], v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Otherwise, try as string
|
||||||
|
return fmt.Errorf("cannot scan []byte of length %d into ULID", len(v))
|
||||||
|
case string:
|
||||||
|
parsed, err := ulid.Parse(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse ULID: %w", err)
|
||||||
|
}
|
||||||
|
u.ULID = parsed
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot scan %T into ULID", src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements the driver.Valuer interface for ULID
|
||||||
|
// Returns the binary representation of the ULID for maximum efficiency
|
||||||
|
func (u ULID) Value() (driver.Value, error) {
|
||||||
|
return u.ULID.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GormValue implements the gorm.Valuer interface for ULID
|
||||||
|
func (u ULID) GormValue(ctx context.Context, db *gorm.DB) clause.Expr {
|
||||||
|
return clause.Expr{
|
||||||
|
SQL: "?",
|
||||||
|
Vars: []any{u.Bytes()},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare implements comparison for ULID
|
||||||
|
func (u ULID) Compare(other ULID) int {
|
||||||
|
return u.ULID.Compare(other.ULID)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
packages:
|
|
||||||
- path: github.com/timetracker/backend/dto
|
|
||||||
type_mappings:
|
|
||||||
"time.Time": "string"
|
|
||||||
"ulid.ULID": "string"
|
|
||||||
output_path: ../frontend/src/types/dto.ts
|
|
||||||
@@ -11,6 +11,16 @@ services:
|
|||||||
POSTGRES_DB: timetracker
|
POSTGRES_DB: timetracker
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql/data
|
- 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:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Note:** This document describes a *conceptual* architecture and is not a final, binding requirement.
|
**Note:** This document describes a *conceptual* architecture and is not a final, binding requirement.
|
||||||
|
|
||||||
The backend is written in Go and follows the principles of **Clean Architecture** and **Domain-Driven Design (DDD)**.
|
The backend is written in Go using idiomatic Go patterns. While initially following Clean Architecture and DDD principles, we've adapted to a more pragmatic approach that better fits Go's conventions and reduces boilerplate code.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
# Database Schema (PostgreSQL)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Multi-Tenant
|
|
||||||
CREATE TABLE companies (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
address TEXT,
|
|
||||||
contact_email VARCHAR(255),
|
|
||||||
contact_phone VARCHAR(50),
|
|
||||||
logo_url TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Go structs for creating and updating customers
|
|
||||||
-- type CustomerCreate struct {
|
|
||||||
-- Name string
|
|
||||||
-- CompanyID int
|
|
||||||
-- }
|
|
||||||
|
|
||||||
-- type CustomerUpdate struct {
|
|
||||||
-- ID ulid.ULID
|
|
||||||
-- Name *string
|
|
||||||
-- CompanyID *int
|
|
||||||
-- }
|
|
||||||
|
|
||||||
-- Go structs for creating and updating companies
|
|
||||||
-- type CompanyCreate struct {
|
|
||||||
-- Name string
|
|
||||||
-- }
|
|
||||||
|
|
||||||
-- type CompanyUpdate struct {
|
|
||||||
-- ID ulid.ULID
|
|
||||||
-- Name *string
|
|
||||||
-- }
|
|
||||||
|
|
||||||
-- Go structs for creating and updating activities
|
|
||||||
-- type ActivityCreate struct {
|
|
||||||
-- Name string
|
|
||||||
-- BillingRate float64
|
|
||||||
-- }
|
|
||||||
|
|
||||||
-- type ActivityUpdate struct {
|
|
||||||
-- ID ulid.ULID
|
|
||||||
-- Name *string
|
|
||||||
-- BillingRate *float64
|
|
||||||
-- }
|
|
||||||
|
|
||||||
-- Users and Roles
|
|
||||||
CREATE TABLE roles (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
permissions JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
company_id UUID REFERENCES companies(id),
|
|
||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
|
||||||
first_name VARCHAR(100),
|
|
||||||
last_name VARCHAR(100),
|
|
||||||
role_id INTEGER REFERENCES roles(id),
|
|
||||||
hourly_rate DECIMAL(10, 2),
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Customers
|
|
||||||
CREATE TABLE customers (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
company_id UUID NOT NULL REFERENCES companies(id),
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
contact_person VARCHAR(255),
|
|
||||||
email VARCHAR(255),
|
|
||||||
phone VARCHAR(50),
|
|
||||||
address TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Projects
|
|
||||||
CREATE TABLE projects (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
company_id UUID NOT NULL REFERENCES companies(id),
|
|
||||||
customer_id UUID REFERENCES customers(id),
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
start_date DATE,
|
|
||||||
end_date DATE,
|
|
||||||
status VARCHAR(50),
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Activities
|
|
||||||
CREATE TABLE activities (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
company_id UUID NOT NULL REFERENCES companies(id),
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
billing_rate DECIMAL(10, 2),
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Time bookings
|
|
||||||
CREATE TABLE time_entries (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
company_id UUID NOT NULL REFERENCES companies(id),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
|
||||||
project_id UUID NOT NULL REFERENCES projects(id),
|
|
||||||
activity_id UUID NOT NULL REFERENCES activities(id),
|
|
||||||
start_time TIMESTAMP NOT NULL,
|
|
||||||
end_time TIMESTAMP NOT NULL,
|
|
||||||
duration INTEGER NOT NULL, -- in minutes
|
|
||||||
description TEXT,
|
|
||||||
billable_percentage INTEGER NOT NULL DEFAULT 100,
|
|
||||||
billing_rate DECIMAL(10, 2),
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Version 2: Sprint/Task Management
|
|
||||||
CREATE TABLE sprints (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
project_id UUID NOT NULL REFERENCES projects(id),
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
status VARCHAR(50),
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE task_statuses (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
company_id UUID NOT NULL REFERENCES companies(id),
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
color VARCHAR(7),
|
|
||||||
position INTEGER NOT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE tasks (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
company_id UUID NOT NULL REFERENCES companies(id),
|
|
||||||
project_id UUID NOT NULL REFERENCES projects(id),
|
|
||||||
sprint_id UUID REFERENCES sprints(id),
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
assignee_id UUID REFERENCES users(id),
|
|
||||||
status_id INTEGER REFERENCES task_statuses(id),
|
|
||||||
priority VARCHAR(50),
|
|
||||||
estimate INTEGER, -- in minutes
|
|
||||||
due_date TIMESTAMP,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE kanban_boards (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
company_id UUID NOT NULL REFERENCES companies(id),
|
|
||||||
project_id UUID NOT NULL REFERENCES projects(id),
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE kanban_columns (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
board_id UUID NOT NULL REFERENCES kanban_boards(id),
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
position INTEGER NOT NULL,
|
|
||||||
task_status_id INTEGER REFERENCES task_statuses(id),
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Linking time entries and tasks
|
|
||||||
ALTER TABLE time_entries ADD COLUMN task_id UUID REFERENCES tasks(id);
|
|
||||||
|
|
||||||
-- Indexes for performance
|
|
||||||
CREATE INDEX idx_time_entries_user ON time_entries(user_id);
|
|
||||||
CREATE INDEX idx_time_entries_project ON time_entries(project_id);
|
|
||||||
CREATE INDEX idx_time_entries_date ON time_entries(start_time);
|
|
||||||
CREATE INDEX idx_projects_company ON projects(company_id);
|
|
||||||
CREATE INDEX idx_users_company ON users(company_id);
|
|
||||||
CREATE INDEX idx_tasks_project ON tasks(project_id);
|
|
||||||
CREATE INDEX idx_tasks_sprint ON tasks(sprint_id);
|
|
||||||
@@ -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
|
|
||||||
+41
-21
@@ -1,3 +1,4 @@
|
|||||||
|
import { Nullable } from "./nullable";
|
||||||
// Code generated by tygo. DO NOT EDIT.
|
// Code generated by tygo. DO NOT EDIT.
|
||||||
|
|
||||||
//////////
|
//////////
|
||||||
@@ -24,6 +25,24 @@ export interface ActivityUpdateDto {
|
|||||||
billingRate?: number /* float64 */;
|
billingRate?: number /* float64 */;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//////////
|
||||||
|
// source: auth_dto.go
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LoginDto represents the login request
|
||||||
|
*/
|
||||||
|
export interface LoginDto {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* TokenDto represents the response after successful authentication
|
||||||
|
*/
|
||||||
|
export interface TokenDto {
|
||||||
|
token: string;
|
||||||
|
user: UserDto;
|
||||||
|
}
|
||||||
|
|
||||||
//////////
|
//////////
|
||||||
// source: company_dto.go
|
// source: company_dto.go
|
||||||
|
|
||||||
@@ -54,11 +73,12 @@ export interface CustomerDto {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastEditorID: string;
|
lastEditorID: string;
|
||||||
name: string;
|
name: string;
|
||||||
companyId: number /* int */;
|
companyId?: string;
|
||||||
|
owningUserID?: string;
|
||||||
}
|
}
|
||||||
export interface CustomerCreateDto {
|
export interface CustomerCreateDto {
|
||||||
name: string;
|
name: string;
|
||||||
companyId: number /* int */;
|
companyId?: string;
|
||||||
}
|
}
|
||||||
export interface CustomerUpdateDto {
|
export interface CustomerUpdateDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -66,7 +86,8 @@ export interface CustomerUpdateDto {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
lastEditorID?: string;
|
lastEditorID?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
companyId?: number /* int */;
|
companyId?: Nullable<string>;
|
||||||
|
owningUserID?: Nullable<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
//////////
|
//////////
|
||||||
@@ -78,11 +99,11 @@ export interface ProjectDto {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastEditorID: string;
|
lastEditorID: string;
|
||||||
name: string;
|
name: string;
|
||||||
customerId: number /* int */;
|
customerId: string;
|
||||||
}
|
}
|
||||||
export interface ProjectCreateDto {
|
export interface ProjectCreateDto {
|
||||||
name: string;
|
name: string;
|
||||||
customerId: number /* int */;
|
customerId: string;
|
||||||
}
|
}
|
||||||
export interface ProjectUpdateDto {
|
export interface ProjectUpdateDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -90,7 +111,7 @@ export interface ProjectUpdateDto {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
lastEditorID?: string;
|
lastEditorID?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
customerId?: number /* int */;
|
customerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
//////////
|
//////////
|
||||||
@@ -101,18 +122,18 @@ export interface TimeEntryDto {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastEditorID: string;
|
lastEditorID: string;
|
||||||
userId: number /* int */;
|
userId: string;
|
||||||
projectId: number /* int */;
|
projectId: string;
|
||||||
activityId: number /* int */;
|
activityId: string;
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
description: string;
|
description: string;
|
||||||
billable: number /* int */; // Percentage (0-100)
|
billable: number /* int */; // Percentage (0-100)
|
||||||
}
|
}
|
||||||
export interface TimeEntryCreateDto {
|
export interface TimeEntryCreateDto {
|
||||||
userId: number /* int */;
|
userId: string;
|
||||||
projectId: number /* int */;
|
projectId: string;
|
||||||
activityId: number /* int */;
|
activityId: string;
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -123,9 +144,9 @@ export interface TimeEntryUpdateDto {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
lastEditorID?: string;
|
lastEditorID?: string;
|
||||||
userId?: number /* int */;
|
userId?: string;
|
||||||
projectId?: number /* int */;
|
projectId?: string;
|
||||||
activityId?: number /* int */;
|
activityId?: string;
|
||||||
start?: string;
|
start?: string;
|
||||||
end?: string;
|
end?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -141,16 +162,15 @@ export interface UserDto {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastEditorID: string;
|
lastEditorID: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string; // Note: In a real application, you would NEVER send the password in a DTO. This is just for demonstration.
|
|
||||||
role: string;
|
role: string;
|
||||||
companyId: number /* int */;
|
companyId?: string;
|
||||||
hourlyRate: number /* float64 */;
|
hourlyRate: number /* float64 */;
|
||||||
}
|
}
|
||||||
export interface UserCreateDto {
|
export interface UserCreateDto {
|
||||||
email: string;
|
email: string;
|
||||||
password: string; // Note: In a real application, you would NEVER send the password in a DTO. This is just for demonstration.
|
password: string;
|
||||||
role: string;
|
role: string;
|
||||||
companyId: number /* int */;
|
companyId?: string;
|
||||||
hourlyRate: number /* float64 */;
|
hourlyRate: number /* float64 */;
|
||||||
}
|
}
|
||||||
export interface UserUpdateDto {
|
export interface UserUpdateDto {
|
||||||
@@ -159,8 +179,8 @@ export interface UserUpdateDto {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
lastEditorID?: string;
|
lastEditorID?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
password?: string; // Note: In a real application, you would NEVER send the password in a DTO. This is just for demonstration.
|
password?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
companyId?: number /* int */;
|
companyId?: Nullable<string>;
|
||||||
hourlyRate?: number /* float64 */;
|
hourlyRate?: number /* float64 */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export type Nullable<T> = T | null;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Refactoring Plan for backend/internal/api/handlers
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Refactor the code in `backend/internal/api/handlers` to reduce repetition and create helper functions for boilerplate operations, utilizing functions from `backend/internal/api/utils/handler_utils.go` and creating new ones if necessary.
|
||||||
|
|
||||||
|
## Analysis
|
||||||
|
|
||||||
|
The following common patterns were identified in the handler files:
|
||||||
|
|
||||||
|
1. **Error Handling:** Each handler function repeats the same error handling pattern.
|
||||||
|
2. **DTO Binding:** Parsing the request body and handling potential errors.
|
||||||
|
3. **ID Parsing:** Parsing the ID from the URL and handling potential errors.
|
||||||
|
4. **DTO Conversion:** Converting between DTOs and models.
|
||||||
|
5. **Success Responses:** Calling `responses.SuccessResponse` with the appropriate HTTP status code and data.
|
||||||
|
6. **Not Found Responses:** Checking if a record exists and calling `responses.NotFoundResponse` if it doesn't.
|
||||||
|
|
||||||
|
The `Update` handler is the most complex and has the most potential for refactoring.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. **Implement a generic `HandleUpdate` function in `handler_utils.go`:** This function will encapsulate the common logic for updating entities, including parsing the ID, binding the JSON, converting the DTO to a model, calling the update function, and handling errors and not found cases. The function will also handle nullable fields correctly.
|
||||||
|
2. **Modify the existing handlers to use the new `HandleUpdate` function:** This will involve removing the duplicated code from each handler and calling the generic function instead.
|
||||||
|
3. **Create new helper functions in `handler_utils.go` if needed:** If there are any specific operations that are not covered by the existing utility functions, I will create new ones to handle them.
|
||||||
|
|
||||||
Reference in New Issue
Block a user