Total rebrand across all Go API source files: - Go module path: casera-api -> honeydue-api - All imports updated (130+ files) - Docker: containers, images, networks renamed - Email templates: support email, noreply, icon URL - Domains: casera.app/mycrib.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - IAP product IDs updated - Landing page, admin panel, config defaults - Seeds, CI workflows, Makefile, docs - Database table names preserved (no migration needed) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
366 lines
12 KiB
Markdown
366 lines
12 KiB
Markdown
# 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/honeydue-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) // InProgress == true
|
|
|
|
// 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/honeydue-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/honeydue-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/honeydue-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** | `InProgress == true` | `in_progress = true` |
|
|
| **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** - `InProgress == true`
|
|
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("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
|
|
```
|
|
|
|
### In Progress Boolean Field
|
|
|
|
The `IsInProgress` predicate now uses a simple boolean field instead of a Status relation:
|
|
|
|
```go
|
|
// IsInProgress uses the in_progress boolean field directly
|
|
// No preloading required for this check
|
|
predicates.IsInProgress(task) // Checks task.InProgress boolean
|
|
```
|
|
|
|
## Quick Reference Import
|
|
|
|
For most files, use the convenience re-exports:
|
|
|
|
```go
|
|
import "github.com/treytartt/honeydue-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/honeydue-api/internal/task/predicates"
|
|
import "github.com/treytartt/honeydue-api/internal/task/scopes"
|
|
```
|
|
|
|
## Related Documentation
|
|
|
|
- `docs/TASK_KANBAN_CATEGORIZATION.md` - Detailed kanban column logic
|
|
- `docs/TASK_KANBAN_LOGIC.md` - Original kanban implementation notes
|