diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 6dc1c4c..2a9ff9a 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -2,13 +2,14 @@ package main import ( "fmt" + "log" "net/http" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" _ "github.com/timetracker/backend/docs" // This line is important for swag to work - "github.com/timetracker/backend/internal/infrastructure/persistence/db" + "github.com/timetracker/backend/internal/models" _ "gorm.io/driver/postgres" // GORM IMPORTS MARKER ) @@ -31,15 +32,19 @@ func helloHandler(c *gin.Context) { func main() { - db, _ := db.NewDatasourceContainer(db.DatabaseConfig{ + dbConfig := models.DatabaseConfig{ Host: "localhost", Port: 5432, - User: "timetracker", - Password: "timetracker", - DBName: "timetracker", - SSLMode: "disable", - }) + User: "postgres", + Password: "password", + DBName: "mydatabase", + SSLMode: "disable", // Für Entwicklungsumgebung + } + // Datenbank initialisieren + if err := models.InitDB(dbConfig); err != nil { + log.Fatalf("Fehler bei der DB-Initialisierung: %v", err) + } r := gin.Default() r.GET("/", helloHandler) diff --git a/backend/go.mod b/backend/go.mod index 1f04f11..68318b4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -48,7 +48,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.15.0 // indirect - golang.org/x/crypto v0.36.0 // indirect + golang.org/x/crypto v0.36.0 golang.org/x/net v0.37.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect diff --git a/backend/internal/domain/entities/activity.go b/backend/internal/domain/entities/activity.go deleted file mode 100644 index 9bfdf02..0000000 --- a/backend/internal/domain/entities/activity.go +++ /dev/null @@ -1,22 +0,0 @@ -package entities - -import ( - "github.com/oklog/ulid/v2" -) - -type Activity struct { - EntityBase - Name string - BillingRate float64 -} - -type ActivityUpdate struct { - ID ulid.ULID - Name *string - BillingRate *float64 -} - -type ActivityCreate struct { - Name string - BillingRate float64 -} diff --git a/backend/internal/domain/entities/base.go b/backend/internal/domain/entities/base.go deleted file mode 100644 index 2f9a623..0000000 --- a/backend/internal/domain/entities/base.go +++ /dev/null @@ -1,14 +0,0 @@ -package entities - -import ( - "time" - - "github.com/oklog/ulid/v2" -) - -type EntityBase struct { - ID ulid.ULID - CreatedAt time.Time - UpdatedAt time.Time - LastEditorID ulid.ULID -} diff --git a/backend/internal/domain/entities/company.go b/backend/internal/domain/entities/company.go deleted file mode 100644 index a6fdc77..0000000 --- a/backend/internal/domain/entities/company.go +++ /dev/null @@ -1,17 +0,0 @@ -package entities - -import "github.com/oklog/ulid/v2" - -type Company struct { - EntityBase - Name string -} - -type CompanyCreate struct { - Name string -} - -type CompanyUpdate struct { - ID ulid.ULID - Name *string -} diff --git a/backend/internal/domain/entities/customer.go b/backend/internal/domain/entities/customer.go deleted file mode 100644 index 39eb16e..0000000 --- a/backend/internal/domain/entities/customer.go +++ /dev/null @@ -1,20 +0,0 @@ -package entities - -import "github.com/oklog/ulid/v2" - -type Customer struct { - EntityBase - Name string - CompanyID int -} - -type CustomerCreate struct { - Name string - CompanyID int -} - -type CustomerUpdate struct { - ID ulid.ULID - Name *string - CompanyID *int -} diff --git a/backend/internal/domain/entities/project.go b/backend/internal/domain/entities/project.go deleted file mode 100644 index 76f03aa..0000000 --- a/backend/internal/domain/entities/project.go +++ /dev/null @@ -1,20 +0,0 @@ -package entities - -import "github.com/oklog/ulid/v2" - -type Project struct { - EntityBase - Name string - CustomerID int -} - -type ProjectCreate struct { - Name string - CustomerID int -} - -type ProjectUpdate struct { - ID ulid.ULID - Name *string - CustomerID *int -} diff --git a/backend/internal/domain/entities/timeentry.go b/backend/internal/domain/entities/timeentry.go deleted file mode 100644 index 8dde54e..0000000 --- a/backend/internal/domain/entities/timeentry.go +++ /dev/null @@ -1,39 +0,0 @@ -package entities - -import ( - "time" - - "github.com/oklog/ulid/v2" -) - -type TimeEntry struct { - EntityBase - UserID int - ProjectID int - ActivityID int - Start time.Time - End time.Time - Description string - Billable int // Percentage (0-100) -} - -type TimeEntryCreate struct { - UserID int - ProjectID int - ActivityID int - Start time.Time - End time.Time - Description string - Billable int // Percentage (0-100) -} - -type TimeEntryUpdate struct { - ID ulid.ULID - UserID *int - ProjectID *int - ActivityID *int - Start *time.Time - End *time.Time - Description *string - Billable *int // Percentage (0-100) -} diff --git a/backend/internal/domain/entities/user.go b/backend/internal/domain/entities/user.go deleted file mode 100644 index 155e5b4..0000000 --- a/backend/internal/domain/entities/user.go +++ /dev/null @@ -1,29 +0,0 @@ -package entities - -import "github.com/oklog/ulid/v2" - -type User struct { - EntityBase - Email string - Salt string - Role string - CompanyID int - HourlyRate float64 -} - -type UserCreate struct { - Email string - Password string - Role string - CompanyID int - HourlyRate float64 -} - -type UserUpdate struct { - ID ulid.ULID - Email *string - Password *string - Role *string - CompanyID *int - HourlyRate *float64 -} diff --git a/backend/internal/domain/persistence/activity_datasource.go b/backend/internal/domain/persistence/activity_datasource.go deleted file mode 100644 index a606423..0000000 --- a/backend/internal/domain/persistence/activity_datasource.go +++ /dev/null @@ -1,15 +0,0 @@ -package persistence - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" -) - -type ActivityDatasource interface { - Get(ctx context.Context, id ulid.ULID) (*entities.Activity, error) - Create(ctx context.Context, activity *entities.Activity) error - Update(ctx context.Context, activity *entities.Activity) error - Delete(ctx context.Context, id ulid.ULID) error -} diff --git a/backend/internal/domain/persistence/company_datasource.go b/backend/internal/domain/persistence/company_datasource.go deleted file mode 100644 index 1e8e92c..0000000 --- a/backend/internal/domain/persistence/company_datasource.go +++ /dev/null @@ -1,15 +0,0 @@ -package persistence - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" -) - -type CompanyDatasource interface { - Get(ctx context.Context, id ulid.ULID) (*entities.Company, error) - Create(ctx context.Context, company *entities.Company) error - Update(ctx context.Context, company *entities.Company) error - Delete(ctx context.Context, id ulid.ULID) error -} diff --git a/backend/internal/domain/persistence/customer_datasource.go b/backend/internal/domain/persistence/customer_datasource.go deleted file mode 100644 index c935d44..0000000 --- a/backend/internal/domain/persistence/customer_datasource.go +++ /dev/null @@ -1,15 +0,0 @@ -package persistence - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" -) - -type CustomerDatasource interface { - Get(ctx context.Context, id ulid.ULID) (*entities.Customer, error) - Create(ctx context.Context, customer *entities.Customer) error - Update(ctx context.Context, customer *entities.Customer) error - Delete(ctx context.Context, id ulid.ULID) error -} diff --git a/backend/internal/domain/persistence/project_datasource.go b/backend/internal/domain/persistence/project_datasource.go deleted file mode 100644 index 4a1290d..0000000 --- a/backend/internal/domain/persistence/project_datasource.go +++ /dev/null @@ -1,15 +0,0 @@ -package persistence - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" -) - -type ProjectDatasource interface { - Get(ctx context.Context, id ulid.ULID) (*entities.Project, error) - Create(ctx context.Context, project *entities.Project) error - Update(ctx context.Context, project *entities.Project) error - Delete(ctx context.Context, id ulid.ULID) error -} diff --git a/backend/internal/domain/persistence/timeentry_datasource.go b/backend/internal/domain/persistence/timeentry_datasource.go deleted file mode 100644 index a9fde17..0000000 --- a/backend/internal/domain/persistence/timeentry_datasource.go +++ /dev/null @@ -1,18 +0,0 @@ -package persistence - -import ( - "context" - - "time" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" -) - -type TimeEntryDatasource interface { - Get(ctx context.Context, id ulid.ULID) (*entities.TimeEntry, error) - Create(ctx context.Context, timeEntry *entities.TimeEntry) error - Update(ctx context.Context, timeEntry *entities.TimeEntry) error - Delete(ctx context.Context, id ulid.ULID) error - GetByRange(ctx context.Context, userID ulid.ULID, from time.Time, to time.Time) ([]*entities.TimeEntry, error) -} diff --git a/backend/internal/domain/persistence/user_datasource.go b/backend/internal/domain/persistence/user_datasource.go deleted file mode 100644 index 58fd5dc..0000000 --- a/backend/internal/domain/persistence/user_datasource.go +++ /dev/null @@ -1,16 +0,0 @@ -package persistence - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" -) - -type UserDatasource interface { - Get(ctx context.Context, id ulid.ULID) (*entities.User, error) - Create(ctx context.Context, user *entities.User, passwordHash string, salt string) error - Update(ctx context.Context, user *entities.User, passwordHash *string) error - Delete(ctx context.Context, id ulid.ULID) error - GetByEmail(ctx context.Context, email string) (*entities.User, error) -} diff --git a/backend/internal/domain/repositories/activity_repository.go b/backend/internal/domain/repositories/activity_repository.go deleted file mode 100644 index 83e4f69..0000000 --- a/backend/internal/domain/repositories/activity_repository.go +++ /dev/null @@ -1,15 +0,0 @@ -package repositories - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" -) - -type ActivityRepository interface { - Get(ctx context.Context, id ulid.ULID) (*entities.Activity, error) - Create(ctx context.Context, activity *entities.ActivityCreate) error - Update(ctx context.Context, activity *entities.ActivityUpdate) error - Delete(ctx context.Context, id ulid.ULID) error -} diff --git a/backend/internal/domain/repositories/company_repository.go b/backend/internal/domain/repositories/company_repository.go deleted file mode 100644 index 54f6187..0000000 --- a/backend/internal/domain/repositories/company_repository.go +++ /dev/null @@ -1,15 +0,0 @@ -package repositories - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" -) - -type CompanyRepository interface { - Get(ctx context.Context, id ulid.ULID) (*entities.Company, error) - Create(ctx context.Context, company *entities.CompanyCreate) error - Update(ctx context.Context, company *entities.CompanyUpdate) error - Delete(ctx context.Context, id ulid.ULID) error -} diff --git a/backend/internal/domain/repositories/customer_repository.go b/backend/internal/domain/repositories/customer_repository.go deleted file mode 100644 index dde8e20..0000000 --- a/backend/internal/domain/repositories/customer_repository.go +++ /dev/null @@ -1,15 +0,0 @@ -package repositories - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" -) - -type CustomerRepository interface { - Get(ctx context.Context, id ulid.ULID) (*entities.Customer, error) - Create(ctx context.Context, customer *entities.CustomerCreate) error - Update(ctx context.Context, customer *entities.CustomerUpdate) error - Delete(ctx context.Context, id ulid.ULID) error -} diff --git a/backend/internal/domain/repositories/project_repository.go b/backend/internal/domain/repositories/project_repository.go deleted file mode 100644 index 0a556b1..0000000 --- a/backend/internal/domain/repositories/project_repository.go +++ /dev/null @@ -1,15 +0,0 @@ -package repositories - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" -) - -type ProjectRepository interface { - Get(ctx context.Context, id ulid.ULID) (*entities.Project, error) - Create(ctx context.Context, project *entities.ProjectCreate) error - Update(ctx context.Context, project *entities.ProjectUpdate) error - Delete(ctx context.Context, id ulid.ULID) error -} diff --git a/backend/internal/domain/repositories/timeentry_repository.go b/backend/internal/domain/repositories/timeentry_repository.go deleted file mode 100644 index 884b204..0000000 --- a/backend/internal/domain/repositories/timeentry_repository.go +++ /dev/null @@ -1,18 +0,0 @@ -package repositories - -import ( - "context" - - "time" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" -) - -type TimeEntryRepository interface { - Get(ctx context.Context, id ulid.ULID) (*entities.TimeEntry, error) - Create(ctx context.Context, timeEntry *entities.TimeEntryCreate) error - Update(ctx context.Context, timeEntry *entities.TimeEntryUpdate) error - Delete(ctx context.Context, id ulid.ULID) error - GetByRange(ctx context.Context, userID ulid.ULID, from time.Time, to time.Time) ([]*entities.TimeEntry, error) -} diff --git a/backend/internal/domain/repositories/user_repository.go b/backend/internal/domain/repositories/user_repository.go deleted file mode 100644 index 851487d..0000000 --- a/backend/internal/domain/repositories/user_repository.go +++ /dev/null @@ -1,16 +0,0 @@ -package repositories - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" -) - -type UserRepository interface { - Get(ctx context.Context, id ulid.ULID) (*entities.User, error) - Create(ctx context.Context, user *entities.UserCreate) error - Update(ctx context.Context, user *entities.UserUpdate) error - Delete(ctx context.Context, id ulid.ULID) error - GetByUsername(ctx context.Context, email string) (*entities.User, error) -} diff --git a/backend/internal/interfaces/http/dto/activity_dto.go b/backend/internal/dtos/activity_dto.go similarity index 100% rename from backend/internal/interfaces/http/dto/activity_dto.go rename to backend/internal/dtos/activity_dto.go diff --git a/backend/internal/interfaces/http/dto/auth_dto.go b/backend/internal/dtos/auth_dto.go similarity index 100% rename from backend/internal/interfaces/http/dto/auth_dto.go rename to backend/internal/dtos/auth_dto.go diff --git a/backend/internal/interfaces/http/dto/company_dto.go b/backend/internal/dtos/company_dto.go similarity index 100% rename from backend/internal/interfaces/http/dto/company_dto.go rename to backend/internal/dtos/company_dto.go diff --git a/backend/internal/interfaces/http/dto/customer_dto.go b/backend/internal/dtos/customer_dto.go similarity index 100% rename from backend/internal/interfaces/http/dto/customer_dto.go rename to backend/internal/dtos/customer_dto.go diff --git a/backend/internal/interfaces/http/dto/project_dto.go b/backend/internal/dtos/project_dto.go similarity index 100% rename from backend/internal/interfaces/http/dto/project_dto.go rename to backend/internal/dtos/project_dto.go diff --git a/backend/internal/interfaces/http/dto/timeentry_dto.go b/backend/internal/dtos/timeentry_dto.go similarity index 100% rename from backend/internal/interfaces/http/dto/timeentry_dto.go rename to backend/internal/dtos/timeentry_dto.go diff --git a/backend/internal/interfaces/http/dto/user_dto.go b/backend/internal/dtos/user_dto.go similarity index 100% rename from backend/internal/interfaces/http/dto/user_dto.go rename to backend/internal/dtos/user_dto.go diff --git a/backend/internal/infrastructure/persistence/db/database.go b/backend/internal/infrastructure/persistence/db/database.go deleted file mode 100644 index 2b0b178..0000000 --- a/backend/internal/infrastructure/persistence/db/database.go +++ /dev/null @@ -1,99 +0,0 @@ -package db - -import ( - "database/sql" - "fmt" - - "github.com/timetracker/backend/internal/domain/persistence" - "github.com/timetracker/backend/internal/infrastructure/persistence/db/ds" - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -// DatabaseConfig enthält die Konfigurationsinformationen für die Datenbankverbindung -type DatabaseConfig struct { - Host string - Port int - User string - Password string - DBName string - SSLMode string -} - -// DatasourceContainer enthält alle Datasource-Instanzen -type DatasourceContainer struct { - ActivityDatasource persistence.ActivityDatasource - CompanyDatasource persistence.CompanyDatasource - CustomerDatasource persistence.CustomerDatasource - ProjectDatasource persistence.ProjectDatasource - TimeEntryDatasource persistence.TimeEntryDatasource - UserDatasource persistence.UserDatasource -} - -// NewDatasourceContainer erstellt und initialisiert alle Datasource-Instanzen -func NewDatasourceContainer(config DatabaseConfig) (*DatasourceContainer, error) { - // Erstelle DSN (Data Source Name) für die Datenbankverbindung - dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - config.Host, config.Port, config.User, config.Password, config.DBName, config.SSLMode) - - // Erstelle SQL-Datenbankverbindung - sqlDB, err := sql.Open("pgx", dsn) - if err != nil { - return nil, fmt.Errorf("fehler beim Öffnen der SQL-Verbindung: %w", err) - } - - // Konfiguriere Verbindungspool - sqlDB.SetMaxIdleConns(10) - sqlDB.SetMaxOpenConns(100) - - // Initialisiere GORM mit der SQL-Verbindung - gormDB, err := gorm.Open(postgres.New(postgres.Config{ - Conn: sqlDB, - }), &gorm.Config{}) - if err != nil { - return nil, fmt.Errorf("fehler beim Initialisieren von GORM: %w", err) - } - - // Erstelle alle Datasource-Instanzen - return &DatasourceContainer{ - ActivityDatasource: ds.NewActivityDatasource(gormDB), - CompanyDatasource: ds.NewCompanyDatasource(gormDB), - CustomerDatasource: ds.NewCustomerDatasource(gormDB), - ProjectDatasource: ds.NewProjectDatasource(gormDB), - TimeEntryDatasource: ds.NewTimeEntryDatasource(gormDB), - UserDatasource: ds.NewUserDatasource(gormDB), - }, nil -} - -// Close schließt die Datenbankverbindung -func (r *DatasourceContainer) Close() error { - db, err := r.getGormDB() - if err != nil { - return err - } - - sqlDB, err := db.DB() - if err != nil { - return err - } - - return sqlDB.Close() -} - -// Helper-Methode, um die GORM-DB aus einem der Repositories zu extrahieren -func (r *DatasourceContainer) getGormDB() (*gorm.DB, error) { - // Wir nehmen an, dass alle Repositories das gleiche DB-Handle verwenden - // Deshalb können wir einfach eines der Repositories nehmen - // Dies funktioniert nur, wenn wir Zugriff auf die interne DB haben oder eine Methode hinzufügen - // Hier müsste angepasst werden, wie Sie Zugriff auf die GORM-DB bekommen - - // Beispiel (müsste angepasst werden): - // activityDS, ok := r.ActivityDatasource.(*ds.ActivityDatasource) - // if !ok { - // return nil, fmt.Errorf("Konnte GORM-DB nicht aus ActivityDatasource extrahieren") - // } - // return activityDS.GetDB(), nil - - // Placeholder für die tatsächliche Implementierung: - return nil, fmt.Errorf("getGormDB() muss implementiert werden") -} diff --git a/backend/internal/infrastructure/persistence/db/dbo/activity_dbo.go b/backend/internal/infrastructure/persistence/db/dbo/activity_dbo.go deleted file mode 100644 index 9337a0a..0000000 --- a/backend/internal/infrastructure/persistence/db/dbo/activity_dbo.go +++ /dev/null @@ -1,18 +0,0 @@ -package dbo - -import ( - "time" - - "github.com/oklog/ulid/v2" - "gorm.io/gorm" -) - -type ActivityDBO struct { - gorm.Model - ID ulid.ULID `gorm:"primaryKey;type:uuid"` - CreatedAt time.Time `gorm:"not null"` - UpdatedAt time.Time `gorm:"not null"` - LastEditorID ulid.ULID - Name string `gorm:"type:varchar(255);not null"` - BillingRate float64 `gorm:"type:decimal(10,2)"` -} diff --git a/backend/internal/infrastructure/persistence/db/dbo/company_dbo.go b/backend/internal/infrastructure/persistence/db/dbo/company_dbo.go deleted file mode 100644 index 792853b..0000000 --- a/backend/internal/infrastructure/persistence/db/dbo/company_dbo.go +++ /dev/null @@ -1,17 +0,0 @@ -package dbo - -import ( - "time" - - "github.com/oklog/ulid/v2" - "gorm.io/gorm" -) - -type CompanyDBO struct { - gorm.Model - ID ulid.ULID `gorm:"primaryKey;type:uuid"` - CreatedAt time.Time `gorm:"not null"` - UpdatedAt time.Time `gorm:"not null"` - LastEditorID ulid.ULID - Name string `gorm:"type:varchar(255);not null"` -} diff --git a/backend/internal/infrastructure/persistence/db/dbo/customer_dbo.go b/backend/internal/infrastructure/persistence/db/dbo/customer_dbo.go deleted file mode 100644 index 1b68d67..0000000 --- a/backend/internal/infrastructure/persistence/db/dbo/customer_dbo.go +++ /dev/null @@ -1,18 +0,0 @@ -package dbo - -import ( - "time" - - "github.com/oklog/ulid/v2" - "gorm.io/gorm" -) - -type CustomerDBO struct { - gorm.Model - ID ulid.ULID `gorm:"primaryKey;type:uuid"` - CreatedAt time.Time `gorm:"not null"` - UpdatedAt time.Time `gorm:"not null"` - LastEditorID ulid.ULID - Name string `gorm:"type:varchar(255);not null"` - CompanyID int -} diff --git a/backend/internal/infrastructure/persistence/db/dbo/project_dbo.go b/backend/internal/infrastructure/persistence/db/dbo/project_dbo.go deleted file mode 100644 index e5e6619..0000000 --- a/backend/internal/infrastructure/persistence/db/dbo/project_dbo.go +++ /dev/null @@ -1,18 +0,0 @@ -package dbo - -import ( - "time" - - "github.com/oklog/ulid/v2" - "gorm.io/gorm" -) - -type ProjectDBO struct { - gorm.Model - ID ulid.ULID `gorm:"primaryKey;type:uuid"` - CreatedAt time.Time `gorm:"not null"` - UpdatedAt time.Time `gorm:"not null"` - LastEditorID ulid.ULID - Name string `gorm:"type:varchar(255);not null"` - CustomerID int -} diff --git a/backend/internal/infrastructure/persistence/db/dbo/timeentry_dbo.go b/backend/internal/infrastructure/persistence/db/dbo/timeentry_dbo.go deleted file mode 100644 index 5c1bbd0..0000000 --- a/backend/internal/infrastructure/persistence/db/dbo/timeentry_dbo.go +++ /dev/null @@ -1,23 +0,0 @@ -package dbo - -import ( - "time" - - "github.com/oklog/ulid/v2" - "gorm.io/gorm" -) - -type TimeEntryDBO struct { - gorm.Model - ID ulid.ULID `gorm:"primaryKey;type:uuid"` - CreatedAt time.Time `gorm:"not null"` - UpdatedAt time.Time `gorm:"not null"` - LastEditorID ulid.ULID - UserID int - ProjectID int - ActivityID int - Start time.Time - End time.Time - Description string - Billable int // Percentage (0-100) -} diff --git a/backend/internal/infrastructure/persistence/db/dbo/user_dbo.go b/backend/internal/infrastructure/persistence/db/dbo/user_dbo.go deleted file mode 100644 index cbabc48..0000000 --- a/backend/internal/infrastructure/persistence/db/dbo/user_dbo.go +++ /dev/null @@ -1,22 +0,0 @@ -package dbo - -import ( - "time" - - "github.com/oklog/ulid/v2" - "gorm.io/gorm" -) - -type UserDBO struct { - gorm.Model - ID ulid.ULID `gorm:"primaryKey;type:uuid"` - CreatedAt time.Time `gorm:"not null"` - UpdatedAt time.Time `gorm:"not null"` - LastEditorID ulid.ULID - Email string - PasswordHash string - Salt string - Role string - CompanyID int - HourlyRate float64 -} diff --git a/backend/internal/infrastructure/persistence/db/ds/activity_datasource.go b/backend/internal/infrastructure/persistence/db/ds/activity_datasource.go deleted file mode 100644 index 55e4ea6..0000000 --- a/backend/internal/infrastructure/persistence/db/ds/activity_datasource.go +++ /dev/null @@ -1,66 +0,0 @@ -package ds - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" - "github.com/timetracker/backend/internal/domain/persistence" - "github.com/timetracker/backend/internal/infrastructure/persistence/db/dbo" - "gorm.io/gorm" -) - -type ActivityDatasource struct { - db *gorm.DB -} - -func NewActivityDatasource(db *gorm.DB) persistence.ActivityDatasource { - return &ActivityDatasource{db: db} -} - -func (r *ActivityDatasource) Get(ctx context.Context, id ulid.ULID) (*entities.Activity, error) { - var activityDBO dbo.ActivityDBO - if err := r.db.WithContext(ctx).First(&activityDBO, "id = ?", id).Error; err != nil { - return nil, err - } - - activity := &entities.Activity{ - EntityBase: entities.EntityBase{ - ID: activityDBO.ID, - CreatedAt: activityDBO.CreatedAt, - UpdatedAt: activityDBO.UpdatedAt, - }, - Name: activityDBO.Name, - BillingRate: activityDBO.BillingRate, - } - - return activity, nil -} - -func (r *ActivityDatasource) Create(ctx context.Context, activity *entities.Activity) error { - activityDBO := dbo.ActivityDBO{ - ID: activity.ID, - CreatedAt: activity.CreatedAt, - UpdatedAt: activity.UpdatedAt, - Name: activity.Name, - BillingRate: activity.BillingRate, - } - - return r.db.WithContext(ctx).Create(&activityDBO).Error -} - -func (r *ActivityDatasource) Update(ctx context.Context, activity *entities.Activity) error { - activityDBO := dbo.ActivityDBO{ - ID: activity.ID, - CreatedAt: activity.CreatedAt, - UpdatedAt: activity.UpdatedAt, - Name: activity.Name, - BillingRate: activity.BillingRate, - } - - return r.db.WithContext(ctx).Save(&activityDBO).Error -} - -func (r *ActivityDatasource) Delete(ctx context.Context, id ulid.ULID) error { - return r.db.WithContext(ctx).Delete(&dbo.ActivityDBO{}, "id = ?", id).Error -} diff --git a/backend/internal/infrastructure/persistence/db/ds/company_datasource.go b/backend/internal/infrastructure/persistence/db/ds/company_datasource.go deleted file mode 100644 index 44d170a..0000000 --- a/backend/internal/infrastructure/persistence/db/ds/company_datasource.go +++ /dev/null @@ -1,63 +0,0 @@ -package ds - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" - "github.com/timetracker/backend/internal/domain/persistence" - "github.com/timetracker/backend/internal/infrastructure/persistence/db/dbo" - "gorm.io/gorm" -) - -type CompanyyDatasource struct { - db *gorm.DB -} - -func NewCompanyDatasource(db *gorm.DB) persistence.CompanyDatasource { - return &CompanyyDatasource{db: db} -} - -func (r *CompanyyDatasource) Get(ctx context.Context, id ulid.ULID) (*entities.Company, error) { - var companyDBO dbo.CompanyDBO - if err := r.db.WithContext(ctx).First(&companyDBO, "id = ?", id).Error; err != nil { - return nil, err - } - - company := &entities.Company{ - EntityBase: entities.EntityBase{ - ID: companyDBO.ID, - CreatedAt: companyDBO.CreatedAt, - UpdatedAt: companyDBO.UpdatedAt, - }, - Name: companyDBO.Name, - } - - return company, nil -} - -func (r *CompanyyDatasource) Create(ctx context.Context, company *entities.Company) error { - companyDBO := dbo.CompanyDBO{ - ID: company.ID, - CreatedAt: company.CreatedAt, - UpdatedAt: company.UpdatedAt, - Name: company.Name, - } - - return r.db.WithContext(ctx).Create(&companyDBO).Error -} - -func (r *CompanyyDatasource) Update(ctx context.Context, company *entities.Company) error { - companyDBO := dbo.CompanyDBO{ - ID: company.ID, - CreatedAt: company.CreatedAt, - UpdatedAt: company.UpdatedAt, - Name: company.Name, - } - - return r.db.WithContext(ctx).Save(&companyDBO).Error -} - -func (r *CompanyyDatasource) Delete(ctx context.Context, id ulid.ULID) error { - return r.db.WithContext(ctx).Delete(&dbo.CompanyDBO{}, "id = ?", id).Error -} diff --git a/backend/internal/infrastructure/persistence/db/ds/customer_datasource.go b/backend/internal/infrastructure/persistence/db/ds/customer_datasource.go deleted file mode 100644 index 57ffa0c..0000000 --- a/backend/internal/infrastructure/persistence/db/ds/customer_datasource.go +++ /dev/null @@ -1,66 +0,0 @@ -package ds - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" - "github.com/timetracker/backend/internal/domain/persistence" - "github.com/timetracker/backend/internal/infrastructure/persistence/db/dbo" - "gorm.io/gorm" -) - -type CustomerDatasource struct { - db *gorm.DB -} - -func NewCustomerDatasource(db *gorm.DB) persistence.CustomerDatasource { - return &CustomerDatasource{db: db} -} - -func (r *CustomerDatasource) Get(ctx context.Context, id ulid.ULID) (*entities.Customer, error) { - var customerDBO dbo.CustomerDBO - if err := r.db.WithContext(ctx).First(&customerDBO, "id = ?", id).Error; err != nil { - return nil, err - } - - customer := &entities.Customer{ - EntityBase: entities.EntityBase{ - ID: customerDBO.ID, - CreatedAt: customerDBO.CreatedAt, - UpdatedAt: customerDBO.UpdatedAt, - }, - Name: customerDBO.Name, - CompanyID: customerDBO.CompanyID, - } - - return customer, nil -} - -func (r *CustomerDatasource) Create(ctx context.Context, customer *entities.Customer) error { - customerDBO := dbo.CustomerDBO{ - ID: customer.ID, - CreatedAt: customer.CreatedAt, - UpdatedAt: customer.UpdatedAt, - Name: customer.Name, - CompanyID: customer.CompanyID, - } - - return r.db.WithContext(ctx).Create(&customerDBO).Error -} - -func (r *CustomerDatasource) Update(ctx context.Context, customer *entities.Customer) error { - customerDBO := dbo.CustomerDBO{ - ID: customer.ID, - CreatedAt: customer.CreatedAt, - UpdatedAt: customer.UpdatedAt, - Name: customer.Name, - CompanyID: customer.CompanyID, - } - - return r.db.WithContext(ctx).Save(&customerDBO).Error -} - -func (r *CustomerDatasource) Delete(ctx context.Context, id ulid.ULID) error { - return r.db.WithContext(ctx).Delete(&dbo.CustomerDBO{}, "id = ?", id).Error -} diff --git a/backend/internal/infrastructure/persistence/db/ds/project_datasource.go b/backend/internal/infrastructure/persistence/db/ds/project_datasource.go deleted file mode 100644 index 1943f34..0000000 --- a/backend/internal/infrastructure/persistence/db/ds/project_datasource.go +++ /dev/null @@ -1,66 +0,0 @@ -package ds - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" - "github.com/timetracker/backend/internal/domain/persistence" - "github.com/timetracker/backend/internal/infrastructure/persistence/db/dbo" - "gorm.io/gorm" -) - -type ProjectDatasource struct { - db *gorm.DB -} - -func NewProjectDatasource(db *gorm.DB) persistence.ProjectDatasource { - return &ProjectDatasource{db: db} -} - -func (r *ProjectDatasource) Get(ctx context.Context, id ulid.ULID) (*entities.Project, error) { - var projectDBO dbo.ProjectDBO - if err := r.db.WithContext(ctx).First(&projectDBO, "id = ?", id).Error; err != nil { - return nil, err - } - - project := &entities.Project{ - EntityBase: entities.EntityBase{ - ID: projectDBO.ID, - CreatedAt: projectDBO.CreatedAt, - UpdatedAt: projectDBO.UpdatedAt, - }, - Name: projectDBO.Name, - CustomerID: projectDBO.CustomerID, - } - - return project, nil -} - -func (r *ProjectDatasource) Create(ctx context.Context, project *entities.Project) error { - projectDBO := dbo.ProjectDBO{ - ID: project.ID, - CreatedAt: project.CreatedAt, - UpdatedAt: project.UpdatedAt, - Name: project.Name, - CustomerID: project.CustomerID, - } - - return r.db.WithContext(ctx).Create(&projectDBO).Error -} - -func (r *ProjectDatasource) Update(ctx context.Context, project *entities.Project) error { - projectDBO := dbo.ProjectDBO{ - ID: project.ID, - CreatedAt: project.CreatedAt, - UpdatedAt: project.UpdatedAt, - Name: project.Name, - CustomerID: project.CustomerID, - } - - return r.db.WithContext(ctx).Save(&projectDBO).Error -} - -func (r *ProjectDatasource) Delete(ctx context.Context, id ulid.ULID) error { - return r.db.WithContext(ctx).Delete(&dbo.ProjectDBO{}, "id = ?", id).Error -} diff --git a/backend/internal/infrastructure/persistence/db/ds/timeentry_datasource.go b/backend/internal/infrastructure/persistence/db/ds/timeentry_datasource.go deleted file mode 100644 index 5223c91..0000000 --- a/backend/internal/infrastructure/persistence/db/ds/timeentry_datasource.go +++ /dev/null @@ -1,112 +0,0 @@ -package ds - -import ( - "context" - "time" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" - "github.com/timetracker/backend/internal/domain/persistence" - "github.com/timetracker/backend/internal/infrastructure/persistence/db/dbo" - "gorm.io/gorm" -) - -type TimeEntryDatasource struct { - db *gorm.DB -} - -func NewTimeEntryDatasource(db *gorm.DB) persistence.TimeEntryDatasource { - return &TimeEntryDatasource{db: db} -} - -func (r *TimeEntryDatasource) Get(ctx context.Context, id ulid.ULID) (*entities.TimeEntry, error) { - var timeEntryDBO dbo.TimeEntryDBO - if err := r.db.WithContext(ctx).First(&timeEntryDBO, "id = ?", id).Error; err != nil { - return nil, err - } - - timeEntry := &entities.TimeEntry{ - EntityBase: entities.EntityBase{ - ID: timeEntryDBO.ID, - CreatedAt: timeEntryDBO.CreatedAt, - UpdatedAt: timeEntryDBO.UpdatedAt, - }, - UserID: timeEntryDBO.UserID, - ProjectID: timeEntryDBO.ProjectID, - ActivityID: timeEntryDBO.ActivityID, - Start: timeEntryDBO.Start, - End: timeEntryDBO.End, - Description: timeEntryDBO.Description, - Billable: timeEntryDBO.Billable, - } - - return timeEntry, nil -} - -func (r *TimeEntryDatasource) Create(ctx context.Context, timeEntry *entities.TimeEntry) error { - timeEntryDBO := dbo.TimeEntryDBO{ - ID: timeEntry.ID, - CreatedAt: timeEntry.CreatedAt, - UpdatedAt: timeEntry.UpdatedAt, - UserID: timeEntry.UserID, - ProjectID: timeEntry.ProjectID, - ActivityID: timeEntry.ActivityID, - Start: timeEntry.Start, - End: timeEntry.End, - Description: timeEntry.Description, - Billable: timeEntry.Billable, - } - - return r.db.WithContext(ctx).Create(&timeEntryDBO).Error -} - -func (r *TimeEntryDatasource) Update(ctx context.Context, timeEntry *entities.TimeEntry) error { - var existingEntry dbo.TimeEntryDBO - if err := r.db.WithContext(ctx).First(&existingEntry, "id = ?", timeEntry.ID).Error; err != nil { - return entities.ErrTimeEntryNotFound - } - - updateData := map[string]any{ - "user_id": timeEntry.UserID, - "project_id": timeEntry.ProjectID, - "activity_id": timeEntry.ActivityID, - "start": timeEntry.Start, - "end": timeEntry.End, - "description": timeEntry.Description, - "billable": timeEntry.Billable, - "updated_at": gorm.Expr("NOW()"), // Optional: Automatisches Update-Datum - } - - return r.db.WithContext(ctx).Model(&dbo.TimeEntryDBO{}).Where("id = ?", timeEntry.ID).Updates(updateData).Error -} - -func (r *TimeEntryDatasource) Delete(ctx context.Context, id ulid.ULID) error { - return r.db.WithContext(ctx).Delete(&dbo.TimeEntryDBO{}, "id = ?", id).Error -} - -func (r *TimeEntryDatasource) GetByRange(ctx context.Context, userID ulid.ULID, from time.Time, to time.Time) ([]*entities.TimeEntry, error) { - var timeEntryDBOs []*dbo.TimeEntryDBO - if err := r.db.WithContext(ctx).Where("user_id = ? AND start_time >= ? AND end_time <= ?", userID, from, to).Find(&timeEntryDBOs).Error; err != nil { - return nil, err - } - - timeEntries := make([]*entities.TimeEntry, len(timeEntryDBOs)) - for i, timeEntryDBO := range timeEntryDBOs { - timeEntries[i] = &entities.TimeEntry{ - EntityBase: entities.EntityBase{ - ID: timeEntryDBO.ID, - CreatedAt: timeEntryDBO.CreatedAt, - UpdatedAt: timeEntryDBO.UpdatedAt, - }, - UserID: timeEntryDBO.UserID, - ProjectID: timeEntryDBO.ProjectID, - ActivityID: timeEntryDBO.ActivityID, - Start: timeEntryDBO.Start, - End: timeEntryDBO.End, - Description: timeEntryDBO.Description, - Billable: timeEntryDBO.Billable, - } - } - - return timeEntries, nil -} diff --git a/backend/internal/infrastructure/persistence/db/ds/user_datasource.go b/backend/internal/infrastructure/persistence/db/ds/user_datasource.go deleted file mode 100644 index 1b4f859..0000000 --- a/backend/internal/infrastructure/persistence/db/ds/user_datasource.go +++ /dev/null @@ -1,111 +0,0 @@ -package ds - -import ( - "context" - - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/domain/entities" - "github.com/timetracker/backend/internal/domain/persistence" - "github.com/timetracker/backend/internal/infrastructure/persistence/db/dbo" - "gorm.io/gorm" -) - -type UserDatasource struct { - db *gorm.DB -} - -func NewUserDatasource(db *gorm.DB) persistence.UserDatasource { - return &UserDatasource{db: db} -} - -func (r *UserDatasource) Get(ctx context.Context, id ulid.ULID) (*entities.User, error) { - var userDBO dbo.UserDBO - if err := r.db.WithContext(ctx).First(&userDBO, "id = ?", id).Error; err != nil { - return nil, err - } - - user := &entities.User{ - EntityBase: entities.EntityBase{ - ID: userDBO.ID, - CreatedAt: userDBO.CreatedAt, - UpdatedAt: userDBO.UpdatedAt, - }, - Email: userDBO.Email, - Salt: userDBO.Salt, - Role: userDBO.Role, - CompanyID: userDBO.CompanyID, - HourlyRate: userDBO.HourlyRate, - } - - return user, nil -} - -func (r *UserDatasource) Create(ctx context.Context, user *entities.User, passwordHash string, salt string) error { - - old := r.db.WithContext(ctx).First(&dbo.UserDBO{}, "email = ?", user.Email) - if old.Error == nil { - return entities.ErrUserAlreadyExists - } - - userDBO := dbo.UserDBO{ - ID: user.ID, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - Email: user.Email, - PasswordHash: passwordHash, - Salt: salt, - Role: user.Role, - CompanyID: user.CompanyID, - HourlyRate: user.HourlyRate, - } - - return r.db.WithContext(ctx).Create(&userDBO).Error -} - -func (r *UserDatasource) Update(ctx context.Context, user *entities.User, passwordHash *string) error { - var existingUser dbo.UserDBO - if err := r.db.WithContext(ctx).First(&existingUser, "id = ?", user.ID).Error; err != nil { - return entities.ErrUserNotFound - } - - // Nur relevante Felder aktualisieren - updateData := map[string]interface{}{ - "email": user.Email, - "role": user.Role, - "company_id": user.CompanyID, - "hourly_rate": user.HourlyRate, - "updated_at": gorm.Expr("NOW()"), // Optional: Automatisch das Update-Datum setzen - } - - if passwordHash != nil { - updateData["password_hash"] = *passwordHash - } - - return r.db.WithContext(ctx).Model(&dbo.UserDBO{}).Where("id = ?", user.ID).Updates(updateData).Error -} - -func (r *UserDatasource) Delete(ctx context.Context, id ulid.ULID) error { - return r.db.WithContext(ctx).Delete(&dbo.UserDBO{}, "id = ?", id).Error -} - -func (r *UserDatasource) GetByEmail(ctx context.Context, email string) (*entities.User, error) { - var userDBO dbo.UserDBO - if err := r.db.WithContext(ctx).Where("email = ?", email).First(&userDBO).Error; err != nil { - return nil, err - } - - user := &entities.User{ - EntityBase: entities.EntityBase{ - ID: userDBO.ID, - CreatedAt: userDBO.CreatedAt, - UpdatedAt: userDBO.UpdatedAt, - }, - Email: userDBO.Email, - Salt: userDBO.Salt, - Role: userDBO.Role, - CompanyID: userDBO.CompanyID, - HourlyRate: userDBO.HourlyRate, - } - - return user, nil -} diff --git a/backend/internal/models/activity.go b/backend/internal/models/activity.go new file mode 100644 index 0000000..f72a968 --- /dev/null +++ b/backend/internal/models/activity.go @@ -0,0 +1,96 @@ +package models + +import ( + "context" + "errors" + + "github.com/oklog/ulid/v2" + "gorm.io/gorm" +) + +// Activity repräsentiert eine Aktivität im System +type Activity struct { + EntityBase + Name string `gorm:"column:name"` + BillingRate float64 `gorm:"column:billing_rate"` +} + +// TableName gibt den Tabellennamen für GORM an +func (Activity) TableName() string { + return "activities" +} + +// ActivityUpdate enthält die aktualisierbaren Felder einer Activity +type ActivityUpdate struct { + ID ulid.ULID `gorm:"-"` // Verwenden Sie "-" um anzuzeigen, dass dieses Feld ignoriert werden soll + Name *string `gorm:"column:name"` + BillingRate *float64 `gorm:"column:billing_rate"` +} + +// ActivityCreate enthält die Felder zum Erstellen einer neuen Activity +type ActivityCreate struct { + Name string `gorm:"column:name"` + BillingRate float64 `gorm:"column:billing_rate"` +} + +// GetActivityByID sucht eine Activity anhand ihrer ID +func GetActivityByID(ctx context.Context, id ulid.ULID) (*Activity, error) { + var activity Activity + result := GetEngine(ctx).Where("id = ?", id).First(&activity) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, result.Error + } + return &activity, nil +} + +// GetAllActivities gibt alle Activities zurück +func GetAllActivities(ctx context.Context) ([]Activity, error) { + var activities []Activity + result := GetEngine(ctx).Find(&activities) + if result.Error != nil { + return nil, result.Error + } + return activities, nil +} + +// CreateActivity erstellt eine neue Activity +func CreateActivity(ctx context.Context, create ActivityCreate) (*Activity, error) { + activity := Activity{ + Name: create.Name, + BillingRate: create.BillingRate, + } + + result := GetEngine(ctx).Create(&activity) + if result.Error != nil { + return nil, result.Error + } + return &activity, nil +} + +// UpdateActivity aktualisiert eine bestehende Activity +func UpdateActivity(ctx context.Context, update ActivityUpdate) (*Activity, error) { + activity, err := GetActivityByID(ctx, update.ID) + if err != nil { + return nil, err + } + if activity == nil { + return nil, errors.New("activity nicht gefunden") + } + + // Generische Update-Funktion verwenden + if err := UpdateModel(ctx, activity, update); err != nil { + return nil, err + } + + // Aktualisierte Daten aus der Datenbank laden + return GetActivityByID(ctx, update.ID) +} + +// DeleteActivity löscht eine Activity anhand ihrer ID +func DeleteActivity(ctx context.Context, id ulid.ULID) error { + result := GetEngine(ctx).Delete(&Activity{}, id) + return result.Error +} diff --git a/backend/internal/models/base.go b/backend/internal/models/base.go new file mode 100644 index 0000000..4c46168 --- /dev/null +++ b/backend/internal/models/base.go @@ -0,0 +1,26 @@ +package models + +import ( + "math/rand" + "time" + + "github.com/oklog/ulid/v2" + "gorm.io/gorm" +) + +type EntityBase struct { + ID ulid.ULID `gorm:"type:uuid;primaryKey"` + CreatedAt time.Time `gorm:"index"` + UpdatedAt time.Time `gorm:"index"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +// BeforeCreate wird von GORM vor dem Erstellen eines Datensatzes aufgerufen +func (eb *EntityBase) BeforeCreate(tx *gorm.DB) error { + if eb.ID.Compare(ulid.ULID{}) == 0 { // Wenn ID leer ist + // Generiere eine neue ULID + entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0) + eb.ID = ulid.MustNew(ulid.Timestamp(time.Now()), entropy) + } + return nil +} diff --git a/backend/internal/models/company.go b/backend/internal/models/company.go new file mode 100644 index 0000000..f40d3f1 --- /dev/null +++ b/backend/internal/models/company.go @@ -0,0 +1,101 @@ +package models + +import ( + "context" + "errors" + + "github.com/oklog/ulid/v2" + "gorm.io/gorm" +) + +// Company repräsentiert ein Unternehmen im System +type Company struct { + EntityBase + Name string `gorm:"column:name"` +} + +// TableName gibt den Tabellennamen für GORM an +func (Company) TableName() string { + return "companies" +} + +// CompanyCreate enthält die Felder zum Erstellen eines neuen Unternehmens +type CompanyCreate struct { + Name string +} + +// CompanyUpdate enthält die aktualisierbaren Felder eines Unternehmens +type CompanyUpdate struct { + ID ulid.ULID `gorm:"-"` // Ausschließen von Updates + Name *string `gorm:"column:name"` +} + +// GetCompanyByID sucht ein Unternehmen anhand seiner ID +func GetCompanyByID(ctx context.Context, id ulid.ULID) (*Company, error) { + var company Company + result := GetEngine(ctx).Where("id = ?", id).First(&company) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, result.Error + } + return &company, nil +} + +// GetAllCompanies gibt alle Unternehmen zurück +func GetAllCompanies(ctx context.Context) ([]Company, error) { + var companies []Company + result := GetEngine(ctx).Find(&companies) + if result.Error != nil { + return nil, result.Error + } + return companies, nil +} + +func GetCustomersByCompanyID(ctx context.Context, companyID int) ([]Customer, error) { + var customers []Customer + result := GetEngine(ctx).Where("company_id = ?", companyID).Find(&customers) + if result.Error != nil { + return nil, result.Error + } + return customers, nil +} + +// CreateCompany erstellt ein neues Unternehmen +func CreateCompany(ctx context.Context, create CompanyCreate) (*Company, error) { + company := Company{ + Name: create.Name, + } + + result := GetEngine(ctx).Create(&company) + if result.Error != nil { + return nil, result.Error + } + return &company, nil +} + +// UpdateCompany aktualisiert ein bestehendes Unternehmen +func UpdateCompany(ctx context.Context, update CompanyUpdate) (*Company, error) { + company, err := GetCompanyByID(ctx, update.ID) + if err != nil { + return nil, err + } + if company == nil { + return nil, errors.New("company nicht gefunden") + } + + // Generische Update-Funktion verwenden + if err := UpdateModel(ctx, company, update); err != nil { + return nil, err + } + + // Aktualisierte Daten aus der Datenbank laden + return GetCompanyByID(ctx, update.ID) +} + +// DeleteCompany löscht ein Unternehmen anhand seiner ID +func DeleteCompany(ctx context.Context, id ulid.ULID) error { + result := GetEngine(ctx).Delete(&Company{}, id) + return result.Error +} diff --git a/backend/internal/models/customer.go b/backend/internal/models/customer.go new file mode 100644 index 0000000..3e20175 --- /dev/null +++ b/backend/internal/models/customer.go @@ -0,0 +1,96 @@ +package models + +import ( + "context" + "errors" + + "github.com/oklog/ulid/v2" + "gorm.io/gorm" +) + +// Customer repräsentiert einen Kunden im System +type Customer struct { + EntityBase + Name string `gorm:"column:name"` + CompanyID int `gorm:"column:company_id"` +} + +// TableName gibt den Tabellennamen für GORM an +func (Customer) TableName() string { + return "customers" +} + +// CustomerCreate enthält die Felder zum Erstellen eines neuen Kunden +type CustomerCreate struct { + Name string + CompanyID int +} + +// CustomerUpdate enthält die aktualisierbaren Felder eines Kunden +type CustomerUpdate struct { + ID ulid.ULID `gorm:"-"` // Ausschließen von Updates + Name *string `gorm:"column:name"` + CompanyID *int `gorm:"column:company_id"` +} + +// GetCustomerByID sucht einen Kunden anhand seiner ID +func GetCustomerByID(ctx context.Context, id ulid.ULID) (*Customer, error) { + var customer Customer + result := GetEngine(ctx).Where("id = ?", id).First(&customer) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, result.Error + } + return &customer, nil +} + +// GetAllCustomers gibt alle Kunden zurück +func GetAllCustomers(ctx context.Context) ([]Customer, error) { + var customers []Customer + result := GetEngine(ctx).Find(&customers) + if result.Error != nil { + return nil, result.Error + } + return customers, nil +} + +// CreateCustomer erstellt einen neuen Kunden +func CreateCustomer(ctx context.Context, create CustomerCreate) (*Customer, error) { + customer := Customer{ + Name: create.Name, + CompanyID: create.CompanyID, + } + + result := GetEngine(ctx).Create(&customer) + if result.Error != nil { + return nil, result.Error + } + return &customer, nil +} + +// UpdateCustomer aktualisiert einen bestehenden Kunden +func UpdateCustomer(ctx context.Context, update CustomerUpdate) (*Customer, error) { + customer, err := GetCustomerByID(ctx, update.ID) + if err != nil { + return nil, err + } + if customer == nil { + return nil, errors.New("customer nicht gefunden") + } + + // Generische Update-Funktion verwenden + if err := UpdateModel(ctx, customer, update); err != nil { + return nil, err + } + + // Aktualisierte Daten aus der Datenbank laden + return GetCustomerByID(ctx, update.ID) +} + +// DeleteCustomer löscht einen Kunden anhand seiner ID +func DeleteCustomer(ctx context.Context, id ulid.ULID) error { + result := GetEngine(ctx).Delete(&Customer{}, id) + return result.Error +} diff --git a/backend/internal/models/db.go b/backend/internal/models/db.go new file mode 100644 index 0000000..a41bc83 --- /dev/null +++ b/backend/internal/models/db.go @@ -0,0 +1,108 @@ +package models + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + + "gorm.io/driver/postgres" // Für PostgreSQL + "gorm.io/gorm" +) + +// Globale Variable für die DB-Verbindung +var defaultDB *gorm.DB + +// DatabaseConfig enthält die Konfigurationsdaten für die Datenbankverbindung +type DatabaseConfig struct { + Host string + Port int + User string + Password string + DBName string + SSLMode string +} + +// InitDB initialisiert die Datenbankverbindung (einmalig beim Start) +// mit der übergebenen Konfiguration +func InitDB(config DatabaseConfig) error { + // DSN (Data Source Name) erstellen + dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + config.Host, config.Port, config.User, config.Password, config.DBName, config.SSLMode) + + // Datenbankverbindung herstellen + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + return fmt.Errorf("fehler beim Verbinden zur Datenbank: %w", err) + } + + defaultDB = db + return nil +} + +// GetEngine gibt die DB-Instanz zurück, ggf. mit context +func GetEngine(ctx context.Context) *gorm.DB { + // Falls in ctx eine spezielle Transaktion steckt, könnte man das hier prüfen + return defaultDB.WithContext(ctx) +} + +// UpdateModel aktualisiert ein Modell anhand der gesetzten Pointer-Felder +func UpdateModel(ctx context.Context, model any, updates any) error { + updateValue := reflect.ValueOf(updates) + + // Wenn updates ein Pointer ist, den Wert dahinter verwenden + if updateValue.Kind() == reflect.Ptr { + updateValue = updateValue.Elem() + } + + // Stelle sicher, dass updates eine Struktur ist + if updateValue.Kind() != reflect.Struct { + return errors.New("updates muss eine Struktur sein") + } + + updateType := updateValue.Type() + updateMap := make(map[string]any) + + // Durch alle Felder iterieren + for i := 0; i < updateValue.NumField(); i++ { + field := updateValue.Field(i) + fieldType := updateType.Field(i) + + // Überspringen von unexportierten Feldern + if !fieldType.IsExported() { + continue + } + + // Spezialfall: ID-Feld überspringen (nur für Updates verwenden) + if fieldType.Name == "ID" { + continue + } + + // Für Pointer-Typen prüfen, ob sie nicht nil sind + if field.Kind() == reflect.Ptr && !field.IsNil() { + // Feldname aus GORM-Tag extrahieren oder Standard-Feldnamen verwenden + fieldName := fieldType.Name + + if tag, ok := fieldType.Tag.Lookup("gorm"); ok { + // Tag-Optionen trennen + options := strings.Split(tag, ";") + for _, option := range options { + if strings.HasPrefix(option, "column:") { + fieldName = strings.TrimPrefix(option, "column:") + break + } + } + } + + // Den Wert hinter dem Pointer verwenden + updateMap[fieldName] = field.Elem().Interface() + } + } + + if len(updateMap) == 0 { + return nil // Nichts zu aktualisieren + } + + return GetEngine(ctx).Model(model).Updates(updateMap).Error +} diff --git a/backend/internal/domain/entities/errors.go b/backend/internal/models/errors.go similarity index 98% rename from backend/internal/domain/entities/errors.go rename to backend/internal/models/errors.go index eec17e8..a62a7c6 100644 --- a/backend/internal/domain/entities/errors.go +++ b/backend/internal/models/errors.go @@ -1,4 +1,4 @@ -package entities +package models import "errors" diff --git a/backend/internal/models/project.go b/backend/internal/models/project.go new file mode 100644 index 0000000..ecca730 --- /dev/null +++ b/backend/internal/models/project.go @@ -0,0 +1,230 @@ +package models + +import ( + "context" + "errors" + "fmt" + + "github.com/oklog/ulid/v2" + "gorm.io/gorm" +) + +// Project repräsentiert ein Projekt im System +type Project struct { + EntityBase + Name string `gorm:"column:name;not null"` + CustomerID ulid.ULID `gorm:"column:customer_id;type:uuid;not null"` + + // Beziehungen (für Eager Loading) + Customer *Customer `gorm:"foreignKey:CustomerID"` +} + +// TableName gibt den Tabellennamen für GORM an +func (Project) TableName() string { + return "projects" +} + +// ProjectCreate enthält die Felder zum Erstellen eines neuen Projekts +type ProjectCreate struct { + Name string + CustomerID ulid.ULID +} + +// ProjectUpdate enthält die aktualisierbaren Felder eines Projekts +type ProjectUpdate struct { + ID ulid.ULID `gorm:"-"` // Ausschließen von Updates + Name *string `gorm:"column:name"` + CustomerID *ulid.ULID `gorm:"column:customer_id"` +} + +// Validate prüft, ob die Create-Struktur gültige Daten enthält +func (pc *ProjectCreate) Validate() error { + if pc.Name == "" { + return errors.New("project name darf nicht leer sein") + } + // Prüfung auf gültige CustomerID + if pc.CustomerID.Compare(ulid.ULID{}) == 0 { + return errors.New("customerID darf nicht leer sein") + } + return nil +} + +// Validate prüft, ob die Update-Struktur gültige Daten enthält +func (pu *ProjectUpdate) Validate() error { + if pu.Name != nil && *pu.Name == "" { + return errors.New("project name darf nicht leer sein") + } + return nil +} + +// GetProjectByID sucht ein Projekt anhand seiner ID +func GetProjectByID(ctx context.Context, id ulid.ULID) (*Project, error) { + var project Project + result := GetEngine(ctx).Where("id = ?", id).First(&project) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, result.Error + } + return &project, nil +} + +// GetProjectWithCustomer lädt ein Projekt mit den zugehörigen Kundeninformationen +func GetProjectWithCustomer(ctx context.Context, id ulid.ULID) (*Project, error) { + var project Project + result := GetEngine(ctx).Preload("Customer").Where("id = ?", id).First(&project) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, result.Error + } + return &project, nil +} + +// GetAllProjects gibt alle Projekte zurück +func GetAllProjects(ctx context.Context) ([]Project, error) { + var projects []Project + result := GetEngine(ctx).Find(&projects) + if result.Error != nil { + return nil, result.Error + } + return projects, nil +} + +// GetAllProjectsWithCustomers gibt alle Projekte mit Kundeninformationen zurück +func GetAllProjectsWithCustomers(ctx context.Context) ([]Project, error) { + var projects []Project + result := GetEngine(ctx).Preload("Customer").Find(&projects) + if result.Error != nil { + return nil, result.Error + } + return projects, nil +} + +// GetProjectsByCustomerID gibt alle Projekte eines bestimmten Kunden zurück +func GetProjectsByCustomerID(ctx context.Context, customerID ulid.ULID) ([]Project, error) { + var projects []Project + result := GetEngine(ctx).Where("customer_id = ?", customerID).Find(&projects) + if result.Error != nil { + return nil, result.Error + } + return projects, nil +} + +// CreateProject erstellt ein neues Projekt mit Validierung +func CreateProject(ctx context.Context, create ProjectCreate) (*Project, error) { + // Validierung + if err := create.Validate(); err != nil { + return nil, fmt.Errorf("validierungsfehler: %w", err) + } + + // Prüfen, ob der Kunde existiert + customer, err := GetCustomerByID(ctx, create.CustomerID) + if err != nil { + return nil, fmt.Errorf("fehler beim Prüfen des Kunden: %w", err) + } + if customer == nil { + return nil, errors.New("der angegebene Kunde existiert nicht") + } + + project := Project{ + Name: create.Name, + CustomerID: create.CustomerID, + } + + result := GetEngine(ctx).Create(&project) + if result.Error != nil { + return nil, fmt.Errorf("fehler beim Erstellen des Projekts: %w", result.Error) + } + return &project, nil +} + +// UpdateProject aktualisiert ein bestehendes Projekt mit Validierung +func UpdateProject(ctx context.Context, update ProjectUpdate) (*Project, error) { + // Validierung + if err := update.Validate(); err != nil { + return nil, fmt.Errorf("validierungsfehler: %w", err) + } + + project, err := GetProjectByID(ctx, update.ID) + if err != nil { + return nil, err + } + if project == nil { + return nil, errors.New("project nicht gefunden") + } + + // Wenn CustomerID aktualisiert wird, prüfen ob der Kunde existiert + if update.CustomerID != nil { + customer, err := GetCustomerByID(ctx, *update.CustomerID) + if err != nil { + return nil, fmt.Errorf("fehler beim Prüfen des Kunden: %w", err) + } + if customer == nil { + return nil, errors.New("der angegebene Kunde existiert nicht") + } + } + + // Generische Update-Funktion verwenden + if err := UpdateModel(ctx, project, update); err != nil { + return nil, fmt.Errorf("fehler beim Aktualisieren des Projekts: %w", err) + } + + // Aktualisierte Daten aus der Datenbank laden + return GetProjectByID(ctx, update.ID) +} + +// DeleteProject löscht ein Projekt anhand seiner ID +func DeleteProject(ctx context.Context, id ulid.ULID) error { + // Hier könnte man prüfen, ob abhängige Entitäten existieren + result := GetEngine(ctx).Delete(&Project{}, id) + if result.Error != nil { + return fmt.Errorf("fehler beim Löschen des Projekts: %w", result.Error) + } + return nil +} + +// CreateProjectWithTransaction erstellt ein Projekt innerhalb einer Transaktion +func CreateProjectWithTransaction(ctx context.Context, create ProjectCreate) (*Project, error) { + // Validierung + if err := create.Validate(); err != nil { + return nil, fmt.Errorf("validierungsfehler: %w", err) + } + + var project *Project + + // Transaktion starten + err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error { + // Kundenprüfung innerhalb der Transaktion + var customer Customer + if err := tx.Where("id = ?", create.CustomerID).First(&customer).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("der angegebene Kunde existiert nicht") + } + return err + } + + // Projekt erstellen + newProject := Project{ + Name: create.Name, + CustomerID: create.CustomerID, + } + + if err := tx.Create(&newProject).Error; err != nil { + return err + } + + // Projekt für die Rückgabe speichern + project = &newProject + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("transaktionsfehler: %w", err) + } + + return project, nil +} diff --git a/backend/internal/models/timeentry.go b/backend/internal/models/timeentry.go new file mode 100644 index 0000000..14190d8 --- /dev/null +++ b/backend/internal/models/timeentry.go @@ -0,0 +1,360 @@ +package models + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/oklog/ulid/v2" + "gorm.io/gorm" +) + +// TimeEntry repräsentiert einen Zeiteintrag im System +type TimeEntry struct { + EntityBase + UserID ulid.ULID `gorm:"column:user_id;type:uuid;not null;index"` + ProjectID ulid.ULID `gorm:"column:project_id;type:uuid;not null;index"` + ActivityID ulid.ULID `gorm:"column:activity_id;type:uuid;not null;index"` + Start time.Time `gorm:"column:start;not null"` + End time.Time `gorm:"column:end;not null"` + Description string `gorm:"column:description"` + Billable int `gorm:"column:billable"` // Percentage (0-100) + + // Beziehungen für Eager Loading + User *User `gorm:"foreignKey:UserID"` + Project *Project `gorm:"foreignKey:ProjectID"` + Activity *Activity `gorm:"foreignKey:ActivityID"` +} + +// TableName gibt den Tabellennamen für GORM an +func (TimeEntry) TableName() string { + return "time_entries" +} + +// TimeEntryCreate enthält die Felder zum Erstellen eines neuen Zeiteintrags +type TimeEntryCreate struct { + UserID ulid.ULID + ProjectID ulid.ULID + ActivityID ulid.ULID + Start time.Time + End time.Time + Description string + Billable int // Percentage (0-100) +} + +// TimeEntryUpdate enthält die aktualisierbaren Felder eines Zeiteintrags +type TimeEntryUpdate struct { + ID ulid.ULID `gorm:"-"` // Ausschließen von Updates + UserID *ulid.ULID `gorm:"column:user_id"` + ProjectID *ulid.ULID `gorm:"column:project_id"` + ActivityID *ulid.ULID `gorm:"column:activity_id"` + Start *time.Time `gorm:"column:start"` + End *time.Time `gorm:"column:end"` + Description *string `gorm:"column:description"` + Billable *int `gorm:"column:billable"` +} + +// Validate prüft, ob die Create-Struktur gültige Daten enthält +func (tc *TimeEntryCreate) Validate() error { + // Prüfung auf leere IDs + if tc.UserID.Compare(ulid.ULID{}) == 0 { + return errors.New("userID darf nicht leer sein") + } + if tc.ProjectID.Compare(ulid.ULID{}) == 0 { + return errors.New("projectID darf nicht leer sein") + } + if tc.ActivityID.Compare(ulid.ULID{}) == 0 { + return errors.New("activityID darf nicht leer sein") + } + + // Zeitprüfungen + if tc.Start.IsZero() { + return errors.New("startzeit darf nicht leer sein") + } + if tc.End.IsZero() { + return errors.New("endzeit darf nicht leer sein") + } + if tc.End.Before(tc.Start) { + return errors.New("endzeit kann nicht vor der startzeit liegen") + } + + // Billable-Prozent Prüfung + if tc.Billable < 0 || tc.Billable > 100 { + return errors.New("billable muss zwischen 0 und 100 liegen") + } + + return nil +} + +// Validate prüft, ob die Update-Struktur gültige Daten enthält +func (tu *TimeEntryUpdate) Validate() error { + // Billable-Prozent Prüfung + if tu.Billable != nil && (*tu.Billable < 0 || *tu.Billable > 100) { + return errors.New("billable muss zwischen 0 und 100 liegen") + } + + // Zeitprüfungen + if tu.Start != nil && tu.End != nil && tu.End.Before(*tu.Start) { + return errors.New("endzeit kann nicht vor der startzeit liegen") + } + + return nil +} + +// GetTimeEntryByID sucht einen Zeiteintrag anhand seiner ID +func GetTimeEntryByID(ctx context.Context, id ulid.ULID) (*TimeEntry, error) { + var timeEntry TimeEntry + result := GetEngine(ctx).Where("id = ?", id).First(&timeEntry) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, result.Error + } + return &timeEntry, nil +} + +// GetTimeEntryWithRelations lädt einen Zeiteintrag mit allen zugehörigen Daten +func GetTimeEntryWithRelations(ctx context.Context, id ulid.ULID) (*TimeEntry, error) { + var timeEntry TimeEntry + result := GetEngine(ctx). + Preload("User"). + Preload("Project"). + Preload("Project.Customer"). // Verschachtelte Beziehung + Preload("Activity"). + Where("id = ?", id). + First(&timeEntry) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, result.Error + } + return &timeEntry, nil +} + +// GetAllTimeEntries gibt alle Zeiteinträge zurück +func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) { + var timeEntries []TimeEntry + result := GetEngine(ctx).Find(&timeEntries) + if result.Error != nil { + return nil, result.Error + } + return timeEntries, nil +} + +// GetTimeEntriesByUserID gibt alle Zeiteinträge eines Benutzers zurück +func GetTimeEntriesByUserID(ctx context.Context, userID ulid.ULID) ([]TimeEntry, error) { + var timeEntries []TimeEntry + result := GetEngine(ctx).Where("user_id = ?", userID).Find(&timeEntries) + if result.Error != nil { + return nil, result.Error + } + return timeEntries, nil +} + +// GetTimeEntriesByProjectID gibt alle Zeiteinträge eines Projekts zurück +func GetTimeEntriesByProjectID(ctx context.Context, projectID ulid.ULID) ([]TimeEntry, error) { + var timeEntries []TimeEntry + result := GetEngine(ctx).Where("project_id = ?", projectID).Find(&timeEntries) + if result.Error != nil { + return nil, result.Error + } + return timeEntries, nil +} + +// GetTimeEntriesByDateRange gibt alle Zeiteinträge in einem Zeitraum zurück +func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]TimeEntry, error) { + var timeEntries []TimeEntry + // Suche nach Überschneidungen im Zeitraum + result := GetEngine(ctx). + Where("(start BETWEEN ? AND ?) OR (end BETWEEN ? AND ?)", + start, end, start, end). + Find(&timeEntries) + + if result.Error != nil { + return nil, result.Error + } + return timeEntries, nil +} + +// SumBillableHoursByProject berechnet die abrechenbaren Stunden pro Projekt +func SumBillableHoursByProject(ctx context.Context, projectID ulid.ULID) (float64, error) { + type Result struct { + TotalHours float64 + } + + var result Result + + // SQL-Berechnung der gewichteten Stunden + err := GetEngine(ctx).Raw(` + SELECT SUM( + EXTRACT(EPOCH FROM (end - start)) / 3600 * (billable / 100.0) + ) as total_hours + FROM time_entries + WHERE project_id = ? + `, projectID).Scan(&result).Error + + if err != nil { + return 0, err + } + + return result.TotalHours, nil +} + +// CreateTimeEntry erstellt einen neuen Zeiteintrag mit Validierung +func CreateTimeEntry(ctx context.Context, create TimeEntryCreate) (*TimeEntry, error) { + // Validierung + if err := create.Validate(); err != nil { + return nil, fmt.Errorf("validierungsfehler: %w", err) + } + + // Starten einer Transaktion + var timeEntry *TimeEntry + + err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error { + // Verweise prüfen + if err := validateReferences(tx, create.UserID, create.ProjectID, create.ActivityID); err != nil { + return err + } + + // Zeiteintrag erstellen + newTimeEntry := TimeEntry{ + UserID: create.UserID, + ProjectID: create.ProjectID, + ActivityID: create.ActivityID, + Start: create.Start, + End: create.End, + Description: create.Description, + Billable: create.Billable, + } + + if err := tx.Create(&newTimeEntry).Error; err != nil { + return fmt.Errorf("fehler beim Erstellen des Zeiteintrags: %w", err) + } + + timeEntry = &newTimeEntry + return nil + }) + + if err != nil { + return nil, err + } + + return timeEntry, nil +} + +// validateReferences prüft, ob alle referenzierten Entitäten existieren +func validateReferences(tx *gorm.DB, userID, projectID, activityID ulid.ULID) error { + // Benutzer prüfen + var userCount int64 + if err := tx.Model(&User{}).Where("id = ?", userID).Count(&userCount).Error; err != nil { + return fmt.Errorf("fehler beim Prüfen des Benutzers: %w", err) + } + if userCount == 0 { + return errors.New("der angegebene Benutzer existiert nicht") + } + + // Projekt prüfen + var projectCount int64 + if err := tx.Model(&Project{}).Where("id = ?", projectID).Count(&projectCount).Error; err != nil { + return fmt.Errorf("fehler beim Prüfen des Projekts: %w", err) + } + if projectCount == 0 { + return errors.New("das angegebene Projekt existiert nicht") + } + + // Aktivität prüfen + var activityCount int64 + if err := tx.Model(&Activity{}).Where("id = ?", activityID).Count(&activityCount).Error; err != nil { + return fmt.Errorf("fehler beim Prüfen der Aktivität: %w", err) + } + if activityCount == 0 { + return errors.New("die angegebene Aktivität existiert nicht") + } + + return nil +} + +// UpdateTimeEntry aktualisiert einen bestehenden Zeiteintrag mit Validierung +func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, error) { + // Validierung + if err := update.Validate(); err != nil { + return nil, fmt.Errorf("validierungsfehler: %w", err) + } + + // Zeiteintrag suchen + timeEntry, err := GetTimeEntryByID(ctx, update.ID) + if err != nil { + return nil, err + } + if timeEntry == nil { + return nil, errors.New("zeiteintrag nicht gefunden") + } + + // Starten einer Transaktion für das Update + err = GetEngine(ctx).Transaction(func(tx *gorm.DB) error { + // Referenzen prüfen, falls sie aktualisiert werden + if update.UserID != nil || update.ProjectID != nil || update.ActivityID != nil { + // Aktuelle Werte verwenden, wenn nicht aktualisiert + userID := timeEntry.UserID + if update.UserID != nil { + userID = *update.UserID + } + + projectID := timeEntry.ProjectID + if update.ProjectID != nil { + projectID = *update.ProjectID + } + + activityID := timeEntry.ActivityID + if update.ActivityID != nil { + activityID = *update.ActivityID + } + + if err := validateReferences(tx, userID, projectID, activityID); err != nil { + return err + } + } + + // Zeitkonsistenz prüfen + start := timeEntry.Start + if update.Start != nil { + start = *update.Start + } + + end := timeEntry.End + if update.End != nil { + end = *update.End + } + + if end.Before(start) { + return errors.New("endzeit kann nicht vor der startzeit liegen") + } + + // Generisches Update verwenden + if err := UpdateModel(ctx, timeEntry, update); err != nil { + return fmt.Errorf("fehler beim Aktualisieren des Zeiteintrags: %w", err) + } + + return nil + }) + + if err != nil { + return nil, err + } + + // Aktualisierte Daten aus der Datenbank laden + return GetTimeEntryByID(ctx, update.ID) +} + +// DeleteTimeEntry löscht einen Zeiteintrag anhand seiner ID +func DeleteTimeEntry(ctx context.Context, id ulid.ULID) error { + result := GetEngine(ctx).Delete(&TimeEntry{}, id) + if result.Error != nil { + return fmt.Errorf("fehler beim Löschen des Zeiteintrags: %w", result.Error) + } + return nil +} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go new file mode 100644 index 0000000..af36b34 --- /dev/null +++ b/backend/internal/models/user.go @@ -0,0 +1,534 @@ +package models + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "regexp" + "strings" + + "slices" + + "github.com/oklog/ulid/v2" + "golang.org/x/crypto/argon2" + "gorm.io/gorm" +) + +// Argon2 Parameter +const ( + // Empfohlene Werte für Argon2id + ArgonTime = 1 + ArgonMemory = 64 * 1024 // 64MB + ArgonThreads = 4 + ArgonKeyLen = 32 + SaltLength = 16 +) + +// Rollen-Konstanten +const ( + RoleAdmin = "admin" + RoleUser = "user" + RoleViewer = "viewer" +) + +// User repräsentiert einen Benutzer im System +type User struct { + EntityBase + Email string `gorm:"column:email;unique;not null"` + Salt string `gorm:"column:salt;not null;type:varchar(64)"` // Basis64-codierter Salt + Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Basis64-codierter Hash + Role string `gorm:"column:role;not null;default:'user'"` + CompanyID ulid.ULID `gorm:"column:company_id;type:uuid;not null;index"` + HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"` + + // Beziehung für Eager Loading + Company *Company `gorm:"foreignKey:CompanyID"` +} + +// TableName gibt den Tabellennamen für GORM an +func (User) TableName() string { + return "users" +} + +// UserCreate enthält die Felder zum Erstellen eines neuen Benutzers +type UserCreate struct { + Email string + Password string + Role string + CompanyID ulid.ULID + HourlyRate float64 +} + +// UserUpdate enthält die aktualisierbaren Felder eines Benutzers +type UserUpdate struct { + ID ulid.ULID `gorm:"-"` // Ausschließen von Updates + Email *string `gorm:"column:email"` + Password *string `gorm:"-"` // Nicht direkt in DB speichern + Role *string `gorm:"column:role"` + CompanyID *ulid.ULID `gorm:"column:company_id"` + HourlyRate *float64 `gorm:"column:hourly_rate"` +} + +// PasswordData enthält die Daten für Passwort-Hash und Salt +type PasswordData struct { + Salt string + Hash string +} + +// GenerateSalt erzeugt einen kryptografisch sicheren Salt +func GenerateSalt() (string, error) { + salt := make([]byte, SaltLength) + _, err := rand.Read(salt) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(salt), nil +} + +// HashPassword erstellt einen sicheren Passwort-Hash mit Argon2id und einem zufälligen Salt +func HashPassword(password string) (PasswordData, error) { + // Erzeugen eines kryptografisch sicheren Salts + saltStr, err := GenerateSalt() + if err != nil { + return PasswordData{}, fmt.Errorf("fehler beim Generieren des Salt: %w", err) + } + + salt, err := base64.StdEncoding.DecodeString(saltStr) + if err != nil { + return PasswordData{}, fmt.Errorf("fehler beim Dekodieren des Salt: %w", err) + } + + // Hash mit Argon2id erstellen (moderne, sichere Hash-Funktion) + hash := argon2.IDKey([]byte(password), salt, ArgonTime, ArgonMemory, ArgonThreads, ArgonKeyLen) + hashStr := base64.StdEncoding.EncodeToString(hash) + + return PasswordData{ + Salt: saltStr, + Hash: hashStr, + }, nil +} + +// VerifyPassword prüft, ob ein Passwort mit dem Hash übereinstimmt +func VerifyPassword(password, saltStr, hashStr string) (bool, error) { + salt, err := base64.StdEncoding.DecodeString(saltStr) + if err != nil { + return false, fmt.Errorf("fehler beim Dekodieren des Salt: %w", err) + } + + hash, err := base64.StdEncoding.DecodeString(hashStr) + if err != nil { + return false, fmt.Errorf("fehler beim Dekodieren des Hash: %w", err) + } + + // Hash mit gleichem Salt berechnen + computedHash := argon2.IDKey([]byte(password), salt, ArgonTime, ArgonMemory, ArgonThreads, ArgonKeyLen) + + // Konstante Zeit-Vergleich, um Timing-Angriffe zu vermeiden + return hmacEqual(hash, computedHash), nil +} + +// hmacEqual führt einen konstante-Zeit Vergleich durch (verhindert Timing-Attacken) +func hmacEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + + var result byte + for i := 0; i < len(a); i++ { + result |= a[i] ^ b[i] + } + + return result == 0 +} + +// Validate prüft, ob die Create-Struktur gültige Daten enthält +func (uc *UserCreate) Validate() error { + if uc.Email == "" { + return errors.New("email darf nicht leer sein") + } + + // Email-Format prüfen + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) + if !emailRegex.MatchString(uc.Email) { + return errors.New("ungültiges email-format") + } + + if uc.Password == "" { + return errors.New("passwort darf nicht leer sein") + } + + // Passwort-Komplexität prüfen + if len(uc.Password) < 10 { + return errors.New("passwort muss mindestens 10 Zeichen lang sein") + } + + // Komplexere Passwortvalidierung + var ( + hasUpper = false + hasLower = false + hasNumber = false + hasSpecial = false + ) + + for _, char := range uc.Password { + switch { + case 'A' <= char && char <= 'Z': + hasUpper = true + case 'a' <= char && char <= 'z': + hasLower = true + case '0' <= char && char <= '9': + hasNumber = true + case char == '!' || char == '@' || char == '#' || char == '$' || + char == '%' || char == '^' || char == '&' || char == '*': + hasSpecial = true + } + } + + if !hasUpper || !hasLower || !hasNumber || !hasSpecial { + return errors.New("passwort muss Großbuchstaben, Kleinbuchstaben, Zahlen und Sonderzeichen enthalten") + } + + // Rolle prüfen + if uc.Role == "" { + uc.Role = RoleUser // Standardrolle setzen + } else { + validRoles := []string{RoleAdmin, RoleUser, RoleViewer} + isValid := slices.Contains(validRoles, uc.Role) + if !isValid { + return fmt.Errorf("ungültige rolle: %s, erlaubt sind: %s", + uc.Role, strings.Join(validRoles, ", ")) + } + } + + if uc.CompanyID.Compare(ulid.ULID{}) == 0 { + return errors.New("companyID darf nicht leer sein") + } + + if uc.HourlyRate < 0 { + return errors.New("stundensatz darf nicht negativ sein") + } + + return nil +} + +// Validate prüft, ob die Update-Struktur gültige Daten enthält +func (uu *UserUpdate) Validate() error { + if uu.Email != nil && *uu.Email == "" { + return errors.New("email darf nicht leer sein") + } + + // Email-Format prüfen + if uu.Email != nil { + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) + if !emailRegex.MatchString(*uu.Email) { + return errors.New("ungültiges email-format") + } + } + + if uu.Password != nil { + if *uu.Password == "" { + return errors.New("passwort darf nicht leer sein") + } + + // Passwort-Komplexität prüfen + if len(*uu.Password) < 10 { + return errors.New("passwort muss mindestens 10 Zeichen lang sein") + } + + // Komplexere Passwortvalidierung + var ( + hasUpper = false + hasLower = false + hasNumber = false + hasSpecial = false + ) + + for _, char := range *uu.Password { + switch { + case 'A' <= char && char <= 'Z': + hasUpper = true + case 'a' <= char && char <= 'z': + hasLower = true + case '0' <= char && char <= '9': + hasNumber = true + case char == '!' || char == '@' || char == '#' || char == '$' || + char == '%' || char == '^' || char == '&' || char == '*': + hasSpecial = true + } + } + + if !hasUpper || !hasLower || !hasNumber || !hasSpecial { + return errors.New("passwort muss Großbuchstaben, Kleinbuchstaben, Zahlen und Sonderzeichen enthalten") + } + } + + // Rolle prüfen + if uu.Role != nil { + validRoles := []string{RoleAdmin, RoleUser, RoleViewer} + isValid := false + for _, role := range validRoles { + if *uu.Role == role { + isValid = true + break + } + } + if !isValid { + return fmt.Errorf("ungültige rolle: %s, erlaubt sind: %s", + *uu.Role, strings.Join(validRoles, ", ")) + } + } + + if uu.HourlyRate != nil && *uu.HourlyRate < 0 { + return errors.New("stundensatz darf nicht negativ sein") + } + + return nil +} + +// GetUserByID sucht einen Benutzer anhand seiner ID +func GetUserByID(ctx context.Context, id ulid.ULID) (*User, error) { + var user User + result := GetEngine(ctx).Where("id = ?", id).First(&user) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, result.Error + } + return &user, nil +} + +// GetUserByEmail sucht einen Benutzer anhand seiner Email +func GetUserByEmail(ctx context.Context, email string) (*User, error) { + var user User + result := GetEngine(ctx).Where("email = ?", email).First(&user) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, result.Error + } + return &user, nil +} + +// GetUserWithCompany lädt einen Benutzer mit seiner Firma +func GetUserWithCompany(ctx context.Context, id ulid.ULID) (*User, error) { + var user User + result := GetEngine(ctx).Preload("Company").Where("id = ?", id).First(&user) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, result.Error + } + return &user, nil +} + +// GetAllUsers gibt alle Benutzer zurück +func GetAllUsers(ctx context.Context) ([]User, error) { + var users []User + result := GetEngine(ctx).Find(&users) + if result.Error != nil { + return nil, result.Error + } + return users, nil +} + +// GetUsersByCompanyID gibt alle Benutzer einer Firma zurück +func GetUsersByCompanyID(ctx context.Context, companyID ulid.ULID) ([]User, error) { + var users []User + result := GetEngine(ctx).Where("company_id = ?", companyID).Find(&users) + if result.Error != nil { + return nil, result.Error + } + return users, nil +} + +// CreateUser erstellt einen neuen Benutzer mit Validierung und sicherem Passwort-Hashing +func CreateUser(ctx context.Context, create UserCreate) (*User, error) { + // Validierung + if err := create.Validate(); err != nil { + return nil, fmt.Errorf("validierungsfehler: %w", err) + } + + // Starten einer Transaktion + var user *User + + err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error { + // Prüfen, ob Email bereits existiert + var count int64 + if err := tx.Model(&User{}).Where("email = ?", create.Email).Count(&count).Error; err != nil { + return fmt.Errorf("fehler beim Prüfen der Email: %w", err) + } + if count > 0 { + return errors.New("email wird bereits verwendet") + } + + // Prüfen, ob Company existiert + var companyCount int64 + if err := tx.Model(&Company{}).Where("id = ?", create.CompanyID).Count(&companyCount).Error; err != nil { + return fmt.Errorf("fehler beim Prüfen der Firma: %w", err) + } + if companyCount == 0 { + return errors.New("die angegebene Firma existiert nicht") + } + + // Passwort hashen mit einzigartigem Salt + pwData, err := HashPassword(create.Password) + if err != nil { + return fmt.Errorf("fehler beim Hashen des Passworts: %w", err) + } + + // Benutzer erstellen mit Salt und Hash getrennt gespeichert + newUser := User{ + Email: create.Email, + Salt: pwData.Salt, + Hash: pwData.Hash, + Role: create.Role, + CompanyID: create.CompanyID, + HourlyRate: create.HourlyRate, + } + + if err := tx.Create(&newUser).Error; err != nil { + return fmt.Errorf("fehler beim Erstellen des Benutzers: %w", err) + } + + user = &newUser + return nil + }) + + if err != nil { + return nil, err + } + + return user, nil +} + +// UpdateUser aktualisiert einen bestehenden Benutzer +func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) { + // Validierung + if err := update.Validate(); err != nil { + return nil, fmt.Errorf("validierungsfehler: %w", err) + } + + // Benutzer suchen + user, err := GetUserByID(ctx, update.ID) + if err != nil { + return nil, err + } + if user == nil { + return nil, errors.New("benutzer nicht gefunden") + } + + // Starten einer Transaktion für das Update + err = GetEngine(ctx).Transaction(func(tx *gorm.DB) error { + // Wenn Email aktualisiert wird, prüfen ob sie bereits verwendet wird + if update.Email != nil && *update.Email != user.Email { + var count int64 + if err := tx.Model(&User{}).Where("email = ? AND id != ?", *update.Email, update.ID).Count(&count).Error; err != nil { + return fmt.Errorf("fehler beim Prüfen der Email: %w", err) + } + if count > 0 { + return errors.New("email wird bereits verwendet") + } + } + + // Wenn CompanyID aktualisiert wird, prüfen ob sie existiert + if update.CompanyID != nil && update.CompanyID.Compare(user.CompanyID) != 0 { + var companyCount int64 + if err := tx.Model(&Company{}).Where("id = ?", *update.CompanyID).Count(&companyCount).Error; err != nil { + return fmt.Errorf("fehler beim Prüfen der Firma: %w", err) + } + if companyCount == 0 { + return errors.New("die angegebene Firma existiert nicht") + } + } + + // Wenn Passwort aktualisiert wird, neu hashen mit neuem Salt + if update.Password != nil { + pwData, err := HashPassword(*update.Password) + if err != nil { + return fmt.Errorf("fehler beim Hashen des Passworts: %w", err) + } + + // Salt und Hash direkt im Modell aktualisieren + if err := tx.Model(user).Updates(map[string]interface{}{ + "salt": pwData.Salt, + "hash": pwData.Hash, + }).Error; err != nil { + return fmt.Errorf("fehler beim Aktualisieren des Passworts: %w", err) + } + } + + // Map für generisches Update erstellen + updates := make(map[string]interface{}) + + // Nur nicht-Passwort-Felder dem Update hinzufügen + if update.Email != nil { + updates["email"] = *update.Email + } + if update.Role != nil { + updates["role"] = *update.Role + } + if update.CompanyID != nil { + updates["company_id"] = *update.CompanyID + } + if update.HourlyRate != nil { + updates["hourly_rate"] = *update.HourlyRate + } + + // Generisches Update nur ausführen, wenn es Änderungen gibt + if len(updates) > 0 { + if err := tx.Model(user).Updates(updates).Error; err != nil { + return fmt.Errorf("fehler beim Aktualisieren des Benutzers: %w", err) + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + // Aktualisierte Daten aus der Datenbank laden + return GetUserByID(ctx, update.ID) +} + +// DeleteUser löscht einen Benutzer anhand seiner ID +func DeleteUser(ctx context.Context, id ulid.ULID) error { + // Hier könnte man prüfen, ob abhängige Entitäten existieren + // z.B. nicht löschen, wenn noch Zeiteinträge vorhanden sind + + result := GetEngine(ctx).Delete(&User{}, id) + if result.Error != nil { + return fmt.Errorf("fehler beim Löschen des Benutzers: %w", result.Error) + } + return nil +} + +// AuthenticateUser authentifiziert einen Benutzer mit Email und Passwort +func AuthenticateUser(ctx context.Context, email, password string) (*User, error) { + user, err := GetUserByEmail(ctx, email) + if err != nil { + return nil, err + } + if user == nil { + // Gleiche Fehlermeldung, um keine Informationen über existierende Accounts preiszugeben + return nil, errors.New("ungültige Anmeldeinformationen") + } + + // Passwort überprüfen mit dem gespeicherten Salt + isValid, err := VerifyPassword(password, user.Salt, user.Hash) + if err != nil { + return nil, fmt.Errorf("fehler bei der Passwortüberprüfung: %w", err) + } + + if !isValid { + return nil, errors.New("ungültige Anmeldeinformationen") + } + + return user, nil +} diff --git a/docu/code_examples/gorm_entities.go b/docu/code_examples/gorm_entities.go index 0ae6192..444b361 100644 --- a/docu/code_examples/gorm_entities.go +++ b/docu/code_examples/gorm_entities.go @@ -1,4 +1,4 @@ -package entities +package models import ( "time" diff --git a/docu/llm_guidance.md b/docu/llm_guidance.md index 070d71e..da881d4 100644 --- a/docu/llm_guidance.md +++ b/docu/llm_guidance.md @@ -43,7 +43,7 @@ Here's a guide to finding information within the project: - **Code Examples:** - `docu/code_examples/react_component.tsx`: Example React component. -**Important Note about Code Examples:** The files in `docu/code_examples/` are for illustrative purposes *only*. They do *not* represent a runnable project structure. Treat each file as an isolated example. The package declarations within these files (e.g., `package entities`, `package repositories`, `package main`) are conceptual and should be interpreted in the context of the described architecture, *not* as a literal directory structure. Do not attempt to run `go get` or similar commands based on these examples, as the necessary project structure and dependencies are not present. +**Important Note about Code Examples:** The files in `docu/code_examples/` are for illustrative purposes *only*. They do *not* represent a runnable project structure. Treat each file as an isolated example. The package declarations within these files (e.g., `package models`, `package repositories`, `package main`) are conceptual and should be interpreted in the context of the described architecture, *not* as a literal directory structure. Do not attempt to run `go get` or similar commands based on these examples, as the necessary project structure and dependencies are not present. ## Rules and Guidelines