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:
259
internal/task/task.go
Normal file
259
internal/task/task.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user