package models import ( "context" "errors" "fmt" "time" "github.com/oklog/ulid/v2" "gorm.io/gorm" ) // TimeEntry represents a time entry in the 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) // Relationships for Eager Loading User *User `gorm:"foreignKey:UserID"` Project *Project `gorm:"foreignKey:ProjectID"` Activity *Activity `gorm:"foreignKey:ActivityID"` } // TableName specifies the table name for GORM func (TimeEntry) TableName() string { return "time_entries" } // TimeEntryCreate contains the fields for creating a new time entry type TimeEntryCreate struct { UserID ulid.ULID ProjectID ulid.ULID ActivityID ulid.ULID Start time.Time End time.Time Description string Billable int // Percentage (0-100) } // TimeEntryUpdate contains the updatable fields of a time entry type TimeEntryUpdate struct { ID ulid.ULID `gorm:"-"` // Exclude from 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 checks if the Create struct contains valid data func (tc *TimeEntryCreate) Validate() error { // Check for empty IDs if tc.UserID.Compare(ulid.ULID{}) == 0 { return errors.New("userID cannot be empty") } if tc.ProjectID.Compare(ulid.ULID{}) == 0 { return errors.New("projectID cannot be empty") } if tc.ActivityID.Compare(ulid.ULID{}) == 0 { return errors.New("activityID cannot be empty") } // Time checks if tc.Start.IsZero() { return errors.New("start time cannot be empty") } if tc.End.IsZero() { return errors.New("end time cannot be empty") } if tc.End.Before(tc.Start) { return errors.New("end time cannot be before start time") } // Billable percentage check if tc.Billable < 0 || tc.Billable > 100 { return errors.New("billable must be between 0 and 100") } return nil } // Validate checks if the Update struct contains valid data func (tu *TimeEntryUpdate) Validate() error { // Billable percentage check if tu.Billable != nil && (*tu.Billable < 0 || *tu.Billable > 100) { return errors.New("billable must be between 0 and 100") } // Time checks if tu.Start != nil && tu.End != nil && tu.End.Before(*tu.Start) { return errors.New("end time cannot be before start time") } return nil } // GetTimeEntryByID finds a time entry by its ID func GetTimeEntryByID(ctx context.Context, id 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 loads a time entry with all associated data func GetTimeEntryWithRelations(ctx context.Context, id ulid.ULID) (*TimeEntry, error) { var timeEntry TimeEntry result := GetEngine(ctx). Preload("User"). Preload("Project"). Preload("Project.Customer"). // Nested relationship Preload("Activity"). Where("id = ?", id). First(&timeEntry) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, nil } return nil, result.Error } return &timeEntry, nil } // GetAllTimeEntries returns all time entries func GetAllTimeEntries(ctx context.Context) ([]TimeEntry, error) { var timeEntries []TimeEntry result := GetEngine(ctx).Find(&timeEntries) if result.Error != nil { return nil, result.Error } return timeEntries, nil } // GetTimeEntriesByUserID returns all time entries of a user func GetTimeEntriesByUserID(ctx context.Context, userID 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 returns all time entries of a project 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 returns all time entries within a time range func GetTimeEntriesByDateRange(ctx context.Context, start, end time.Time) ([]TimeEntry, error) { var timeEntries []TimeEntry // Search for overlaps in the time range result := GetEngine(ctx). Where("(start BETWEEN ? AND ?) OR (end BETWEEN ? AND ?)", start, end, start, end). Find(&timeEntries) if result.Error != nil { return nil, result.Error } return timeEntries, nil } // SumBillableHoursByProject calculates the billable hours per project func SumBillableHoursByProject(ctx context.Context, projectID ulid.ULID) (float64, error) { type Result struct { TotalHours float64 } var result Result // SQL calculation of weighted hours 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 creates a new time entry with validation func CreateTimeEntry(ctx context.Context, create TimeEntryCreate) (*TimeEntry, error) { // Validation if err := create.Validate(); err != nil { return nil, fmt.Errorf("validation error: %w", err) } // Start a transaction var timeEntry *TimeEntry err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error { // Check references if err := validateReferences(tx, create.UserID, create.ProjectID, create.ActivityID); err != nil { return err } // Create time entry newTimeEntry := TimeEntry{ UserID: create.UserID, ProjectID: create.ProjectID, ActivityID: create.ActivityID, Start: create.Start, End: create.End, Description: create.Description, Billable: create.Billable, } if err := tx.Create(&newTimeEntry).Error; err != nil { return fmt.Errorf("error creating the time entry: %w", err) } timeEntry = &newTimeEntry return nil }) if err != nil { return nil, err } return timeEntry, nil } // validateReferences checks if all referenced entities exist func validateReferences(tx *gorm.DB, userID, projectID, activityID ulid.ULID) error { // Check user var userCount int64 if err := tx.Model(&User{}).Where("id = ?", userID).Count(&userCount).Error; err != nil { return fmt.Errorf("error checking the user: %w", err) } if userCount == 0 { return errors.New("the specified user does not exist") } // Check project var projectCount int64 if err := tx.Model(&Project{}).Where("id = ?", projectID).Count(&projectCount).Error; err != nil { return fmt.Errorf("error checking the project: %w", err) } if projectCount == 0 { return errors.New("the specified project does not exist") } // Check activity var activityCount int64 if err := tx.Model(&Activity{}).Where("id = ?", activityID).Count(&activityCount).Error; err != nil { return fmt.Errorf("error checking the activity: %w", err) } if activityCount == 0 { return errors.New("the specified activity does not exist") } return nil } // UpdateTimeEntry updates an existing time entry with validation func UpdateTimeEntry(ctx context.Context, update TimeEntryUpdate) (*TimeEntry, error) { // Validation if err := update.Validate(); err != nil { return nil, fmt.Errorf("validation error: %w", err) } // Find time entry timeEntry, err := GetTimeEntryByID(ctx, update.ID) if err != nil { return nil, err } if timeEntry == nil { return nil, errors.New("time entry not found") } // Start a transaction for the update err = GetEngine(ctx).Transaction(func(tx *gorm.DB) error { // Check references if they are updated if update.UserID != nil || update.ProjectID != nil || update.ActivityID != nil { // Use current values if not updated userID := timeEntry.UserID if update.UserID != nil { userID = *update.UserID } projectID := timeEntry.ProjectID if update.ProjectID != nil { projectID = *update.ProjectID } activityID := timeEntry.ActivityID if update.ActivityID != nil { activityID = *update.ActivityID } if err := validateReferences(tx, userID, projectID, activityID); err != nil { return err } } // Check time consistency start := timeEntry.Start if update.Start != nil { start = *update.Start } end := timeEntry.End if update.End != nil { end = *update.End } if end.Before(start) { return errors.New("end time cannot be before start time") } // Use generic update if err := UpdateModel(ctx, timeEntry, update); err != nil { return fmt.Errorf("error updating the time entry: %w", err) } return nil }) if err != nil { return nil, err } // Load updated data from the database return GetTimeEntryByID(ctx, update.ID) } // DeleteTimeEntry deletes a time entry by its ID func DeleteTimeEntry(ctx context.Context, id ulid.ULID) error { result := GetEngine(ctx).Delete(&TimeEntry{}, id) if result.Error != nil { return fmt.Errorf("error deleting the time entry: %w", result.Error) } return nil }