package repositories import ( "errors" "fmt" "time" "github.com/rs/zerolog/log" "gorm.io/gorm" "github.com/treytartt/honeydue-api/internal/dto/responses" "github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/task" "github.com/treytartt/honeydue-api/internal/task/categorization" ) // ErrVersionConflict indicates a concurrent modification was detected var ErrVersionConflict = errors.New("version conflict: task was modified by another request") // 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} } // DB returns the underlying database connection. // Used by services that need to run transactions spanning multiple operations. func (r *TaskRepository) DB() *gorm.DB { return r.db } // CreateCompletionTx creates a new task completion within an existing transaction. func (r *TaskRepository) CreateCompletionTx(tx *gorm.DB, completion *models.TaskCompletion) error { return tx.Create(completion).Error } // taskUpdateFields returns the canonical field map used by both Update and UpdateTx. // Centralised here so the two methods never drift out of sync. func taskUpdateFields(t *models.Task) map[string]interface{} { return map[string]interface{}{ "title": t.Title, "description": t.Description, "category_id": t.CategoryID, "priority_id": t.PriorityID, "frequency_id": t.FrequencyID, "custom_interval_days": t.CustomIntervalDays, "in_progress": t.InProgress, "assigned_to_id": t.AssignedToID, "due_date": t.DueDate, "next_due_date": t.NextDueDate, "estimated_cost": t.EstimatedCost, "actual_cost": t.ActualCost, "contractor_id": t.ContractorID, "is_cancelled": t.IsCancelled, "is_archived": t.IsArchived, "version": gorm.Expr("version + 1"), } } // taskUpdateOmitAssociations lists the association fields to omit during task updates. var taskUpdateOmitAssociations = []string{ "Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions", } // UpdateTx updates a task with optimistic locking within an existing transaction. func (r *TaskRepository) UpdateTx(tx *gorm.DB, task *models.Task) error { result := tx.Model(task). Where("id = ? AND version = ?", task.ID, task.Version). Omit(taskUpdateOmitAssociations...). Updates(taskUpdateFields(task)) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return ErrVersionConflict } task.Version++ // Update local copy return nil } // === 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 PreloadFrequency bool // For smart notifications } // 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 (only from active residences) query = query.Where( "(task_task.assigned_to_id IN ? OR task_task.residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ? AND is_active = true))", 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") }) } if opts.PreloadFrequency { query = query.Preload("Frequency") } 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 // IMPORTANT: Use date comparison (not timestamps) for timezone correctness todayStr := now.Format("2006-01-02") query = query.Where("is_cancelled = ?", false). Scopes(task.ScopeNotCompleted). Where("DATE(COALESCE(next_due_date, due_date)) < DATE(?)", todayStr) } 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 } // GetActiveTasksForUsers returns all active, non-completed tasks for the specified users. // This is used by the smart notification system to evaluate all tasks for potential reminders. // It includes tasks that are overdue, due soon, or upcoming - the caller determines // which reminders to send based on the task's frequency and due date. func (r *TaskRepository) GetActiveTasksForUsers(now time.Time, opts TaskFilterOptions) ([]models.Task, error) { var tasks []models.Task // Get all active, non-completed tasks query := r.db.Model(&models.Task{}). Scopes(task.ScopeActive, task.ScopeNotCompleted) // Include in-progress tasks if specified if !opts.IncludeInProgress { query = query.Scopes(task.ScopeNotInProgress) } // Apply filters and preloads query = r.applyFilterOptions(query, opts) // Order by due date for consistent processing query = query.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST") 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 } // CreateTx creates a new task within an existing transaction. Used by // bulk-create flows where multiple inserts must succeed or fail together. func (r *TaskRepository) CreateTx(tx *gorm.DB, task *models.Task) error { return tx.Create(task).Error } // FindByIDTx loads a task within an existing transaction. Preloads only the // fields the bulk-create response needs (CreatedBy, AssignedTo). Category / // Priority / Frequency are resolved client-side from the lookup cache, so // we skip them here to match the FindByResidence preload set. func (r *TaskRepository) FindByIDTx(tx *gorm.DB, id uint) (*models.Task, error) { var task models.Task err := tx.Preload("CreatedBy"). Preload("AssignedTo"). First(&task, id).Error if err != nil { return nil, err } return &task, nil } // Update updates a task with optimistic locking. // The update only succeeds if the task's version in the database matches the expected version. // On success, the local task.Version is incremented to reflect the new version. func (r *TaskRepository) Update(task *models.Task) error { result := r.db.Model(task). Where("id = ? AND version = ?", task.ID, task.Version). Omit(taskUpdateOmitAssociations...). Updates(taskUpdateFields(task)) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return ErrVersionConflict } task.Version++ // Update local copy return nil } // 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 with optimistic locking. func (r *TaskRepository) MarkInProgress(id uint, version int) error { result := r.db.Model(&models.Task{}). Where("id = ? AND version = ?", id, version). Updates(map[string]interface{}{ "in_progress": true, "version": gorm.Expr("version + 1"), }) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return ErrVersionConflict } return nil } // Cancel cancels a task with optimistic locking. func (r *TaskRepository) Cancel(id uint, version int) error { result := r.db.Model(&models.Task{}). Where("id = ? AND version = ?", id, version). Updates(map[string]interface{}{ "is_cancelled": true, "version": gorm.Expr("version + 1"), }) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return ErrVersionConflict } return nil } // Uncancel uncancels a task with optimistic locking. func (r *TaskRepository) Uncancel(id uint, version int) error { result := r.db.Model(&models.Task{}). Where("id = ? AND version = ?", id, version). Updates(map[string]interface{}{ "is_cancelled": false, "version": gorm.Expr("version + 1"), }) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return ErrVersionConflict } return nil } // Archive archives a task with optimistic locking. func (r *TaskRepository) Archive(id uint, version int) error { result := r.db.Model(&models.Task{}). Where("id = ? AND version = ?", id, version). Updates(map[string]interface{}{ "is_archived": true, "version": gorm.Expr("version + 1"), }) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return ErrVersionConflict } return nil } // Unarchive unarchives a task with optimistic locking. func (r *TaskRepository) Unarchive(id uint, version int) error { result := r.db.Model(&models.Task{}). Where("id = ? AND version = ?", id, version). Updates(map[string]interface{}{ "is_archived": false, "version": gorm.Expr("version + 1"), }) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return ErrVersionConflict } return nil } // === Kanban Board === // buildKanbanColumns builds the kanban column array from categorized task slices. // This is a helper function to reduce duplication between GetKanbanData and GetKanbanDataForMultipleResidences. // Note: cancelled/archived tasks are intentionally hidden from the kanban board. // They still retain "cancelled_tasks" as task-level categorization for detail views/actions. func buildKanbanColumns( overdue, inProgress, dueSoon, upcoming, completed []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), }, // Intentionally hidden from board: // cancelled/archived tasks are not returned as a kanban column. // { // 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. // Fetches all non-cancelled, non-archived tasks for the residence in a single query, // then categorizes them in-memory using the task categorization chain for consistency // with the predicate-based logic used throughout the application. // The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. // // Optimization: Single query with preloads, then in-memory categorization. // 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) { // Fetch all tasks for this residence in a single query (excluding cancelled/archived) var allTasks []models.Task query := r.db.Model(&models.Task{}). Where("task_task.residence_id = ?", residenceID). Preload("CreatedBy"). Preload("AssignedTo"). Preload("Completions", func(db *gorm.DB) *gorm.DB { return db.Select("id", "task_id", "completed_at") }). Scopes(task.ScopeKanbanOrder) if err := query.Find(&allTasks).Error; err != nil { return nil, fmt.Errorf("get tasks for kanban: %w", err) } // Categorize all tasks in-memory using the categorization chain columnMap := categorization.CategorizeTasksIntoColumnsWithTime(allTasks, daysThreshold, now) columns := buildKanbanColumns( columnMap[categorization.ColumnOverdue], columnMap[categorization.ColumnInProgress], columnMap[categorization.ColumnDueSoon], columnMap[categorization.ColumnUpcoming], columnMap[categorization.ColumnCompleted], ) return &models.KanbanBoard{ Columns: columns, DaysThreshold: daysThreshold, ResidenceID: fmt.Sprintf("%d", residenceID), }, nil } // GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display. // Fetches all tasks in a single query, then categorizes them in-memory using the // task categorization chain for consistency with predicate-based logic. // The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. // // Optimization: Single query with preloads, then in-memory categorization. // 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) { // Fetch all tasks for these residences in a single query (excluding cancelled/archived) var allTasks []models.Task query := r.db.Model(&models.Task{}). Where("task_task.residence_id IN ?", residenceIDs). Preload("CreatedBy"). Preload("AssignedTo"). Preload("Residence"). Preload("Completions", func(db *gorm.DB) *gorm.DB { return db.Select("id", "task_id", "completed_at") }). Scopes(task.ScopeKanbanOrder) if err := query.Find(&allTasks).Error; err != nil { return nil, fmt.Errorf("get tasks for kanban: %w", err) } // Categorize all tasks in-memory using the categorization chain columnMap := categorization.CategorizeTasksIntoColumnsWithTime(allTasks, daysThreshold, now) columns := buildKanbanColumns( columnMap[categorization.ColumnOverdue], columnMap[categorization.ColumnInProgress], columnMap[categorization.ColumnDueSoon], columnMap[categorization.ColumnUpcoming], columnMap[categorization.ColumnCompleted], ) 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 } // CountByResidenceIDs counts all active tasks across multiple residences in a single query. // Returns the total count of non-cancelled, non-archived tasks for the given residence IDs. func (r *TaskRepository) CountByResidenceIDs(residenceIDs []uint) (int64, error) { if len(residenceIDs) == 0 { return 0, nil } var count int64 err := r.db.Model(&models.Task{}). Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, 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 } // UpdateCompletion updates an existing task completion func (r *TaskRepository) UpdateCompletion(completion *models.TaskCompletion) error { return r.db.Omit("Task", "CompletedBy", "Images").Save(completion).Error } // DeleteCompletion deletes a task completion and its associated images atomically. // Wrapped in a transaction so that if the completion delete fails, image // deletions are rolled back as well. func (r *TaskRepository) DeleteCompletion(id uint) error { return r.db.Transaction(func(tx *gorm.DB) error { // Delete images first if err := tx.Where("completion_id = ?", id).Delete(&models.TaskCompletionImage{}).Error; err != nil { log.Error().Err(err).Uint("completion_id", id).Msg("Failed to delete completion images") return err } return tx.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. // Excludes in-progress tasks to match what's displayed in the kanban overdue column. // 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), task.ScopeNotInProgress). 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 } // kanbanColumnColors maps kanban column names to their hex colors. var kanbanColumnColors = map[string]string{ "overdue_tasks": "#FF3B30", "in_progress_tasks": "#5856D6", "due_soon_tasks": "#FF9500", "upcoming_tasks": "#007AFF", "completed_tasks": "#34C759", "cancelled_tasks": "#8E8E93", } // KanbanColumnColor returns the hex color for a kanban column name. func KanbanColumnColor(column string) string { if color, ok := kanbanColumnColors[column]; ok { return color } return "#34C759" // default to green } // completionAggRow is an internal type for scanning aggregated completion data. type completionAggRow struct { ResidenceID uint CompletedFromColumn string CompletedMonth string Count int64 } // GetCompletionSummary returns completion summary data for a single residence. // Returns total all-time count and monthly breakdowns (by column) for the last 12 months. func (r *TaskRepository) GetCompletionSummary(residenceID uint, now time.Time, maxPerMonth int) (*responses.CompletionSummary, error) { // 1. Total all-time completions for this residence var totalAllTime int64 err := r.db.Model(&models.TaskCompletion{}). Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id"). Where("task_task.residence_id = ?", residenceID). Count(&totalAllTime).Error if err != nil { return nil, err } // 2. Monthly breakdown for last 12 months startDate := time.Date(now.Year()-1, now.Month(), 1, 0, 0, 0, 0, now.Location()) // Use dialect-appropriate date formatting (PostgreSQL vs SQLite) dateExpr := "TO_CHAR(task_taskcompletion.completed_at, 'YYYY-MM')" if r.db.Dialector.Name() == "sqlite" { dateExpr = "strftime('%Y-%m', task_taskcompletion.completed_at)" } var rows []completionAggRow err = r.db.Model(&models.TaskCompletion{}). Select(fmt.Sprintf("task_task.residence_id, task_taskcompletion.completed_from_column, %s as completed_month, COUNT(*) as count", dateExpr)). Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id"). Where("task_task.residence_id = ? AND task_taskcompletion.completed_at >= ?", residenceID, startDate). Group(fmt.Sprintf("task_task.residence_id, task_taskcompletion.completed_from_column, %s", dateExpr)). Order("completed_month ASC"). Scan(&rows).Error if err != nil { return nil, err } // Build month map type monthData struct { columns map[string]int total int } monthMap := make(map[string]*monthData) // Initialize all 12 months for i := 0; i < 12; i++ { m := startDate.AddDate(0, i, 0) key := m.Format("2006-01") monthMap[key] = &monthData{columns: make(map[string]int)} } // Populate from query results totalLast12 := 0 for _, row := range rows { md, ok := monthMap[row.CompletedMonth] if !ok { continue } md.columns[row.CompletedFromColumn] = int(row.Count) md.total += int(row.Count) totalLast12 += int(row.Count) } // Convert to response DTOs months := make([]responses.MonthlyCompletionSummary, 0, 12) for i := 0; i < 12; i++ { m := startDate.AddDate(0, i, 0) key := m.Format("2006-01") md := monthMap[key] completions := make([]responses.ColumnCompletionCount, 0) for col, count := range md.columns { completions = append(completions, responses.ColumnCompletionCount{ Column: col, Color: KanbanColumnColor(col), Count: count, }) } overflow := 0 if md.total > maxPerMonth { overflow = md.total - maxPerMonth } months = append(months, responses.MonthlyCompletionSummary{ Month: key, Completions: completions, Total: md.total, Overflow: overflow, }) } return &responses.CompletionSummary{ TotalAllTime: int(totalAllTime), TotalLast12Months: totalLast12, Months: months, }, nil } // GetBatchCompletionSummaries returns completion summaries for multiple residences // in two queries total (one for all-time counts, one for monthly breakdowns), // instead of 2*N queries when calling GetCompletionSummary per residence. func (r *TaskRepository) GetBatchCompletionSummaries(residenceIDs []uint, now time.Time, maxPerMonth int) (map[uint]*responses.CompletionSummary, error) { result := make(map[uint]*responses.CompletionSummary, len(residenceIDs)) if len(residenceIDs) == 0 { return result, nil } // 1. Total all-time completions per residence (single query) type allTimeRow struct { ResidenceID uint Count int64 } var allTimeRows []allTimeRow err := r.db.Model(&models.TaskCompletion{}). Select("task_task.residence_id, COUNT(*) as count"). Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id"). Where("task_task.residence_id IN ?", residenceIDs). Group("task_task.residence_id"). Scan(&allTimeRows).Error if err != nil { return nil, err } allTimeMap := make(map[uint]int64, len(allTimeRows)) for _, row := range allTimeRows { allTimeMap[row.ResidenceID] = row.Count } // 2. Monthly breakdown for last 12 months across all residences (single query) startDate := time.Date(now.Year()-1, now.Month(), 1, 0, 0, 0, 0, now.Location()) dateExpr := "TO_CHAR(task_taskcompletion.completed_at, 'YYYY-MM')" if r.db.Dialector.Name() == "sqlite" { dateExpr = "strftime('%Y-%m', task_taskcompletion.completed_at)" } var rows []completionAggRow err = r.db.Model(&models.TaskCompletion{}). Select(fmt.Sprintf("task_task.residence_id, task_taskcompletion.completed_from_column, %s as completed_month, COUNT(*) as count", dateExpr)). Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id"). Where("task_task.residence_id IN ? AND task_taskcompletion.completed_at >= ?", residenceIDs, startDate). Group(fmt.Sprintf("task_task.residence_id, task_taskcompletion.completed_from_column, %s", dateExpr)). Order("completed_month ASC"). Scan(&rows).Error if err != nil { return nil, err } // 3. Build per-residence summaries type monthData struct { columns map[string]int total int } // Initialize all residences with empty month maps residenceMonths := make(map[uint]map[string]*monthData, len(residenceIDs)) for _, rid := range residenceIDs { mm := make(map[string]*monthData, 12) for i := 0; i < 12; i++ { m := startDate.AddDate(0, i, 0) key := m.Format("2006-01") mm[key] = &monthData{columns: make(map[string]int)} } residenceMonths[rid] = mm } // Populate from query results residenceLast12 := make(map[uint]int, len(residenceIDs)) for _, row := range rows { mm, ok := residenceMonths[row.ResidenceID] if !ok { continue } md, ok := mm[row.CompletedMonth] if !ok { continue } md.columns[row.CompletedFromColumn] = int(row.Count) md.total += int(row.Count) residenceLast12[row.ResidenceID] += int(row.Count) } // Convert to response DTOs per residence for _, rid := range residenceIDs { mm := residenceMonths[rid] months := make([]responses.MonthlyCompletionSummary, 0, 12) for i := 0; i < 12; i++ { m := startDate.AddDate(0, i, 0) key := m.Format("2006-01") md := mm[key] completions := make([]responses.ColumnCompletionCount, 0) for col, count := range md.columns { completions = append(completions, responses.ColumnCompletionCount{ Column: col, Color: KanbanColumnColor(col), Count: count, }) } overflow := 0 if md.total > maxPerMonth { overflow = md.total - maxPerMonth } months = append(months, responses.MonthlyCompletionSummary{ Month: key, Completions: completions, Total: md.total, Overflow: overflow, }) } result[rid] = &responses.CompletionSummary{ TotalAllTime: int(allTimeMap[rid]), TotalLast12Months: residenceLast12[rid], Months: months, } } return result, nil }