package repositories import ( "errors" "fmt" "time" "github.com/rs/zerolog/log" "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" ) // 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 } // 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("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions"). Updates(map[string]interface{}{ "title": task.Title, "description": task.Description, "category_id": task.CategoryID, "priority_id": task.PriorityID, "frequency_id": task.FrequencyID, "custom_interval_days": task.CustomIntervalDays, "in_progress": task.InProgress, "assigned_to_id": task.AssignedToID, "due_date": task.DueDate, "next_due_date": task.NextDueDate, "estimated_cost": task.EstimatedCost, "actual_cost": task.ActualCost, "contractor_id": task.ContractorID, "is_cancelled": task.IsCancelled, "is_archived": task.IsArchived, "version": gorm.Expr("version + 1"), }) 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 } // 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("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions"). Updates(map[string]interface{}{ "title": task.Title, "description": task.Description, "category_id": task.CategoryID, "priority_id": task.PriorityID, "frequency_id": task.FrequencyID, "custom_interval_days": task.CustomIntervalDays, "in_progress": task.InProgress, "assigned_to_id": task.AssignedToID, "due_date": task.DueDate, "next_due_date": task.NextDueDate, "estimated_cost": task.EstimatedCost, "actual_cost": task.ActualCost, "contractor_id": task.ContractorID, "is_cancelled": task.IsCancelled, "is_archived": task.IsArchived, "version": gorm.Expr("version + 1"), }) 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 func (r *TaskRepository) DeleteCompletion(id uint) error { // Delete images first if err := r.db.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 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. // 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 }