362 lines
9.8 KiB
Go
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
|
|
}
|