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

@@ -0,0 +1,369 @@
# Task Logic Architecture
This document describes the consolidated task logic architecture following DRY principles. All task-related logic (completion detection, overdue calculation, kanban categorization, statistics counting) is centralized in the `internal/task/` package.
## Architecture Overview
```
internal/task/
├── predicates/
│ └── predicates.go # SINGLE SOURCE OF TRUTH - Pure Go predicate functions
├── scopes/
│ └── scopes.go # SQL mirrors of predicates for GORM queries
├── categorization/
│ └── chain.go # Chain of Responsibility for kanban column determination
└── task.go # Re-exports for convenient single import
```
## Package Responsibilities
### predicates/ - Single Source of Truth
Pure Go functions that define task logic. These are the **canonical definitions** for all task states.
```go
import "github.com/treytartt/casera-api/internal/task/predicates"
// State checks
predicates.IsCompleted(task) // NextDueDate == nil && len(Completions) > 0
predicates.IsActive(task) // !IsCancelled && !IsArchived
predicates.IsCancelled(task) // IsCancelled == true
predicates.IsArchived(task) // IsArchived == true
predicates.IsInProgress(task) // Status.Name == "In Progress"
// Date calculations
predicates.EffectiveDate(task) // NextDueDate ?? DueDate
predicates.IsOverdue(task, now) // Active, not completed, effectiveDate < now
predicates.IsDueSoon(task, now, days) // Active, not completed, now <= effectiveDate < threshold
predicates.IsUpcoming(task, now, days) // Everything else
```
### scopes/ - SQL Mirrors for Database Queries
GORM scope functions that produce the same results as predicates, but execute at the database level. Use these when counting or filtering large datasets without loading all records into memory.
```go
import "github.com/treytartt/casera-api/internal/task/scopes"
// State scopes
db.Scopes(scopes.ScopeActive) // is_cancelled = false AND is_archived = false
db.Scopes(scopes.ScopeCompleted) // next_due_date IS NULL AND EXISTS(completion)
db.Scopes(scopes.ScopeNotCompleted) // NOT (next_due_date IS NULL AND EXISTS(completion))
db.Scopes(scopes.ScopeInProgress) // JOIN status WHERE name = 'In Progress'
// Date scopes (require time parameter)
db.Scopes(scopes.ScopeOverdue(now)) // COALESCE(next_due_date, due_date) < now
db.Scopes(scopes.ScopeDueSoon(now, 30)) // >= now AND < threshold
db.Scopes(scopes.ScopeUpcoming(now, 30)) // >= threshold OR no due date
// Filter scopes
db.Scopes(scopes.ScopeForResidence(id)) // residence_id = ?
db.Scopes(scopes.ScopeForResidences(ids)) // residence_id IN (?)
// Ordering
db.Scopes(scopes.ScopeKanbanOrder) // Due date ASC, priority DESC, created DESC
```
### categorization/ - Chain of Responsibility Pattern
Determines which kanban column a task belongs to. Uses predicates internally.
```go
import "github.com/treytartt/casera-api/internal/task/categorization"
// Single task
column := categorization.CategorizeTask(task, 30)
columnStr := categorization.DetermineKanbanColumn(task, 30)
// Multiple tasks
columns := categorization.CategorizeTasksIntoColumns(tasks, 30)
// Returns map[KanbanColumn][]Task
```
### task.go - Convenient Re-exports
For most use cases, import the main task package which re-exports everything:
```go
import "github.com/treytartt/casera-api/internal/task"
// Use predicates
if task.IsCompleted(t) { ... }
// Use scopes
db.Scopes(task.ScopeOverdue(now)).Count(&count)
// Use categorization
column := task.CategorizeTask(t, 30)
```
## Canonical Rules (Single Source of Truth)
These rules are defined in `predicates/predicates.go` and enforced everywhere:
| Concept | Definition | SQL Equivalent |
|---------|------------|----------------|
| **Completed** | `NextDueDate == nil && len(Completions) > 0` | `next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion WHERE task_id = ?)` |
| **Active** | `!IsCancelled && !IsArchived` | `is_cancelled = false AND is_archived = false` |
| **In Progress** | `Status.Name == "In Progress"` | `JOIN task_taskstatus WHERE name = 'In Progress'` |
| **Effective Date** | `NextDueDate ?? DueDate` | `COALESCE(next_due_date, due_date)` |
| **Overdue** | `Active && !Completed && EffectiveDate < now` | Active + NotCompleted + `COALESCE(...) < ?` |
| **Due Soon** | `Active && !Completed && now <= EffectiveDate < threshold` | Active + NotCompleted + `COALESCE(...) >= ? AND COALESCE(...) < ?` |
| **Upcoming** | Everything else (future or no due date) | Active + NotCompleted + `COALESCE(...) >= ? OR (both NULL)` |
## Kanban Column Priority Order
When categorizing a task, the chain evaluates in this priority order:
1. **Cancelled** (highest) - `IsCancelled == true`
2. **Completed** - `NextDueDate == nil && len(Completions) > 0`
3. **In Progress** - `Status.Name == "In Progress"`
4. **Overdue** - `EffectiveDate < now`
5. **Due Soon** - `now <= EffectiveDate < threshold`
6. **Upcoming** (lowest/default) - Everything else
## Usage Examples
### Counting Overdue Tasks (Efficient)
```go
// Use scopes for database-level counting
var count int64
db.Model(&models.Task{}).
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeOverdue(now)).
Count(&count)
```
### Building a Kanban Board
```go
// Load tasks with preloads
var tasks []models.Task
db.Preload("Status").Preload("Completions").
Scopes(task.ScopeForResidence(residenceID)).
Find(&tasks)
// Categorize in memory using predicates
columns := task.CategorizeTasksIntoColumns(tasks, 30)
```
### Checking Task State in Business Logic
```go
// Use predicates for in-memory checks
if task.IsCompleted(t) {
return []string{} // No actions for completed tasks
}
if task.IsOverdue(t, time.Now().UTC()) {
return []string{"edit", "complete", "cancel", "mark_in_progress"}
}
```
### Button Types for a Task
```go
func GetButtonTypesForTask(t *models.Task, daysThreshold int) []string {
now := time.Now().UTC()
if predicates.IsCancelled(t) {
return []string{"uncancel", "delete"}
}
if predicates.IsCompleted(t) {
return []string{} // read-only
}
if predicates.IsInProgress(t) {
return []string{"edit", "complete", "cancel"}
}
// Overdue, Due Soon, Upcoming all get the same buttons
return []string{"edit", "complete", "cancel", "mark_in_progress"}
}
```
## Why Two Layers (Predicates + Scopes)?
| Layer | Use Case | Performance |
|-------|----------|-------------|
| **Predicates** | In-memory checks on loaded objects | Fast for small sets, already loaded data |
| **Scopes** | Database-level filtering/counting | Efficient for large datasets, avoids loading all records |
**Example**: Counting overdue tasks across 1000 residences:
- Predicates: Load all tasks into memory, filter in Go
- Scopes: Execute `SELECT COUNT(*) WHERE ...` in database
## Consistency Guarantees
1. **Scopes mirror predicates** - Each scope produces the same logical result as its predicate counterpart
2. **Tests verify consistency** - Unit tests ensure predicates and scopes produce identical results
3. **Cross-reference comments** - Each scope has a comment linking to its predicate equivalent
4. **Single import point** - The `task` package re-exports everything, making it easy to use consistently
## Files That Were Refactored
The following files were updated to use the consolidated task logic:
| File | Changes |
|------|---------|
| `internal/repositories/task_repo.go` | Removed duplicate `isTaskCompleted()`, uses categorization and scopes |
| `internal/services/task_button_types.go` | Uses predicates instead of inline logic |
| `internal/dto/responses/task.go` | `DetermineKanbanColumn` delegates to categorization package |
| `internal/worker/jobs/handler.go` | SQL queries documented with predicate references |
| `internal/admin/handlers/dashboard_handler.go` | Uses scopes for task statistics |
| `internal/services/residence_service.go` | Uses predicates for report generation |
| `internal/models/task.go` | `IsOverdue()` and `IsDueSoon()` fixed to use EffectiveDate |
## Migration Notes
### Old Pattern (Avoid)
```go
// DON'T: Inline logic that may drift
if task.NextDueDate == nil && len(task.Completions) > 0 {
// completed...
}
```
### New Pattern (Preferred)
```go
// DO: Use predicates
if predicates.IsCompleted(task) {
// completed...
}
// Or via the task package
if task.IsCompleted(t) {
// completed...
}
```
## Developer Checklist: Adding Task-Related Features
**BEFORE writing any task-related code, ask yourself:**
### 1. Does this logic already exist?
Check `internal/task/predicates/predicates.go` for:
- [ ] State checks: `IsCompleted`, `IsActive`, `IsCancelled`, `IsArchived`, `IsInProgress`
- [ ] Date logic: `EffectiveDate`, `IsOverdue`, `IsDueSoon`, `IsUpcoming`
- [ ] Completion helpers: `HasCompletions`, `CompletionCount`, `IsRecurring`
### 2. Am I querying tasks from the database?
Use scopes from `internal/task/scopes/scopes.go`:
- [ ] Filtering by state: `ScopeActive`, `ScopeCompleted`, `ScopeNotCompleted`
- [ ] Filtering by date: `ScopeOverdue(now)`, `ScopeDueSoon(now, days)`, `ScopeUpcoming(now, days)`
- [ ] Filtering by residence: `ScopeForResidence(id)`, `ScopeForResidences(ids)`
- [ ] Ordering: `ScopeKanbanOrder`
### 3. Am I categorizing tasks into kanban columns?
Use `internal/task/categorization`:
- [ ] Single task: `categorization.CategorizeTask(task, daysThreshold)`
- [ ] Multiple tasks: `categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)`
### 4. Am I writing inline task logic?
**STOP!** If you're writing any of these patterns inline, use the existing functions instead:
```go
// BAD: Inline completion check
if task.NextDueDate == nil && len(task.Completions) > 0 { ... }
// GOOD: Use predicate
if predicates.IsCompleted(task) { ... }
// BAD: Inline SQL for overdue
db.Where("due_date < ?", now)
// GOOD: Use scope
db.Scopes(scopes.ScopeOverdue(now))
// BAD: Manual kanban categorization
if task.IsCancelled { return "cancelled" }
else if ... { return "completed" }
// GOOD: Use categorization chain
column := categorization.CategorizeTask(task, 30)
```
### 5. Am I adding a NEW task state or concept?
If the existing predicates don't cover your use case:
1. **Add the predicate first** in `predicates/predicates.go`
2. **Add the corresponding scope** in `scopes/scopes.go` (if DB queries needed)
3. **Update the categorization chain** if it affects kanban columns
4. **Add tests** for both predicate and scope
5. **Update this documentation**
## Common Pitfalls
### PostgreSQL DATE vs TIMESTAMP Comparison
When comparing DATE columns with time parameters, GORM passes `time.Time` as a string. PostgreSQL then compares dates, not timestamps:
```sql
-- This compares DATES (wrong for time-of-day precision):
'2025-12-07'::date < '2025-12-07 17:00:00' -- FALSE!
-- This compares TIMESTAMPS (correct):
'2025-12-07'::date < '2025-12-07 17:00:00'::timestamp -- TRUE
```
**Solution**: All date scopes use explicit `::timestamp` casts:
```go
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", now)
```
### Forgetting to Preload Completions
The `IsCompleted` predicate checks `len(task.Completions) > 0`. If you query tasks without preloading completions, this will always return false:
```go
// BAD: Completions not loaded
db.Find(&tasks)
predicates.IsCompleted(task) // Always false!
// GOOD: Preload completions
db.Preload("Completions").Find(&tasks)
predicates.IsCompleted(task) // Correct result
```
### Forgetting to Preload Status
The `IsInProgress` predicate checks `task.Status.Name == "In Progress"`. Without preloading:
```go
// BAD: Status not loaded
db.Find(&tasks)
predicates.IsInProgress(task) // Nil pointer or always false
// GOOD: Preload status
db.Preload("Status").Find(&tasks)
predicates.IsInProgress(task) // Correct result
```
## Quick Reference Import
For most files, use the convenience re-exports:
```go
import "github.com/treytartt/casera-api/internal/task"
// Then use:
task.IsCompleted(t)
task.ScopeOverdue(now)
task.CategorizeTask(t, 30)
```
For files that only need predicates or only need scopes:
```go
import "github.com/treytartt/casera-api/internal/task/predicates"
import "github.com/treytartt/casera-api/internal/task/scopes"
```
## Related Documentation
- `docs/TASK_KANBAN_CATEGORIZATION.md` - Detailed kanban column logic
- `docs/TASK_KANBAN_LOGIC.md` - Original kanban implementation notes