refactor: Remove unused entity and datasource files; add AuthDto for authentication, simplification

This commit is contained in:
2025-03-10 09:47:44 +00:00
parent 3b0b2b4340
commit 7f275c774e
52 changed files with 1567 additions and 1059 deletions
+96
View File
@@ -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
}
+26
View File
@@ -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
}
+101
View File
@@ -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
}
+96
View File
@@ -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
}
+108
View File
@@ -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
}
+31
View File
@@ -0,0 +1,31 @@
package models
import "errors"
var ErrUserAlreadyExists = errors.New("user already exists")
var ErrUserNotFound = errors.New("user not found")
var ErrActivityNotFound = errors.New("activity not found")
var ErrActivityAlreadyExists = errors.New("activity already exists")
var ErrInvalidPassword = errors.New("invalid password")
var ErrInvalidEmail = errors.New("invalid email")
var ErrInvalidUsername = errors.New("invalid username")
var ErrInvalidRole = errors.New("invalid role")
var ErrInvalidCompanyID = errors.New("invalid company id")
var ErrInvalidHourlyRate = errors.New("invalid hourly rate")
var ErrInvalidID = errors.New("invalid id")
var ErrTimeEntryNotFound = errors.New("time entry not found")
var ErrTimeEntryAlreadyExists = errors.New("time entry already exists")
var ErrInvalidDuration = errors.New("invalid duration")
var ErrInvalidDescription = errors.New("invalid description")
var ErrInvalidStartTime = errors.New("invalid start time")
var ErrInvalidEndTime = errors.New("invalid end time")
var ErrInvalidBillable = errors.New("invalid billable")
var ErrInvalidProjectID = errors.New("invalid project id")
var ErrProjectNotFound = errors.New("project not found")
var ErrProjectAlreadyExists = errors.New("project already exists")
var ErrInvalidName = errors.New("invalid name")
var ErrInvalidClientID = errors.New("invalid client id")
var ErrClientNotFound = errors.New("client not found")
var ErrClientAlreadyExists = errors.New("client already exists")
var ErrInvalidAddress = errors.New("invalid address")
var ErrInvalidPhone = errors.New("invalid phone")
+230
View File
@@ -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
}
+360
View File
@@ -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
}
+534
View File
@@ -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
}