Consolidate task logic into single source of truth (DRY refactor)
This refactor eliminates duplicate task logic across the codebase by creating a centralized task package with three layers: - predicates/: Pure Go functions defining task state logic (IsCompleted, IsOverdue, IsDueSoon, IsUpcoming, IsActive, IsInProgress, EffectiveDate) - scopes/: GORM scope functions mirroring predicates for database queries - categorization/: Chain of Responsibility pattern for kanban column assignment Key fixes: - Fixed PostgreSQL DATE vs TIMESTAMP comparison bug in scopes (added explicit ::timestamp casts) that caused summary/kanban count mismatches - Fixed models/task.go IsOverdue() and IsDueSoon() to use EffectiveDate (NextDueDate ?? DueDate) instead of only DueDate - Removed duplicate isTaskCompleted() helpers from task_repo.go and task_button_types.go Files refactored to use consolidated logic: - task_repo.go: Uses scopes for statistics, predicates for filtering - task_button_types.go: Uses predicates instead of inline logic - responses/task.go: Delegates to categorization package - dashboard_handler.go: Uses scopes for task statistics - residence_service.go: Uses predicates for report generation - worker/jobs/handler.go: Documented SQL with predicate references Added comprehensive tests: - predicates_test.go: Unit tests for all predicate functions - scopes_test.go: Integration tests verifying scopes match predicates - consistency_test.go: Three-layer consistency tests ensuring predicates, scopes, and categorization all return identical results 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
274
internal/task/scopes/scopes.go
Normal file
274
internal/task/scopes/scopes.go
Normal file
@@ -0,0 +1,274 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in New Issue
Block a user