2025-03-31 19:07:30 +00:00

362 lines
9.8 KiB
Go

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