Major changes: - Migrate all handlers from Gin to Echo framework - Add new apperrors, echohelpers, and validator packages - Update middleware for Echo compatibility - Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column) - Add 6 new integration tests: - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks - MultiUserSharing: Complex sharing with user removal - TaskStateTransitions: All state transitions and kanban column changes - DateBoundaryEdgeCases: Threshold boundary testing - CascadeOperations: Residence deletion cascade effects - MultiUserOperations: Shared residence collaboration - Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.) - Fix RemoveUser route param mismatch (userId -> user_id) - Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
269 lines
9.4 KiB
Go
269 lines
9.4 KiB
Go
// Package scopes provides GORM scope functions that mirror the predicates.
|
|
// These scopes allow efficient database-level filtering using the same logic
|
|
// as the predicates in ../predicates/predicates.go.
|
|
//
|
|
// IMPORTANT: These scopes must produce the same results as their predicate counterparts.
|
|
// Any change to a predicate MUST be reflected in the corresponding scope.
|
|
// Tests verify consistency between predicates and scopes.
|
|
//
|
|
// Each scope includes a comment referencing its predicate counterpart for easy cross-reference.
|
|
package scopes
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// =============================================================================
|
|
// STATE SCOPES
|
|
// =============================================================================
|
|
|
|
// ScopeActive filters to tasks that are not cancelled and not archived.
|
|
// Active tasks are eligible for display in the kanban board.
|
|
//
|
|
// Predicate equivalent: IsActive(task)
|
|
//
|
|
// SQL: is_cancelled = false AND is_archived = false
|
|
func ScopeActive(db *gorm.DB) *gorm.DB {
|
|
return db.Where("is_cancelled = ? AND is_archived = ?", false, false)
|
|
}
|
|
|
|
// ScopeCancelled filters to cancelled tasks only.
|
|
//
|
|
// Predicate equivalent: IsCancelled(task)
|
|
//
|
|
// SQL: is_cancelled = true
|
|
func ScopeCancelled(db *gorm.DB) *gorm.DB {
|
|
return db.Where("is_cancelled = ?", true)
|
|
}
|
|
|
|
// ScopeArchived filters to archived tasks only.
|
|
//
|
|
// Predicate equivalent: IsArchived(task)
|
|
//
|
|
// SQL: is_archived = true
|
|
func ScopeArchived(db *gorm.DB) *gorm.DB {
|
|
return db.Where("is_archived = ?", true)
|
|
}
|
|
|
|
// ScopeCompleted filters to completed tasks.
|
|
//
|
|
// A task is completed when NextDueDate is nil AND it has at least one completion.
|
|
//
|
|
// Predicate equivalent: IsCompleted(task)
|
|
//
|
|
// SQL: next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)
|
|
func ScopeCompleted(db *gorm.DB) *gorm.DB {
|
|
return db.Where(
|
|
"next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)",
|
|
)
|
|
}
|
|
|
|
// ScopeNotCompleted excludes completed tasks.
|
|
//
|
|
// A task is NOT completed when it either has a NextDueDate OR has no completions.
|
|
//
|
|
// Predicate equivalent: !IsCompleted(task)
|
|
//
|
|
// SQL: NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
|
|
func ScopeNotCompleted(db *gorm.DB) *gorm.DB {
|
|
return db.Where(
|
|
"NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))",
|
|
)
|
|
}
|
|
|
|
// ScopeInProgress filters to tasks marked as in progress.
|
|
//
|
|
// Predicate equivalent: IsInProgress(task)
|
|
//
|
|
// SQL: in_progress = true
|
|
func ScopeInProgress(db *gorm.DB) *gorm.DB {
|
|
return db.Where("in_progress = ?", true)
|
|
}
|
|
|
|
// ScopeNotInProgress excludes tasks marked as in progress.
|
|
//
|
|
// Predicate equivalent: !IsInProgress(task)
|
|
//
|
|
// SQL: in_progress = false
|
|
func ScopeNotInProgress(db *gorm.DB) *gorm.DB {
|
|
return db.Where("in_progress = ?", false)
|
|
}
|
|
|
|
// =============================================================================
|
|
// DATE SCOPES
|
|
// =============================================================================
|
|
|
|
// ScopeOverdue returns a scope for overdue tasks.
|
|
//
|
|
// A task is overdue when its effective date (COALESCE(next_due_date, due_date))
|
|
// is before the start of the given day, and it's active and not completed.
|
|
//
|
|
// Note: A task due "today" is NOT overdue. It becomes overdue tomorrow.
|
|
//
|
|
// Predicate equivalent: IsOverdue(task, now)
|
|
//
|
|
// SQL: COALESCE(next_due_date, due_date) < ? AND active AND not_completed
|
|
func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB {
|
|
return func(db *gorm.DB) *gorm.DB {
|
|
// Compute start of day in Go for database-agnostic comparison
|
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
return db.Scopes(ScopeActive, ScopeNotCompleted).
|
|
Where("COALESCE(next_due_date, due_date) < ?", startOfDay)
|
|
}
|
|
}
|
|
|
|
// ScopeDueSoon returns a scope for tasks due within the threshold.
|
|
//
|
|
// A task is "due soon" when its effective date is >= start of today AND < start of (today + threshold),
|
|
// and it's active and not completed.
|
|
//
|
|
// Note: Uses day-level comparisons so tasks due "today" are included.
|
|
//
|
|
// Predicate equivalent: IsDueSoon(task, now, daysThreshold)
|
|
//
|
|
// SQL: COALESCE(next_due_date, due_date) >= ? AND COALESCE(next_due_date, due_date) < ?
|
|
//
|
|
// AND active AND not_completed
|
|
func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
|
|
return func(db *gorm.DB) *gorm.DB {
|
|
// Compute start of day and threshold in Go for database-agnostic comparison
|
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
threshold := startOfDay.AddDate(0, 0, daysThreshold)
|
|
return db.Scopes(ScopeActive, ScopeNotCompleted).
|
|
Where("COALESCE(next_due_date, due_date) >= ?", startOfDay).
|
|
Where("COALESCE(next_due_date, due_date) < ?", threshold)
|
|
}
|
|
}
|
|
|
|
// ScopeUpcoming returns a scope for tasks due after the threshold or with no due date.
|
|
//
|
|
// A task is "upcoming" when its effective date is >= start of (today + threshold) OR is null,
|
|
// and it's active and not completed.
|
|
//
|
|
// Note: Uses start of day for comparisons for consistency with other scopes.
|
|
//
|
|
// Predicate equivalent: IsUpcoming(task, now, daysThreshold)
|
|
//
|
|
// SQL: (COALESCE(next_due_date, due_date) >= ? OR (next_due_date IS NULL AND due_date IS NULL))
|
|
//
|
|
// AND active AND not_completed
|
|
func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
|
|
return func(db *gorm.DB) *gorm.DB {
|
|
// Compute threshold as start of day + N days in Go for database-agnostic comparison
|
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
threshold := startOfDay.AddDate(0, 0, daysThreshold)
|
|
return db.Scopes(ScopeActive, ScopeNotCompleted).
|
|
Where(
|
|
"COALESCE(next_due_date, due_date) >= ? OR (next_due_date IS NULL AND due_date IS NULL)",
|
|
threshold,
|
|
)
|
|
}
|
|
}
|
|
|
|
// ScopeDueInRange returns a scope for tasks with effective date in a range.
|
|
//
|
|
// SQL: COALESCE(next_due_date, due_date) >= ? AND COALESCE(next_due_date, due_date) < ?
|
|
func ScopeDueInRange(start, end time.Time) func(db *gorm.DB) *gorm.DB {
|
|
return func(db *gorm.DB) *gorm.DB {
|
|
return db.
|
|
Where("COALESCE(next_due_date, due_date) >= ?", start).
|
|
Where("COALESCE(next_due_date, due_date) < ?", end)
|
|
}
|
|
}
|
|
|
|
// ScopeHasDueDate filters to tasks that have an effective due date.
|
|
//
|
|
// SQL: (next_due_date IS NOT NULL OR due_date IS NOT NULL)
|
|
func ScopeHasDueDate(db *gorm.DB) *gorm.DB {
|
|
return db.Where("next_due_date IS NOT NULL OR due_date IS NOT NULL")
|
|
}
|
|
|
|
// ScopeNoDueDate filters to tasks that have no effective due date.
|
|
//
|
|
// SQL: next_due_date IS NULL AND due_date IS NULL
|
|
func ScopeNoDueDate(db *gorm.DB) *gorm.DB {
|
|
return db.Where("next_due_date IS NULL AND due_date IS NULL")
|
|
}
|
|
|
|
// =============================================================================
|
|
// FILTER SCOPES
|
|
// =============================================================================
|
|
|
|
// ScopeForResidence filters tasks by a single residence ID.
|
|
//
|
|
// SQL: residence_id = ?
|
|
func ScopeForResidence(residenceID uint) func(db *gorm.DB) *gorm.DB {
|
|
return func(db *gorm.DB) *gorm.DB {
|
|
return db.Where("residence_id = ?", residenceID)
|
|
}
|
|
}
|
|
|
|
// ScopeForResidences filters tasks by multiple residence IDs.
|
|
//
|
|
// SQL: residence_id IN (?)
|
|
func ScopeForResidences(residenceIDs []uint) func(db *gorm.DB) *gorm.DB {
|
|
return func(db *gorm.DB) *gorm.DB {
|
|
if len(residenceIDs) == 0 {
|
|
// Return empty result if no residence IDs provided
|
|
return db.Where("1 = 0")
|
|
}
|
|
return db.Where("residence_id IN ?", residenceIDs)
|
|
}
|
|
}
|
|
|
|
// ScopeHasCompletions filters to tasks that have at least one completion.
|
|
//
|
|
// Predicate equivalent: HasCompletions(task)
|
|
//
|
|
// SQL: EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)
|
|
func ScopeHasCompletions(db *gorm.DB) *gorm.DB {
|
|
return db.Where(
|
|
"EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)",
|
|
)
|
|
}
|
|
|
|
// ScopeNoCompletions filters to tasks that have no completions.
|
|
//
|
|
// Predicate equivalent: !HasCompletions(task)
|
|
//
|
|
// SQL: NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)
|
|
func ScopeNoCompletions(db *gorm.DB) *gorm.DB {
|
|
return db.Where(
|
|
"NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)",
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// ORDERING
|
|
// =============================================================================
|
|
|
|
// ScopeOrderByDueDate orders tasks by effective due date ascending, nulls last.
|
|
//
|
|
// SQL: ORDER BY COALESCE(next_due_date, due_date) ASC NULLS LAST
|
|
func ScopeOrderByDueDate(db *gorm.DB) *gorm.DB {
|
|
return db.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST")
|
|
}
|
|
|
|
// ScopeOrderByPriority orders tasks by priority level descending (urgent first).
|
|
//
|
|
// SQL: ORDER BY priority_id DESC
|
|
func ScopeOrderByPriority(db *gorm.DB) *gorm.DB {
|
|
return db.Order("priority_id DESC")
|
|
}
|
|
|
|
// ScopeOrderByCreatedAt orders tasks by creation date descending (newest first).
|
|
//
|
|
// SQL: ORDER BY created_at DESC
|
|
func ScopeOrderByCreatedAt(db *gorm.DB) *gorm.DB {
|
|
return db.Order("created_at DESC")
|
|
}
|
|
|
|
// ScopeKanbanOrder applies the standard kanban ordering.
|
|
//
|
|
// SQL: ORDER BY COALESCE(next_due_date, due_date) ASC NULLS LAST, priority_id DESC, created_at DESC
|
|
func ScopeKanbanOrder(db *gorm.DB) *gorm.DB {
|
|
return db.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST, priority_id DESC, created_at DESC")
|
|
}
|