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:
Trey t
2025-12-07 11:48:03 -06:00
parent f0c7b070d7
commit cfb8a28870
16 changed files with 3408 additions and 679 deletions

259
internal/task/task.go Normal file
View File

@@ -0,0 +1,259 @@
// Package task provides consolidated task domain logic.
//
// This package serves as the single entry point for all task-related business logic.
// It re-exports functions from sub-packages for convenient imports.
//
// Architecture:
//
// predicates/ - Pure Go predicate functions (SINGLE SOURCE OF TRUTH)
// scopes/ - GORM scope functions (SQL mirrors of predicates)
// categorization/ - Chain of Responsibility for kanban categorization
//
// Usage:
//
// import "github.com/treytartt/casera-api/internal/task"
//
// // Use predicates for in-memory checks
// if task.IsCompleted(myTask) { ... }
//
// // Use scopes for database queries
// db.Scopes(task.ScopeOverdue(now)).Find(&tasks)
//
// // Use categorization for kanban column determination
// column := task.CategorizeTask(myTask, 30)
//
// For more details, see docs/TASK_LOGIC_ARCHITECTURE.md
package task
import (
"time"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/categorization"
"github.com/treytartt/casera-api/internal/task/predicates"
"github.com/treytartt/casera-api/internal/task/scopes"
"gorm.io/gorm"
)
// =============================================================================
// RE-EXPORTED TYPES
// =============================================================================
// KanbanColumn represents the possible kanban column names
type KanbanColumn = categorization.KanbanColumn
// Column constants
const (
ColumnOverdue = categorization.ColumnOverdue
ColumnDueSoon = categorization.ColumnDueSoon
ColumnUpcoming = categorization.ColumnUpcoming
ColumnInProgress = categorization.ColumnInProgress
ColumnCompleted = categorization.ColumnCompleted
ColumnCancelled = categorization.ColumnCancelled
)
// =============================================================================
// RE-EXPORTED PREDICATES
// These are the SINGLE SOURCE OF TRUTH for task logic
// =============================================================================
// IsCompleted returns true if a task is considered "completed" per kanban rules.
// A task is completed when NextDueDate is nil AND has at least one completion.
func IsCompleted(task *models.Task) bool {
return predicates.IsCompleted(task)
}
// IsActive returns true if the task is not cancelled and not archived.
func IsActive(task *models.Task) bool {
return predicates.IsActive(task)
}
// IsCancelled returns true if the task has been cancelled.
func IsCancelled(task *models.Task) bool {
return predicates.IsCancelled(task)
}
// IsArchived returns true if the task has been archived.
func IsArchived(task *models.Task) bool {
return predicates.IsArchived(task)
}
// IsInProgress returns true if the task has status "In Progress".
func IsInProgress(task *models.Task) bool {
return predicates.IsInProgress(task)
}
// EffectiveDate returns the date used for scheduling calculations.
// Prefers NextDueDate, falls back to DueDate.
func EffectiveDate(task *models.Task) *time.Time {
return predicates.EffectiveDate(task)
}
// IsOverdue returns true if the task's effective date is in the past.
func IsOverdue(task *models.Task, now time.Time) bool {
return predicates.IsOverdue(task, now)
}
// IsDueSoon returns true if the task's effective date is within the threshold.
func IsDueSoon(task *models.Task, now time.Time, daysThreshold int) bool {
return predicates.IsDueSoon(task, now, daysThreshold)
}
// IsUpcoming returns true if the task is due after the threshold or has no due date.
func IsUpcoming(task *models.Task, now time.Time, daysThreshold int) bool {
return predicates.IsUpcoming(task, now, daysThreshold)
}
// HasCompletions returns true if the task has at least one completion record.
func HasCompletions(task *models.Task) bool {
return predicates.HasCompletions(task)
}
// CompletionCount returns the number of completions for a task.
func CompletionCount(task *models.Task) int {
return predicates.CompletionCount(task)
}
// IsRecurring returns true if the task has a recurring frequency.
func IsRecurring(task *models.Task) bool {
return predicates.IsRecurring(task)
}
// IsOneTime returns true if the task is a one-time (non-recurring) task.
func IsOneTime(task *models.Task) bool {
return predicates.IsOneTime(task)
}
// =============================================================================
// RE-EXPORTED SCOPES
// These are SQL mirrors of the predicates for database queries
// =============================================================================
// ScopeActive filters to tasks that are not cancelled and not archived.
func ScopeActive(db *gorm.DB) *gorm.DB {
return scopes.ScopeActive(db)
}
// ScopeCancelled filters to cancelled tasks only.
func ScopeCancelled(db *gorm.DB) *gorm.DB {
return scopes.ScopeCancelled(db)
}
// ScopeArchived filters to archived tasks only.
func ScopeArchived(db *gorm.DB) *gorm.DB {
return scopes.ScopeArchived(db)
}
// ScopeCompleted filters to completed tasks.
func ScopeCompleted(db *gorm.DB) *gorm.DB {
return scopes.ScopeCompleted(db)
}
// ScopeNotCompleted excludes completed tasks.
func ScopeNotCompleted(db *gorm.DB) *gorm.DB {
return scopes.ScopeNotCompleted(db)
}
// ScopeInProgress filters to tasks with status "In Progress".
func ScopeInProgress(db *gorm.DB) *gorm.DB {
return scopes.ScopeInProgress(db)
}
// ScopeNotInProgress excludes tasks with status "In Progress".
func ScopeNotInProgress(db *gorm.DB) *gorm.DB {
return scopes.ScopeNotInProgress(db)
}
// ScopeOverdue returns a scope for overdue tasks.
func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB {
return scopes.ScopeOverdue(now)
}
// ScopeDueSoon returns a scope for tasks due within the threshold.
func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
return scopes.ScopeDueSoon(now, daysThreshold)
}
// ScopeUpcoming returns a scope for tasks due after the threshold or with no due date.
func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
return scopes.ScopeUpcoming(now, daysThreshold)
}
// ScopeDueInRange returns a scope for tasks with effective date in a range.
func ScopeDueInRange(start, end time.Time) func(db *gorm.DB) *gorm.DB {
return scopes.ScopeDueInRange(start, end)
}
// ScopeHasDueDate filters to tasks that have an effective due date.
func ScopeHasDueDate(db *gorm.DB) *gorm.DB {
return scopes.ScopeHasDueDate(db)
}
// ScopeNoDueDate filters to tasks that have no effective due date.
func ScopeNoDueDate(db *gorm.DB) *gorm.DB {
return scopes.ScopeNoDueDate(db)
}
// ScopeForResidence filters tasks by a single residence ID.
func ScopeForResidence(residenceID uint) func(db *gorm.DB) *gorm.DB {
return scopes.ScopeForResidence(residenceID)
}
// ScopeForResidences filters tasks by multiple residence IDs.
func ScopeForResidences(residenceIDs []uint) func(db *gorm.DB) *gorm.DB {
return scopes.ScopeForResidences(residenceIDs)
}
// ScopeHasCompletions filters to tasks that have at least one completion.
func ScopeHasCompletions(db *gorm.DB) *gorm.DB {
return scopes.ScopeHasCompletions(db)
}
// ScopeNoCompletions filters to tasks that have no completions.
func ScopeNoCompletions(db *gorm.DB) *gorm.DB {
return scopes.ScopeNoCompletions(db)
}
// ScopeOrderByDueDate orders tasks by effective due date ascending, nulls last.
func ScopeOrderByDueDate(db *gorm.DB) *gorm.DB {
return scopes.ScopeOrderByDueDate(db)
}
// ScopeOrderByPriority orders tasks by priority level descending (urgent first).
func ScopeOrderByPriority(db *gorm.DB) *gorm.DB {
return scopes.ScopeOrderByPriority(db)
}
// ScopeOrderByCreatedAt orders tasks by creation date descending (newest first).
func ScopeOrderByCreatedAt(db *gorm.DB) *gorm.DB {
return scopes.ScopeOrderByCreatedAt(db)
}
// ScopeKanbanOrder applies the standard kanban ordering.
func ScopeKanbanOrder(db *gorm.DB) *gorm.DB {
return scopes.ScopeKanbanOrder(db)
}
// =============================================================================
// RE-EXPORTED CATEGORIZATION
// =============================================================================
// CategorizeTask determines which kanban column a task belongs to.
func CategorizeTask(task *models.Task, daysThreshold int) KanbanColumn {
return categorization.CategorizeTask(task, daysThreshold)
}
// DetermineKanbanColumn is a convenience function that returns the column as a string.
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
return categorization.DetermineKanbanColumn(task, daysThreshold)
}
// CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns.
func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[KanbanColumn][]models.Task {
return categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)
}
// NewChain creates a new categorization chain for custom usage.
func NewChain() *categorization.Chain {
return categorization.NewChain()
}