Files
honeyDueAPI/internal/repositories/task_repo.go
Trey t 9bd0708ca4 Fix date comparison for cross-DB compatibility and add timezone coverage
- Change all date scopes from PostgreSQL-specific ::date to DATE() function
  which works in both PostgreSQL and SQLite (used in tests)
- Fix ScopeOverdue, ScopeDueSoon, ScopeUpcoming, ScopeDueInRange
- Fix GetOverdueTasks inline query in task_repo.go

- Fix timezone unit tests: due dates must be stored as midnight UTC
  (calendar dates), not with timezone info that GORM converts to UTC
- Update TestGetOverdueTasks_Timezone_Tokyo, NewYork, InternationalDateLine
- Update TestGetDueSoonTasks_Timezone_DST

- Add TestIntegration_TimezoneDivergence: proves same task appears in
  different kanban columns based on X-Timezone header
- Update TestIntegration_DateBoundaryEdgeCases to use America/New_York
- Update TestIntegration_TasksByResidenceKanban to use America/Los_Angeles
- Add identity-based column membership assertions (columnTaskIDs approach)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 14:59:12 -06:00

638 lines
22 KiB
Go

package repositories
import (
"fmt"
"time"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/task"
"github.com/treytartt/casera-api/internal/task/categorization"
)
// TaskRepository handles database operations for tasks
type TaskRepository struct {
db *gorm.DB
}
// NewTaskRepository creates a new task repository
func NewTaskRepository(db *gorm.DB) *TaskRepository {
return &TaskRepository{db: db}
}
// === Task Filter Options ===
// TaskFilterOptions provides flexible filtering for task queries.
// Use exactly one of ResidenceID, ResidenceIDs, or UserIDs to specify the filter scope.
type TaskFilterOptions struct {
// Filter by single residence (kanban single-residence view)
ResidenceID uint
// Filter by multiple residences (kanban all-residences view)
ResidenceIDs []uint
// Filter by users - matches tasks where assigned_to IN userIDs
// OR residence owner IN userIDs (for notifications)
UserIDs []uint
// Include archived tasks (default: false, excludes archived)
IncludeArchived bool
// IncludeInProgress controls whether in-progress tasks are included in
// overdue/due-soon/upcoming queries. Default is false (excludes in-progress)
// for kanban column consistency. Set to true for notifications where
// users should still be notified about in-progress tasks that are overdue.
IncludeInProgress bool
// Preload options
PreloadCreatedBy bool
PreloadAssignedTo bool
PreloadResidence bool
PreloadCompletions bool // Minimal: just id, task_id, completed_at
}
// applyFilterOptions applies the filter options to a query.
// Returns a new query with filters and preloads applied.
func (r *TaskRepository) applyFilterOptions(query *gorm.DB, opts TaskFilterOptions) *gorm.DB {
// Apply residence/user filters
if opts.ResidenceID != 0 {
query = query.Where("task_task.residence_id = ?", opts.ResidenceID)
} else if len(opts.ResidenceIDs) > 0 {
query = query.Where("task_task.residence_id IN ?", opts.ResidenceIDs)
} else if len(opts.UserIDs) > 0 {
// For notifications: tasks assigned to users OR owned by users
query = query.Where(
"(task_task.assigned_to_id IN ? OR task_task.residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
opts.UserIDs, opts.UserIDs,
)
}
// Apply archived filter (default excludes archived)
if !opts.IncludeArchived {
query = query.Where("task_task.is_archived = ?", false)
}
// Apply preloads
if opts.PreloadCreatedBy {
query = query.Preload("CreatedBy")
}
if opts.PreloadAssignedTo {
query = query.Preload("AssignedTo")
}
if opts.PreloadResidence {
query = query.Preload("Residence")
}
if opts.PreloadCompletions {
query = query.Preload("Completions", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "task_id", "completed_at")
})
}
return query
}
// === Single-Purpose Task Query Functions ===
// These functions use the scopes from internal/task/scopes for consistent filtering.
// They are the single source of truth for task categorization queries, used by both
// kanban and notification handlers.
// GetOverdueTasks returns active, non-completed tasks past their effective due date.
// Uses task.ScopeOverdue for consistent filtering logic.
// The `now` parameter should be in the user's timezone for accurate overdue detection.
//
// By default, excludes in-progress tasks for kanban column consistency.
// Set opts.IncludeInProgress=true for notifications where in-progress tasks should still appear.
func (r *TaskRepository) GetOverdueTasks(now time.Time, opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
query := r.db.Model(&models.Task{})
if opts.IncludeArchived {
// When including archived, build the query manually to skip the archived check
// but still apply cancelled check, not-completed check, and date check
// IMPORTANT: Use date comparison (not timestamps) for timezone correctness
todayStr := now.Format("2006-01-02")
query = query.Where("is_cancelled = ?", false).
Scopes(task.ScopeNotCompleted).
Where("DATE(COALESCE(next_due_date, due_date)) < DATE(?)", todayStr)
} else {
// Use the combined scope which includes is_archived = false
query = query.Scopes(task.ScopeOverdue(now))
}
query = query.Scopes(task.ScopeKanbanOrder)
if !opts.IncludeInProgress {
query = query.Scopes(task.ScopeNotInProgress)
}
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetDueSoonTasks returns active, non-completed tasks due within the threshold.
// Uses task.ScopeDueSoon for consistent filtering logic.
// The `now` parameter should be in the user's timezone for accurate detection.
//
// By default, excludes in-progress tasks for kanban column consistency.
// Set opts.IncludeInProgress=true for notifications where in-progress tasks should still appear.
func (r *TaskRepository) GetDueSoonTasks(now time.Time, daysThreshold int, opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
query := r.db.Model(&models.Task{}).
Scopes(task.ScopeDueSoon(now, daysThreshold), task.ScopeKanbanOrder)
if !opts.IncludeInProgress {
query = query.Scopes(task.ScopeNotInProgress)
}
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetInProgressTasks returns active, non-completed tasks marked as in-progress.
// Uses task.ScopeInProgress for consistent filtering logic.
//
// Note: Excludes completed tasks to match kanban column behavior (completed has higher priority).
func (r *TaskRepository) GetInProgressTasks(opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
query := r.db.Model(&models.Task{}).
Scopes(task.ScopeActive, task.ScopeNotCompleted, task.ScopeInProgress, task.ScopeKanbanOrder)
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetUpcomingTasks returns active, non-completed tasks due after the threshold or with no due date.
// Uses task.ScopeUpcoming for consistent filtering logic.
//
// By default, excludes in-progress tasks for kanban column consistency.
// Set opts.IncludeInProgress=true for notifications where in-progress tasks should still appear.
func (r *TaskRepository) GetUpcomingTasks(now time.Time, daysThreshold int, opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
query := r.db.Model(&models.Task{}).
Scopes(task.ScopeUpcoming(now, daysThreshold), task.ScopeKanbanOrder)
if !opts.IncludeInProgress {
query = query.Scopes(task.ScopeNotInProgress)
}
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetCompletedTasks returns completed tasks (NextDueDate nil with at least one completion).
// Uses task.ScopeCompleted for consistent filtering logic.
func (r *TaskRepository) GetCompletedTasks(opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
// Completed tasks: not cancelled, has completion, no next due date
// Note: We don't apply ScopeActive because completed tasks may not be "active" in that sense
query := r.db.Model(&models.Task{}).
Where("is_cancelled = ?", false).
Scopes(task.ScopeCompleted, task.ScopeKanbanOrder)
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetCancelledTasks returns cancelled OR archived tasks.
// Archived tasks are grouped with cancelled for kanban purposes - they both represent
// tasks that are no longer active/actionable.
func (r *TaskRepository) GetCancelledTasks(opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
// Include both cancelled and archived tasks in this column
// Archived tasks should ONLY appear here, not in any other column
query := r.db.Model(&models.Task{}).
Where("is_cancelled = ? OR is_archived = ?", true, true).
Scopes(task.ScopeKanbanOrder)
// Override IncludeArchived to true since this function specifically handles archived tasks
opts.IncludeArchived = true
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// === Task CRUD ===
// FindByID finds a task by ID with preloaded relations
// Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs
func (r *TaskRepository) FindByID(id uint) (*models.Task, error) {
var task models.Task
err := r.db.Preload("Residence").
Preload("CreatedBy").
Preload("AssignedTo").
Preload("Completions").
Preload("Completions.Images").
Preload("Completions.CompletedBy").
First(&task, id).Error
if err != nil {
return nil, err
}
return &task, nil
}
// FindByResidence finds all tasks for a residence
// Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs
func (r *TaskRepository) FindByResidence(residenceID uint) ([]models.Task, error) {
var tasks []models.Task
err := r.db.Preload("CreatedBy").
Preload("AssignedTo").
Preload("Completions").
Preload("Completions.Images").
Preload("Completions.CompletedBy").
Where("residence_id = ?", residenceID).
Order("due_date ASC NULLS LAST, created_at DESC").
Find(&tasks).Error
return tasks, err
}
// FindByUser finds all tasks accessible to a user (across all their residences)
// Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs
func (r *TaskRepository) FindByUser(userID uint, residenceIDs []uint) ([]models.Task, error) {
var tasks []models.Task
err := r.db.Preload("Residence").
Preload("CreatedBy").
Preload("AssignedTo").
Preload("Completions").
Preload("Completions.Images").
Preload("Completions.CompletedBy").
Where("residence_id IN ?", residenceIDs).
Order("due_date ASC NULLS LAST, created_at DESC").
Find(&tasks).Error
return tasks, err
}
// Create creates a new task
func (r *TaskRepository) Create(task *models.Task) error {
return r.db.Create(task).Error
}
// Update updates a task
// Uses Omit to exclude associations that shouldn't be updated via Save
func (r *TaskRepository) Update(task *models.Task) error {
return r.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions").Save(task).Error
}
// Delete hard-deletes a task
func (r *TaskRepository) Delete(id uint) error {
return r.db.Delete(&models.Task{}, id).Error
}
// === Task State Operations ===
// MarkInProgress marks a task as in progress
func (r *TaskRepository) MarkInProgress(id uint) error {
return r.db.Model(&models.Task{}).
Where("id = ?", id).
Update("in_progress", true).Error
}
// Cancel cancels a task
func (r *TaskRepository) Cancel(id uint) error {
return r.db.Model(&models.Task{}).
Where("id = ?", id).
Update("is_cancelled", true).Error
}
// Uncancel uncancels a task
func (r *TaskRepository) Uncancel(id uint) error {
return r.db.Model(&models.Task{}).
Where("id = ?", id).
Update("is_cancelled", false).Error
}
// Archive archives a task
func (r *TaskRepository) Archive(id uint) error {
return r.db.Model(&models.Task{}).
Where("id = ?", id).
Update("is_archived", true).Error
}
// Unarchive unarchives a task
func (r *TaskRepository) Unarchive(id uint) error {
return r.db.Model(&models.Task{}).
Where("id = ?", id).
Update("is_archived", false).Error
}
// === Kanban Board ===
// buildKanbanColumns builds the kanban column array from categorized task slices.
// This is a helper function to reduce duplication between GetKanbanData and GetKanbanDataForMultipleResidences.
func buildKanbanColumns(
overdue, inProgress, dueSoon, upcoming, completed, cancelled []models.Task,
) []models.KanbanColumn {
return []models.KanbanColumn{
{
Name: string(categorization.ColumnOverdue),
DisplayName: "Overdue",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
Color: "#FF3B30",
Tasks: overdue,
Count: len(overdue),
},
{
Name: string(categorization.ColumnInProgress),
DisplayName: "In Progress",
ButtonTypes: []string{"edit", "complete", "cancel"},
Icons: map[string]string{"ios": "hammer", "android": "Build"},
Color: "#5856D6",
Tasks: inProgress,
Count: len(inProgress),
},
{
Name: string(categorization.ColumnDueSoon),
DisplayName: "Due Soon",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "clock", "android": "Schedule"},
Color: "#FF9500",
Tasks: dueSoon,
Count: len(dueSoon),
},
{
Name: string(categorization.ColumnUpcoming),
DisplayName: "Upcoming",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "calendar", "android": "Event"},
Color: "#007AFF",
Tasks: upcoming,
Count: len(upcoming),
},
{
Name: string(categorization.ColumnCompleted),
DisplayName: "Completed",
ButtonTypes: []string{},
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
Color: "#34C759",
Tasks: completed,
Count: len(completed),
},
{
Name: string(categorization.ColumnCancelled),
DisplayName: "Cancelled",
ButtonTypes: []string{"uncancel", "delete"},
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
Color: "#8E8E93",
Tasks: cancelled,
Count: len(cancelled),
},
}
}
// GetKanbanData retrieves tasks organized for kanban display.
// Uses single-purpose query functions for each column type, ensuring consistency
// with notification handlers that use the same functions.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
//
// Optimization: Preloads only minimal completion data (id, task_id, completed_at) for count/detection.
// Images and CompletedBy are NOT preloaded - fetch separately when viewing completion details.
func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
opts := TaskFilterOptions{
ResidenceID: residenceID,
PreloadCreatedBy: true,
PreloadAssignedTo: true,
PreloadCompletions: true,
}
// Query each column using single-purpose functions
// These functions use the same scopes as notification handlers for consistency
overdue, err := r.GetOverdueTasks(now, opts)
if err != nil {
return nil, fmt.Errorf("get overdue tasks: %w", err)
}
inProgress, err := r.GetInProgressTasks(opts)
if err != nil {
return nil, fmt.Errorf("get in-progress tasks: %w", err)
}
dueSoon, err := r.GetDueSoonTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get due-soon tasks: %w", err)
}
upcoming, err := r.GetUpcomingTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get upcoming tasks: %w", err)
}
completed, err := r.GetCompletedTasks(opts)
if err != nil {
return nil, fmt.Errorf("get completed tasks: %w", err)
}
cancelled, err := r.GetCancelledTasks(opts)
if err != nil {
return nil, fmt.Errorf("get cancelled tasks: %w", err)
}
columns := buildKanbanColumns(overdue, inProgress, dueSoon, upcoming, completed, cancelled)
return &models.KanbanBoard{
Columns: columns,
DaysThreshold: daysThreshold,
ResidenceID: fmt.Sprintf("%d", residenceID),
}, nil
}
// GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display.
// Uses single-purpose query functions for each column type, ensuring consistency
// with notification handlers that use the same functions.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
//
// Optimization: Preloads only minimal completion data (id, task_id, completed_at) for count/detection.
// Images and CompletedBy are NOT preloaded - fetch separately when viewing completion details.
func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
opts := TaskFilterOptions{
ResidenceIDs: residenceIDs,
PreloadCreatedBy: true,
PreloadAssignedTo: true,
PreloadResidence: true,
PreloadCompletions: true,
}
// Query each column using single-purpose functions
// These functions use the same scopes as notification handlers for consistency
overdue, err := r.GetOverdueTasks(now, opts)
if err != nil {
return nil, fmt.Errorf("get overdue tasks: %w", err)
}
inProgress, err := r.GetInProgressTasks(opts)
if err != nil {
return nil, fmt.Errorf("get in-progress tasks: %w", err)
}
dueSoon, err := r.GetDueSoonTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get due-soon tasks: %w", err)
}
upcoming, err := r.GetUpcomingTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get upcoming tasks: %w", err)
}
completed, err := r.GetCompletedTasks(opts)
if err != nil {
return nil, fmt.Errorf("get completed tasks: %w", err)
}
cancelled, err := r.GetCancelledTasks(opts)
if err != nil {
return nil, fmt.Errorf("get cancelled tasks: %w", err)
}
columns := buildKanbanColumns(overdue, inProgress, dueSoon, upcoming, completed, cancelled)
return &models.KanbanBoard{
Columns: columns,
DaysThreshold: daysThreshold,
ResidenceID: "all",
}, nil
}
// === Lookup Operations ===
// GetAllCategories returns all task categories
func (r *TaskRepository) GetAllCategories() ([]models.TaskCategory, error) {
var categories []models.TaskCategory
err := r.db.Order("display_order, name").Find(&categories).Error
return categories, err
}
// GetAllPriorities returns all task priorities
func (r *TaskRepository) GetAllPriorities() ([]models.TaskPriority, error) {
var priorities []models.TaskPriority
err := r.db.Order("level").Find(&priorities).Error
return priorities, err
}
// GetAllFrequencies returns all task frequencies
func (r *TaskRepository) GetAllFrequencies() ([]models.TaskFrequency, error) {
var frequencies []models.TaskFrequency
err := r.db.Order("display_order").Find(&frequencies).Error
return frequencies, err
}
// GetFrequencyByID retrieves a single frequency by ID
func (r *TaskRepository) GetFrequencyByID(id uint) (*models.TaskFrequency, error) {
var frequency models.TaskFrequency
err := r.db.First(&frequency, id).Error
if err != nil {
return nil, err
}
return &frequency, nil
}
// CountByResidence counts tasks in a residence
func (r *TaskRepository) CountByResidence(residenceID uint) (int64, error) {
var count int64
err := r.db.Model(&models.Task{}).
Where("residence_id = ? AND is_cancelled = ? AND is_archived = ?", residenceID, false, false).
Count(&count).Error
return count, err
}
// === Task Completion Operations ===
// CreateCompletion creates a new task completion
func (r *TaskRepository) CreateCompletion(completion *models.TaskCompletion) error {
return r.db.Create(completion).Error
}
// FindCompletionByID finds a completion by ID
func (r *TaskRepository) FindCompletionByID(id uint) (*models.TaskCompletion, error) {
var completion models.TaskCompletion
err := r.db.Preload("Task").
Preload("CompletedBy").
Preload("Images").
First(&completion, id).Error
if err != nil {
return nil, err
}
return &completion, nil
}
// FindCompletionsByTask finds all completions for a task
func (r *TaskRepository) FindCompletionsByTask(taskID uint) ([]models.TaskCompletion, error) {
var completions []models.TaskCompletion
err := r.db.Preload("CompletedBy").
Preload("Images").
Where("task_id = ?", taskID).
Order("completed_at DESC").
Find(&completions).Error
return completions, err
}
// FindCompletionsByUser finds all completions by a user
func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint) ([]models.TaskCompletion, error) {
var completions []models.TaskCompletion
err := r.db.Preload("Task").
Preload("CompletedBy").
Preload("Images").
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
Where("task_task.residence_id IN ?", residenceIDs).
Order("completed_at DESC").
Find(&completions).Error
return completions, err
}
// DeleteCompletion deletes a task completion
func (r *TaskRepository) DeleteCompletion(id uint) error {
// Delete images first
r.db.Where("completion_id = ?", id).Delete(&models.TaskCompletionImage{})
return r.db.Delete(&models.TaskCompletion{}, id).Error
}
// CreateCompletionImage creates a new completion image
func (r *TaskRepository) CreateCompletionImage(image *models.TaskCompletionImage) error {
return r.db.Create(image).Error
}
// DeleteCompletionImage deletes a completion image
func (r *TaskRepository) DeleteCompletionImage(id uint) error {
return r.db.Delete(&models.TaskCompletionImage{}, id).Error
}
// FindCompletionImageByID finds a completion image by ID
func (r *TaskRepository) FindCompletionImageByID(id uint) (*models.TaskCompletionImage, error) {
var image models.TaskCompletionImage
err := r.db.First(&image, id).Error
if err != nil {
return nil, err
}
return &image, nil
}
// GetOverdueCountByResidence returns a map of residence ID to overdue task count.
// Uses the task.scopes package for consistent filtering logic.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
func (r *TaskRepository) GetOverdueCountByResidence(residenceIDs []uint, now time.Time) (map[uint]int, error) {
if len(residenceIDs) == 0 {
return map[uint]int{}, nil
}
// Query to get overdue count grouped by residence
type result struct {
ResidenceID uint
Count int64
}
var results []result
err := r.db.Model(&models.Task{}).
Select("residence_id, COUNT(*) as count").
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeOverdue(now)).
Group("residence_id").
Scan(&results).Error
if err != nil {
return nil, err
}
// Convert to map
countMap := make(map[uint]int)
for _, r := range results {
countMap[r.ResidenceID] = int(r.Count)
}
return countMap, nil
}