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

View File

@@ -8,6 +8,7 @@ import (
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task/scopes"
)
// AdminDashboardHandler handles admin dashboard endpoints
@@ -115,43 +116,41 @@ func (h *AdminDashboardHandler) GetStats(c *gin.Context) {
h.db.Model(&models.Residence{}).Where("is_active = ?", true).Count(&stats.Residences.Active)
h.db.Model(&models.Residence{}).Where("created_at >= ?", thirtyDaysAgo).Count(&stats.Residences.New30d)
// Task stats
// Task stats - uses scopes from internal/task/scopes for consistency
h.db.Model(&models.Task{}).Count(&stats.Tasks.Total)
h.db.Model(&models.Task{}).Where("is_cancelled = ? AND is_archived = ?", false, false).Count(&stats.Tasks.Active)
h.db.Model(&models.Task{}).Where("is_archived = ?", true).Count(&stats.Tasks.Archived)
h.db.Model(&models.Task{}).Where("is_cancelled = ?", true).Count(&stats.Tasks.Cancelled)
h.db.Model(&models.Task{}).Scopes(scopes.ScopeActive).Count(&stats.Tasks.Active)
h.db.Model(&models.Task{}).Scopes(scopes.ScopeArchived).Count(&stats.Tasks.Archived)
h.db.Model(&models.Task{}).Scopes(scopes.ScopeCancelled).Count(&stats.Tasks.Cancelled)
h.db.Model(&models.Task{}).Where("created_at >= ?", thirtyDaysAgo).Count(&stats.Tasks.New30d)
// Task counts by status (using LEFT JOIN to handle tasks with no status)
// Note: These status counts use DB status names, not kanban categorization
h.db.Model(&models.Task{}).
Where("is_cancelled = ? AND is_archived = ?", false, false).
Scopes(scopes.ScopeActive).
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("LOWER(task_taskstatus.name) = ? OR task_taskstatus.id IS NULL", "pending").
Count(&stats.Tasks.Pending)
h.db.Model(&models.Task{}).
Where("is_cancelled = ? AND is_archived = ?", false, false).
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("LOWER(task_taskstatus.name) = ?", "in progress").
Scopes(scopes.ScopeActive, scopes.ScopeInProgress).
Count(&stats.Tasks.InProgress)
// Completed count: uses kanban completion logic (NextDueDate == nil AND has completions)
// See internal/task/predicates.IsCompleted for the definition
h.db.Model(&models.Task{}).
Where("is_cancelled = ? AND is_archived = ?", false, false).
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("LOWER(task_taskstatus.name) = ?", "completed").
Scopes(scopes.ScopeActive, scopes.ScopeCompleted).
Count(&stats.Tasks.Completed)
h.db.Model(&models.Task{}).
Where("is_cancelled = ? AND is_archived = ?", false, false).
Scopes(scopes.ScopeActive).
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("LOWER(task_taskstatus.name) = ?", "on hold").
Count(&stats.Tasks.OnHold)
// Overdue: past due date, not completed, not cancelled, not archived
// Overdue: uses consistent logic from internal/task/scopes.ScopeOverdue
// Effective date (COALESCE(next_due_date, due_date)) < now, active, not completed
h.db.Model(&models.Task{}).
Where("next_due_date < ? AND is_cancelled = ? AND is_archived = ?", now, false, false).
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
Where("LOWER(task_taskstatus.name) NOT IN ? OR task_taskstatus.id IS NULL", []string{"completed", "cancelled"}).
Scopes(scopes.ScopeOverdue(now)).
Count(&stats.Tasks.Overdue)
// Contractor stats