package repositories import ( "time" "gorm.io/gorm" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/task" "github.com/treytartt/casera-api/internal/task/categorization" ) // TaskRepository handles database operations for tasks type TaskRepository struct { db *gorm.DB } // NewTaskRepository creates a new task repository func NewTaskRepository(db *gorm.DB) *TaskRepository { return &TaskRepository{db: db} } // === Task CRUD === // FindByID finds a task by ID with preloaded relations func (r *TaskRepository) FindByID(id uint) (*models.Task, error) { var task models.Task err := r.db.Preload("Residence"). Preload("CreatedBy"). Preload("AssignedTo"). Preload("Category"). Preload("Priority"). Preload("Frequency"). Preload("Completions"). Preload("Completions.Images"). Preload("Completions.CompletedBy"). First(&task, id).Error if err != nil { return nil, err } return &task, nil } // FindByResidence finds all tasks for a residence func (r *TaskRepository) FindByResidence(residenceID uint) ([]models.Task, error) { var tasks []models.Task err := r.db.Preload("CreatedBy"). Preload("AssignedTo"). Preload("Category"). Preload("Priority"). Preload("Frequency"). Preload("Completions"). Preload("Completions.Images"). Preload("Completions.CompletedBy"). Where("residence_id = ?", residenceID). Order("due_date ASC NULLS LAST, created_at DESC"). Find(&tasks).Error return tasks, err } // FindByUser finds all tasks accessible to a user (across all their residences) func (r *TaskRepository) FindByUser(userID uint, residenceIDs []uint) ([]models.Task, error) { var tasks []models.Task err := r.db.Preload("Residence"). Preload("CreatedBy"). Preload("AssignedTo"). Preload("Category"). Preload("Priority"). Preload("Frequency"). Preload("Completions"). Preload("Completions.Images"). Preload("Completions.CompletedBy"). Where("residence_id IN ?", residenceIDs). Order("due_date ASC NULLS LAST, created_at DESC"). Find(&tasks).Error return tasks, err } // Create creates a new task func (r *TaskRepository) Create(task *models.Task) error { return r.db.Create(task).Error } // Update updates a task // Uses Omit to exclude associations that shouldn't be updated via Save func (r *TaskRepository) Update(task *models.Task) error { return r.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions").Save(task).Error } // Delete hard-deletes a task func (r *TaskRepository) Delete(id uint) error { return r.db.Delete(&models.Task{}, id).Error } // === Task State Operations === // MarkInProgress marks a task as in progress func (r *TaskRepository) MarkInProgress(id uint) error { return r.db.Model(&models.Task{}). Where("id = ?", id). Update("in_progress", true).Error } // Cancel cancels a task func (r *TaskRepository) Cancel(id uint) error { return r.db.Model(&models.Task{}). Where("id = ?", id). Update("is_cancelled", true).Error } // Uncancel uncancels a task func (r *TaskRepository) Uncancel(id uint) error { return r.db.Model(&models.Task{}). Where("id = ?", id). Update("is_cancelled", false).Error } // Archive archives a task func (r *TaskRepository) Archive(id uint) error { return r.db.Model(&models.Task{}). Where("id = ?", id). Update("is_archived", true).Error } // Unarchive unarchives a task func (r *TaskRepository) Unarchive(id uint) error { return r.db.Model(&models.Task{}). Where("id = ?", id). Update("is_archived", false).Error } // === Kanban Board === // GetKanbanData retrieves tasks organized for kanban display // Uses the task.categorization package as the single source of truth for categorization logic. func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*models.KanbanBoard, error) { var tasks []models.Task err := r.db.Preload("CreatedBy"). Preload("AssignedTo"). Preload("Category"). Preload("Priority"). Preload("Frequency"). Preload("Completions"). Preload("Completions.Images"). Preload("Completions.CompletedBy"). Where("residence_id = ? AND is_archived = ?", residenceID, false). Scopes(task.ScopeKanbanOrder). Find(&tasks).Error if err != nil { return nil, err } // Use the categorization package as the single source of truth categorized := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold) columns := []models.KanbanColumn{ { Name: string(categorization.ColumnOverdue), DisplayName: "Overdue", ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"}, Color: "#FF3B30", Tasks: categorized[categorization.ColumnOverdue], Count: len(categorized[categorization.ColumnOverdue]), }, { Name: string(categorization.ColumnInProgress), DisplayName: "In Progress", ButtonTypes: []string{"edit", "complete", "cancel"}, Icons: map[string]string{"ios": "hammer", "android": "Build"}, Color: "#5856D6", Tasks: categorized[categorization.ColumnInProgress], Count: len(categorized[categorization.ColumnInProgress]), }, { Name: string(categorization.ColumnDueSoon), DisplayName: "Due Soon", ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "clock", "android": "Schedule"}, Color: "#FF9500", Tasks: categorized[categorization.ColumnDueSoon], Count: len(categorized[categorization.ColumnDueSoon]), }, { Name: string(categorization.ColumnUpcoming), DisplayName: "Upcoming", ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "calendar", "android": "Event"}, Color: "#007AFF", Tasks: categorized[categorization.ColumnUpcoming], Count: len(categorized[categorization.ColumnUpcoming]), }, { Name: string(categorization.ColumnCompleted), DisplayName: "Completed", ButtonTypes: []string{}, Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"}, Color: "#34C759", Tasks: categorized[categorization.ColumnCompleted], Count: len(categorized[categorization.ColumnCompleted]), }, { Name: string(categorization.ColumnCancelled), DisplayName: "Cancelled", ButtonTypes: []string{"uncancel", "delete"}, Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"}, Color: "#8E8E93", Tasks: categorized[categorization.ColumnCancelled], Count: len(categorized[categorization.ColumnCancelled]), }, } return &models.KanbanBoard{ Columns: columns, DaysThreshold: daysThreshold, ResidenceID: string(rune(residenceID)), }, nil } // GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display // Uses the task.categorization package as the single source of truth for categorization logic. func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int) (*models.KanbanBoard, error) { var tasks []models.Task err := r.db.Preload("CreatedBy"). Preload("AssignedTo"). Preload("Category"). Preload("Priority"). Preload("Frequency"). Preload("Completions"). Preload("Completions.Images"). Preload("Completions.CompletedBy"). Preload("Residence"). Where("residence_id IN ? AND is_archived = ?", residenceIDs, false). Scopes(task.ScopeKanbanOrder). Find(&tasks).Error if err != nil { return nil, err } // Use the categorization package as the single source of truth categorized := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold) columns := []models.KanbanColumn{ { Name: string(categorization.ColumnOverdue), DisplayName: "Overdue", ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"}, Color: "#FF3B30", Tasks: categorized[categorization.ColumnOverdue], Count: len(categorized[categorization.ColumnOverdue]), }, { Name: string(categorization.ColumnInProgress), DisplayName: "In Progress", ButtonTypes: []string{"edit", "complete", "cancel"}, Icons: map[string]string{"ios": "hammer", "android": "Build"}, Color: "#5856D6", Tasks: categorized[categorization.ColumnInProgress], Count: len(categorized[categorization.ColumnInProgress]), }, { Name: string(categorization.ColumnDueSoon), DisplayName: "Due Soon", ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "clock", "android": "Schedule"}, Color: "#FF9500", Tasks: categorized[categorization.ColumnDueSoon], Count: len(categorized[categorization.ColumnDueSoon]), }, { Name: string(categorization.ColumnUpcoming), DisplayName: "Upcoming", ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "calendar", "android": "Event"}, Color: "#007AFF", Tasks: categorized[categorization.ColumnUpcoming], Count: len(categorized[categorization.ColumnUpcoming]), }, { Name: string(categorization.ColumnCompleted), DisplayName: "Completed", ButtonTypes: []string{}, Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"}, Color: "#34C759", Tasks: categorized[categorization.ColumnCompleted], Count: len(categorized[categorization.ColumnCompleted]), }, { Name: string(categorization.ColumnCancelled), DisplayName: "Cancelled", ButtonTypes: []string{"uncancel", "delete"}, Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"}, Color: "#8E8E93", Tasks: categorized[categorization.ColumnCancelled], Count: len(categorized[categorization.ColumnCancelled]), }, } return &models.KanbanBoard{ Columns: columns, DaysThreshold: daysThreshold, ResidenceID: "all", }, nil } // === Lookup Operations === // GetAllCategories returns all task categories func (r *TaskRepository) GetAllCategories() ([]models.TaskCategory, error) { var categories []models.TaskCategory err := r.db.Order("display_order, name").Find(&categories).Error return categories, err } // GetAllPriorities returns all task priorities func (r *TaskRepository) GetAllPriorities() ([]models.TaskPriority, error) { var priorities []models.TaskPriority err := r.db.Order("level").Find(&priorities).Error return priorities, err } // GetAllFrequencies returns all task frequencies func (r *TaskRepository) GetAllFrequencies() ([]models.TaskFrequency, error) { var frequencies []models.TaskFrequency err := r.db.Order("display_order").Find(&frequencies).Error return frequencies, err } // CountByResidence counts tasks in a residence func (r *TaskRepository) CountByResidence(residenceID uint) (int64, error) { var count int64 err := r.db.Model(&models.Task{}). Where("residence_id = ? AND is_cancelled = ? AND is_archived = ?", residenceID, false, false). Count(&count).Error return count, err } // === Task Completion Operations === // CreateCompletion creates a new task completion func (r *TaskRepository) CreateCompletion(completion *models.TaskCompletion) error { return r.db.Create(completion).Error } // FindCompletionByID finds a completion by ID func (r *TaskRepository) FindCompletionByID(id uint) (*models.TaskCompletion, error) { var completion models.TaskCompletion err := r.db.Preload("Task"). Preload("CompletedBy"). Preload("Images"). First(&completion, id).Error if err != nil { return nil, err } return &completion, nil } // FindCompletionsByTask finds all completions for a task func (r *TaskRepository) FindCompletionsByTask(taskID uint) ([]models.TaskCompletion, error) { var completions []models.TaskCompletion err := r.db.Preload("CompletedBy"). Preload("Images"). Where("task_id = ?", taskID). Order("completed_at DESC"). Find(&completions).Error return completions, err } // FindCompletionsByUser finds all completions by a user func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint) ([]models.TaskCompletion, error) { var completions []models.TaskCompletion err := r.db.Preload("Task"). Preload("CompletedBy"). Preload("Images"). Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id"). Where("task_task.residence_id IN ?", residenceIDs). Order("completed_at DESC"). Find(&completions).Error return completions, err } // DeleteCompletion deletes a task completion func (r *TaskRepository) DeleteCompletion(id uint) error { // Delete images first r.db.Where("completion_id = ?", id).Delete(&models.TaskCompletionImage{}) return r.db.Delete(&models.TaskCompletion{}, id).Error } // CreateCompletionImage creates a new completion image func (r *TaskRepository) CreateCompletionImage(image *models.TaskCompletionImage) error { return r.db.Create(image).Error } // DeleteCompletionImage deletes a completion image func (r *TaskRepository) DeleteCompletionImage(id uint) error { return r.db.Delete(&models.TaskCompletionImage{}, id).Error } // FindCompletionImageByID finds a completion image by ID func (r *TaskRepository) FindCompletionImageByID(id uint) (*models.TaskCompletionImage, error) { var image models.TaskCompletionImage err := r.db.First(&image, id).Error if err != nil { return nil, err } return &image, nil } // TaskStatistics represents aggregated task statistics type TaskStatistics struct { TotalTasks int TotalPending int TotalOverdue int TasksDueNextWeek int TasksDueNextMonth int } // GetTaskStatistics returns aggregated task statistics for multiple residences. // Uses the task.scopes package for consistent filtering logic. func (r *TaskRepository) GetTaskStatistics(residenceIDs []uint) (*TaskStatistics, error) { if len(residenceIDs) == 0 { return &TaskStatistics{}, nil } now := time.Now().UTC() var totalTasks, totalOverdue, totalPending, tasksDueNextWeek, tasksDueNextMonth int64 // Count total active tasks (not cancelled, not archived) // Uses: task.ScopeActive, task.ScopeForResidences err := r.db.Model(&models.Task{}). Scopes(task.ScopeForResidences(residenceIDs), task.ScopeActive). Count(&totalTasks).Error if err != nil { return nil, err } // Count overdue tasks using consistent scope // Uses: task.ScopeOverdue (which includes ScopeActive and ScopeNotCompleted) err = r.db.Model(&models.Task{}). Scopes(task.ScopeForResidences(residenceIDs), task.ScopeOverdue(now)). Count(&totalOverdue).Error if err != nil { return nil, err } // Count pending tasks (active, not completed) // Uses: task.ScopeActive, task.ScopeNotCompleted err = r.db.Model(&models.Task{}). Scopes(task.ScopeForResidences(residenceIDs), task.ScopeActive, task.ScopeNotCompleted). Count(&totalPending).Error if err != nil { return nil, err } // Count tasks due next week using consistent scope // Uses: task.ScopeDueSoon with 7-day threshold err = r.db.Model(&models.Task{}). Scopes(task.ScopeForResidences(residenceIDs), task.ScopeDueSoon(now, 7)). Count(&tasksDueNextWeek).Error if err != nil { return nil, err } // Count tasks due next month using consistent scope // Uses: task.ScopeDueSoon with 30-day threshold err = r.db.Model(&models.Task{}). Scopes(task.ScopeForResidences(residenceIDs), task.ScopeDueSoon(now, 30)). Count(&tasksDueNextMonth).Error if err != nil { return nil, err } return &TaskStatistics{ TotalTasks: int(totalTasks), TotalPending: int(totalPending), TotalOverdue: int(totalOverdue), TasksDueNextWeek: int(tasksDueNextWeek), TasksDueNextMonth: int(tasksDueNextMonth), }, nil } // GetOverdueCountByResidence returns a map of residence ID to overdue task count. // Uses the task.scopes package for consistent filtering logic. func (r *TaskRepository) GetOverdueCountByResidence(residenceIDs []uint) (map[uint]int, error) { if len(residenceIDs) == 0 { return map[uint]int{}, nil } now := time.Now().UTC() // Query to get overdue count grouped by residence type result struct { ResidenceID uint Count int64 } var results []result err := r.db.Model(&models.Task{}). Select("residence_id, COUNT(*) as count"). Scopes(task.ScopeForResidences(residenceIDs), task.ScopeOverdue(now)). Group("residence_id"). Scan(&results).Error if err != nil { return nil, err } // Convert to map countMap := make(map[uint]int) for _, r := range results { countMap[r.ResidenceID] = int(r.Count) } return countMap, nil }