package repositories import ( "fmt" "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 Filter Options === // TaskFilterOptions provides flexible filtering for task queries. // Use exactly one of ResidenceID, ResidenceIDs, or UserIDs to specify the filter scope. type TaskFilterOptions struct { // Filter by single residence (kanban single-residence view) ResidenceID uint // Filter by multiple residences (kanban all-residences view) ResidenceIDs []uint // Filter by users - matches tasks where assigned_to IN userIDs // OR residence owner IN userIDs (for notifications) UserIDs []uint // Include archived tasks (default: false, excludes archived) IncludeArchived bool // IncludeInProgress controls whether in-progress tasks are included in // overdue/due-soon/upcoming queries. Default is false (excludes in-progress) // for kanban column consistency. Set to true for notifications where // users should still be notified about in-progress tasks that are overdue. IncludeInProgress bool // Preload options PreloadCreatedBy bool PreloadAssignedTo bool PreloadResidence bool PreloadCompletions bool // Minimal: just id, task_id, completed_at } // applyFilterOptions applies the filter options to a query. // Returns a new query with filters and preloads applied. func (r *TaskRepository) applyFilterOptions(query *gorm.DB, opts TaskFilterOptions) *gorm.DB { // Apply residence/user filters if opts.ResidenceID != 0 { query = query.Where("task_task.residence_id = ?", opts.ResidenceID) } else if len(opts.ResidenceIDs) > 0 { query = query.Where("task_task.residence_id IN ?", opts.ResidenceIDs) } else if len(opts.UserIDs) > 0 { // For notifications: tasks assigned to users OR owned by users query = query.Where( "(task_task.assigned_to_id IN ? OR task_task.residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))", opts.UserIDs, opts.UserIDs, ) } // Apply archived filter (default excludes archived) if !opts.IncludeArchived { query = query.Where("task_task.is_archived = ?", false) } // Apply preloads if opts.PreloadCreatedBy { query = query.Preload("CreatedBy") } if opts.PreloadAssignedTo { query = query.Preload("AssignedTo") } if opts.PreloadResidence { query = query.Preload("Residence") } if opts.PreloadCompletions { query = query.Preload("Completions", func(db *gorm.DB) *gorm.DB { return db.Select("id", "task_id", "completed_at") }) } return query } // === Single-Purpose Task Query Functions === // These functions use the scopes from internal/task/scopes for consistent filtering. // They are the single source of truth for task categorization queries, used by both // kanban and notification handlers. // GetOverdueTasks returns active, non-completed tasks past their effective due date. // Uses task.ScopeOverdue for consistent filtering logic. // The `now` parameter should be in the user's timezone for accurate overdue detection. // // By default, excludes in-progress tasks for kanban column consistency. // Set opts.IncludeInProgress=true for notifications where in-progress tasks should still appear. func (r *TaskRepository) GetOverdueTasks(now time.Time, opts TaskFilterOptions) ([]models.Task, error) { var tasks []models.Task query := r.db.Model(&models.Task{}) if opts.IncludeArchived { // When including archived, build the query manually to skip the archived check // but still apply cancelled check, not-completed check, and date check startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) query = query.Where("is_cancelled = ?", false). Scopes(task.ScopeNotCompleted). Where("COALESCE(next_due_date, due_date) < ?", startOfDay) } else { // Use the combined scope which includes is_archived = false query = query.Scopes(task.ScopeOverdue(now)) } query = query.Scopes(task.ScopeKanbanOrder) if !opts.IncludeInProgress { query = query.Scopes(task.ScopeNotInProgress) } query = r.applyFilterOptions(query, opts) err := query.Find(&tasks).Error return tasks, err } // GetDueSoonTasks returns active, non-completed tasks due within the threshold. // Uses task.ScopeDueSoon for consistent filtering logic. // The `now` parameter should be in the user's timezone for accurate detection. // // By default, excludes in-progress tasks for kanban column consistency. // Set opts.IncludeInProgress=true for notifications where in-progress tasks should still appear. func (r *TaskRepository) GetDueSoonTasks(now time.Time, daysThreshold int, opts TaskFilterOptions) ([]models.Task, error) { var tasks []models.Task query := r.db.Model(&models.Task{}). Scopes(task.ScopeDueSoon(now, daysThreshold), task.ScopeKanbanOrder) if !opts.IncludeInProgress { query = query.Scopes(task.ScopeNotInProgress) } query = r.applyFilterOptions(query, opts) err := query.Find(&tasks).Error return tasks, err } // GetInProgressTasks returns active, non-completed tasks marked as in-progress. // Uses task.ScopeInProgress for consistent filtering logic. // // Note: Excludes completed tasks to match kanban column behavior (completed has higher priority). func (r *TaskRepository) GetInProgressTasks(opts TaskFilterOptions) ([]models.Task, error) { var tasks []models.Task query := r.db.Model(&models.Task{}). Scopes(task.ScopeActive, task.ScopeNotCompleted, task.ScopeInProgress, task.ScopeKanbanOrder) query = r.applyFilterOptions(query, opts) err := query.Find(&tasks).Error return tasks, err } // GetUpcomingTasks returns active, non-completed tasks due after the threshold or with no due date. // Uses task.ScopeUpcoming for consistent filtering logic. // // By default, excludes in-progress tasks for kanban column consistency. // Set opts.IncludeInProgress=true for notifications where in-progress tasks should still appear. func (r *TaskRepository) GetUpcomingTasks(now time.Time, daysThreshold int, opts TaskFilterOptions) ([]models.Task, error) { var tasks []models.Task query := r.db.Model(&models.Task{}). Scopes(task.ScopeUpcoming(now, daysThreshold), task.ScopeKanbanOrder) if !opts.IncludeInProgress { query = query.Scopes(task.ScopeNotInProgress) } query = r.applyFilterOptions(query, opts) err := query.Find(&tasks).Error return tasks, err } // GetCompletedTasks returns completed tasks (NextDueDate nil with at least one completion). // Uses task.ScopeCompleted for consistent filtering logic. func (r *TaskRepository) GetCompletedTasks(opts TaskFilterOptions) ([]models.Task, error) { var tasks []models.Task // Completed tasks: not cancelled, has completion, no next due date // Note: We don't apply ScopeActive because completed tasks may not be "active" in that sense query := r.db.Model(&models.Task{}). Where("is_cancelled = ?", false). Scopes(task.ScopeCompleted, task.ScopeKanbanOrder) query = r.applyFilterOptions(query, opts) err := query.Find(&tasks).Error return tasks, err } // GetCancelledTasks returns cancelled OR archived tasks. // Archived tasks are grouped with cancelled for kanban purposes - they both represent // tasks that are no longer active/actionable. func (r *TaskRepository) GetCancelledTasks(opts TaskFilterOptions) ([]models.Task, error) { var tasks []models.Task // Include both cancelled and archived tasks in this column // Archived tasks should ONLY appear here, not in any other column query := r.db.Model(&models.Task{}). Where("is_cancelled = ? OR is_archived = ?", true, true). Scopes(task.ScopeKanbanOrder) // Override IncludeArchived to true since this function specifically handles archived tasks opts.IncludeArchived = true query = r.applyFilterOptions(query, opts) err := query.Find(&tasks).Error return tasks, err } // === Task CRUD === // FindByID finds a task by ID with preloaded relations // Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs func (r *TaskRepository) FindByID(id uint) (*models.Task, error) { var task models.Task err := r.db.Preload("Residence"). Preload("CreatedBy"). Preload("AssignedTo"). 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 // Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs func (r *TaskRepository) FindByResidence(residenceID uint) ([]models.Task, error) { var tasks []models.Task err := r.db.Preload("CreatedBy"). Preload("AssignedTo"). 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) // Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs 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("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 === // buildKanbanColumns builds the kanban column array from categorized task slices. // This is a helper function to reduce duplication between GetKanbanData and GetKanbanDataForMultipleResidences. func buildKanbanColumns( overdue, inProgress, dueSoon, upcoming, completed, cancelled []models.Task, ) []models.KanbanColumn { return []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: overdue, Count: len(overdue), }, { Name: string(categorization.ColumnInProgress), DisplayName: "In Progress", ButtonTypes: []string{"edit", "complete", "cancel"}, Icons: map[string]string{"ios": "hammer", "android": "Build"}, Color: "#5856D6", Tasks: inProgress, Count: len(inProgress), }, { 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: dueSoon, Count: len(dueSoon), }, { 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: upcoming, Count: len(upcoming), }, { Name: string(categorization.ColumnCompleted), DisplayName: "Completed", ButtonTypes: []string{}, Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"}, Color: "#34C759", Tasks: completed, Count: len(completed), }, { Name: string(categorization.ColumnCancelled), DisplayName: "Cancelled", ButtonTypes: []string{"uncancel", "delete"}, Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"}, Color: "#8E8E93", Tasks: cancelled, Count: len(cancelled), }, } } // GetKanbanData retrieves tasks organized for kanban display. // Uses single-purpose query functions for each column type, ensuring consistency // with notification handlers that use the same functions. // The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. // // Optimization: Preloads only minimal completion data (id, task_id, completed_at) for count/detection. // Images and CompletedBy are NOT preloaded - fetch separately when viewing completion details. func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) { opts := TaskFilterOptions{ ResidenceID: residenceID, PreloadCreatedBy: true, PreloadAssignedTo: true, PreloadCompletions: true, } // Query each column using single-purpose functions // These functions use the same scopes as notification handlers for consistency overdue, err := r.GetOverdueTasks(now, opts) if err != nil { return nil, fmt.Errorf("get overdue tasks: %w", err) } inProgress, err := r.GetInProgressTasks(opts) if err != nil { return nil, fmt.Errorf("get in-progress tasks: %w", err) } dueSoon, err := r.GetDueSoonTasks(now, daysThreshold, opts) if err != nil { return nil, fmt.Errorf("get due-soon tasks: %w", err) } upcoming, err := r.GetUpcomingTasks(now, daysThreshold, opts) if err != nil { return nil, fmt.Errorf("get upcoming tasks: %w", err) } completed, err := r.GetCompletedTasks(opts) if err != nil { return nil, fmt.Errorf("get completed tasks: %w", err) } cancelled, err := r.GetCancelledTasks(opts) if err != nil { return nil, fmt.Errorf("get cancelled tasks: %w", err) } columns := buildKanbanColumns(overdue, inProgress, dueSoon, upcoming, completed, cancelled) return &models.KanbanBoard{ Columns: columns, DaysThreshold: daysThreshold, ResidenceID: fmt.Sprintf("%d", residenceID), }, nil } // GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display. // Uses single-purpose query functions for each column type, ensuring consistency // with notification handlers that use the same functions. // The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. // // Optimization: Preloads only minimal completion data (id, task_id, completed_at) for count/detection. // Images and CompletedBy are NOT preloaded - fetch separately when viewing completion details. func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) { opts := TaskFilterOptions{ ResidenceIDs: residenceIDs, PreloadCreatedBy: true, PreloadAssignedTo: true, PreloadResidence: true, PreloadCompletions: true, } // Query each column using single-purpose functions // These functions use the same scopes as notification handlers for consistency overdue, err := r.GetOverdueTasks(now, opts) if err != nil { return nil, fmt.Errorf("get overdue tasks: %w", err) } inProgress, err := r.GetInProgressTasks(opts) if err != nil { return nil, fmt.Errorf("get in-progress tasks: %w", err) } dueSoon, err := r.GetDueSoonTasks(now, daysThreshold, opts) if err != nil { return nil, fmt.Errorf("get due-soon tasks: %w", err) } upcoming, err := r.GetUpcomingTasks(now, daysThreshold, opts) if err != nil { return nil, fmt.Errorf("get upcoming tasks: %w", err) } completed, err := r.GetCompletedTasks(opts) if err != nil { return nil, fmt.Errorf("get completed tasks: %w", err) } cancelled, err := r.GetCancelledTasks(opts) if err != nil { return nil, fmt.Errorf("get cancelled tasks: %w", err) } columns := buildKanbanColumns(overdue, inProgress, dueSoon, upcoming, completed, cancelled) 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 } // GetFrequencyByID retrieves a single frequency by ID func (r *TaskRepository) GetFrequencyByID(id uint) (*models.TaskFrequency, error) { var frequency models.TaskFrequency err := r.db.First(&frequency, id).Error if err != nil { return nil, err } return &frequency, nil } // 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 } // GetOverdueCountByResidence returns a map of residence ID to overdue task count. // Uses the task.scopes package for consistent filtering logic. // The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. func (r *TaskRepository) GetOverdueCountByResidence(residenceIDs []uint, now time.Time) (map[uint]int, error) { if len(residenceIDs) == 0 { return map[uint]int{}, nil } // 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 }