// Package scopes provides GORM scope functions that mirror the predicates. // These scopes allow efficient database-level filtering using the same logic // as the predicates in ../predicates/predicates.go. // // IMPORTANT: These scopes must produce the same results as their predicate counterparts. // Any change to a predicate MUST be reflected in the corresponding scope. // Tests verify consistency between predicates and scopes. // // Each scope includes a comment referencing its predicate counterpart for easy cross-reference. package scopes import ( "time" "gorm.io/gorm" ) // ============================================================================= // STATE SCOPES // ============================================================================= // ScopeActive filters to tasks that are not cancelled and not archived. // Active tasks are eligible for display in the kanban board. // // Predicate equivalent: IsActive(task) // // SQL: is_cancelled = false AND is_archived = false func ScopeActive(db *gorm.DB) *gorm.DB { return db.Where("is_cancelled = ? AND is_archived = ?", false, false) } // ScopeCancelled filters to cancelled tasks only. // // Predicate equivalent: IsCancelled(task) // // SQL: is_cancelled = true func ScopeCancelled(db *gorm.DB) *gorm.DB { return db.Where("is_cancelled = ?", true) } // ScopeArchived filters to archived tasks only. // // Predicate equivalent: IsArchived(task) // // SQL: is_archived = true func ScopeArchived(db *gorm.DB) *gorm.DB { return db.Where("is_archived = ?", true) } // ScopeCompleted filters to completed tasks. // // A task is completed when NextDueDate is nil AND it has at least one completion. // // Predicate equivalent: IsCompleted(task) // // SQL: next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id) func ScopeCompleted(db *gorm.DB) *gorm.DB { return db.Where( "next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)", ) } // ScopeNotCompleted excludes completed tasks. // // A task is NOT completed when it either has a NextDueDate OR has no completions. // // Predicate equivalent: !IsCompleted(task) // // SQL: NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)) func ScopeNotCompleted(db *gorm.DB) *gorm.DB { return db.Where( "NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))", ) } // ScopeInProgress filters to tasks with status "In Progress". // // Predicate equivalent: IsInProgress(task) // // SQL: Joins task_taskstatus and filters by name = 'In Progress' func ScopeInProgress(db *gorm.DB) *gorm.DB { return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id"). Where("task_taskstatus.name = ?", "In Progress") } // ScopeNotInProgress excludes tasks with status "In Progress". // // Predicate equivalent: !IsInProgress(task) func ScopeNotInProgress(db *gorm.DB) *gorm.DB { return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id"). Where("task_taskstatus.name != ? OR task_taskstatus.name IS NULL", "In Progress") } // ============================================================================= // DATE SCOPES // ============================================================================= // ScopeOverdue returns a scope for overdue tasks. // // A task is overdue when its effective date (COALESCE(next_due_date, due_date)) // is before the given time, and it's active and not completed. // // Predicate equivalent: IsOverdue(task, now) // // SQL: COALESCE(next_due_date, due_date) < ?::timestamp AND active AND not_completed // // NOTE: We explicitly cast to timestamp because PostgreSQL DATE columns compared // against string literals (which is how GORM passes time.Time) use date comparison, // not timestamp comparison. For example: // - '2025-12-07'::date < '2025-12-07 17:00:00' = false (compares dates only) // - '2025-12-07'::date < '2025-12-07 17:00:00'::timestamp = true (compares timestamp) func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db.Scopes(ScopeActive, ScopeNotCompleted). Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", now) } } // ScopeDueSoon returns a scope for tasks due within the threshold. // // A task is "due soon" when its effective date is >= now AND < (now + threshold), // and it's active and not completed. // // Predicate equivalent: IsDueSoon(task, now, daysThreshold) // // SQL: COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp // // AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp // AND active AND not_completed // // NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns. // See ScopeOverdue for detailed explanation. func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { threshold := now.AddDate(0, 0, daysThreshold) return db.Scopes(ScopeActive, ScopeNotCompleted). Where("COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp", now). Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", threshold) } } // ScopeUpcoming returns a scope for tasks due after the threshold or with no due date. // // A task is "upcoming" when its effective date is >= (now + threshold) OR is null, // and it's active and not completed. // // Predicate equivalent: IsUpcoming(task, now, daysThreshold) // // SQL: (COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp OR (next_due_date IS NULL AND due_date IS NULL)) // // AND active AND not_completed // // NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns. // See ScopeOverdue for detailed explanation. func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { threshold := now.AddDate(0, 0, daysThreshold) return db.Scopes(ScopeActive, ScopeNotCompleted). Where( "COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp OR (next_due_date IS NULL AND due_date IS NULL)", threshold, ) } } // ScopeDueInRange returns a scope for tasks with effective date in a range. // // SQL: COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp // // AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp // // NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns. // See ScopeOverdue for detailed explanation. func ScopeDueInRange(start, end time.Time) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db. Where("COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp", start). Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", end) } } // ScopeHasDueDate filters to tasks that have an effective due date. // // SQL: (next_due_date IS NOT NULL OR due_date IS NOT NULL) func ScopeHasDueDate(db *gorm.DB) *gorm.DB { return db.Where("next_due_date IS NOT NULL OR due_date IS NOT NULL") } // ScopeNoDueDate filters to tasks that have no effective due date. // // SQL: next_due_date IS NULL AND due_date IS NULL func ScopeNoDueDate(db *gorm.DB) *gorm.DB { return db.Where("next_due_date IS NULL AND due_date IS NULL") } // ============================================================================= // FILTER SCOPES // ============================================================================= // ScopeForResidence filters tasks by a single residence ID. // // SQL: residence_id = ? func ScopeForResidence(residenceID uint) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db.Where("residence_id = ?", residenceID) } } // ScopeForResidences filters tasks by multiple residence IDs. // // SQL: residence_id IN (?) func ScopeForResidences(residenceIDs []uint) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { if len(residenceIDs) == 0 { // Return empty result if no residence IDs provided return db.Where("1 = 0") } return db.Where("residence_id IN ?", residenceIDs) } } // ScopeHasCompletions filters to tasks that have at least one completion. // // Predicate equivalent: HasCompletions(task) // // SQL: EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id) func ScopeHasCompletions(db *gorm.DB) *gorm.DB { return db.Where( "EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)", ) } // ScopeNoCompletions filters to tasks that have no completions. // // Predicate equivalent: !HasCompletions(task) // // SQL: NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id) func ScopeNoCompletions(db *gorm.DB) *gorm.DB { return db.Where( "NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)", ) } // ============================================================================= // ORDERING // ============================================================================= // ScopeOrderByDueDate orders tasks by effective due date ascending, nulls last. // // SQL: ORDER BY COALESCE(next_due_date, due_date) ASC NULLS LAST func ScopeOrderByDueDate(db *gorm.DB) *gorm.DB { return db.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST") } // ScopeOrderByPriority orders tasks by priority level descending (urgent first). // // SQL: ORDER BY priority_id DESC func ScopeOrderByPriority(db *gorm.DB) *gorm.DB { return db.Order("priority_id DESC") } // ScopeOrderByCreatedAt orders tasks by creation date descending (newest first). // // SQL: ORDER BY created_at DESC func ScopeOrderByCreatedAt(db *gorm.DB) *gorm.DB { return db.Order("created_at DESC") } // ScopeKanbanOrder applies the standard kanban ordering. // // SQL: ORDER BY COALESCE(next_due_date, due_date) ASC NULLS LAST, priority_id DESC, created_at DESC func ScopeKanbanOrder(db *gorm.DB) *gorm.DB { return db.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST, priority_id DESC, created_at DESC") }