Files
honeyDueAPI/internal/repositories/task_repo.go
Trey t 684856e0e9 Add timezone-aware overdue task detection
Fix issue where tasks showed as "Overdue" on the server while displaying
"Tomorrow" on the client due to timezone differences between server (UTC)
and user's local timezone.

Changes:
- Add X-Timezone header support to extract user's timezone from requests
- Add TimezoneMiddleware to parse timezone and calculate user's local "today"
- Update task categorization to accept custom time for accurate date comparisons
- Update repository, service, and handler layers to pass timezone-aware time
- Update CORS to allow X-Timezone header

The client now sends the user's IANA timezone (e.g., "America/Los_Angeles")
and the server uses it to determine if a task is overdue based on the
user's local date, not UTC.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 00:04:09 -06:00

519 lines
17 KiB
Go

package repositories
import (
"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 CRUD ===
// FindByID finds a task by ID with preloaded relations
func (r *TaskRepository) FindByID(id uint) (*models.Task, error) {
var task models.Task
err := r.db.Preload("Residence").
Preload("CreatedBy").
Preload("AssignedTo").
Preload("Category").
Preload("Priority").
Preload("Frequency").
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
func (r *TaskRepository) FindByResidence(residenceID uint) ([]models.Task, error) {
var tasks []models.Task
err := r.db.Preload("CreatedBy").
Preload("AssignedTo").
Preload("Category").
Preload("Priority").
Preload("Frequency").
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)
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("Category").
Preload("Priority").
Preload("Frequency").
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 ===
// GetKanbanData retrieves tasks organized for kanban display.
// Uses the task.categorization package as the single source of truth for categorization logic.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
var tasks []models.Task
err := r.db.Preload("CreatedBy").
Preload("AssignedTo").
Preload("Category").
Preload("Priority").
Preload("Frequency").
Preload("Completions").
Preload("Completions.Images").
Preload("Completions.CompletedBy").
Where("residence_id = ? AND is_archived = ?", residenceID, false).
Scopes(task.ScopeKanbanOrder).
Find(&tasks).Error
if err != nil {
return nil, err
}
// Use the categorization package as the single source of truth
// Pass the user's timezone-aware time for accurate overdue detection
categorized := categorization.CategorizeTasksIntoColumnsWithTime(tasks, daysThreshold, now)
columns := []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: categorized[categorization.ColumnOverdue],
Count: len(categorized[categorization.ColumnOverdue]),
},
{
Name: string(categorization.ColumnInProgress),
DisplayName: "In Progress",
ButtonTypes: []string{"edit", "complete", "cancel"},
Icons: map[string]string{"ios": "hammer", "android": "Build"},
Color: "#5856D6",
Tasks: categorized[categorization.ColumnInProgress],
Count: len(categorized[categorization.ColumnInProgress]),
},
{
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: categorized[categorization.ColumnDueSoon],
Count: len(categorized[categorization.ColumnDueSoon]),
},
{
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: categorized[categorization.ColumnUpcoming],
Count: len(categorized[categorization.ColumnUpcoming]),
},
{
Name: string(categorization.ColumnCompleted),
DisplayName: "Completed",
ButtonTypes: []string{},
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
Color: "#34C759",
Tasks: categorized[categorization.ColumnCompleted],
Count: len(categorized[categorization.ColumnCompleted]),
},
{
Name: string(categorization.ColumnCancelled),
DisplayName: "Cancelled",
ButtonTypes: []string{"uncancel", "delete"},
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
Color: "#8E8E93",
Tasks: categorized[categorization.ColumnCancelled],
Count: len(categorized[categorization.ColumnCancelled]),
},
}
return &models.KanbanBoard{
Columns: columns,
DaysThreshold: daysThreshold,
ResidenceID: string(rune(residenceID)),
}, nil
}
// GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display.
// Uses the task.categorization package as the single source of truth for categorization logic.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
var tasks []models.Task
err := r.db.Preload("CreatedBy").
Preload("AssignedTo").
Preload("Category").
Preload("Priority").
Preload("Frequency").
Preload("Completions").
Preload("Completions.Images").
Preload("Completions.CompletedBy").
Preload("Residence").
Where("residence_id IN ? AND is_archived = ?", residenceIDs, false).
Scopes(task.ScopeKanbanOrder).
Find(&tasks).Error
if err != nil {
return nil, err
}
// Use the categorization package as the single source of truth
// Pass the user's timezone-aware time for accurate overdue detection
categorized := categorization.CategorizeTasksIntoColumnsWithTime(tasks, daysThreshold, now)
columns := []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: categorized[categorization.ColumnOverdue],
Count: len(categorized[categorization.ColumnOverdue]),
},
{
Name: string(categorization.ColumnInProgress),
DisplayName: "In Progress",
ButtonTypes: []string{"edit", "complete", "cancel"},
Icons: map[string]string{"ios": "hammer", "android": "Build"},
Color: "#5856D6",
Tasks: categorized[categorization.ColumnInProgress],
Count: len(categorized[categorization.ColumnInProgress]),
},
{
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: categorized[categorization.ColumnDueSoon],
Count: len(categorized[categorization.ColumnDueSoon]),
},
{
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: categorized[categorization.ColumnUpcoming],
Count: len(categorized[categorization.ColumnUpcoming]),
},
{
Name: string(categorization.ColumnCompleted),
DisplayName: "Completed",
ButtonTypes: []string{},
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
Color: "#34C759",
Tasks: categorized[categorization.ColumnCompleted],
Count: len(categorized[categorization.ColumnCompleted]),
},
{
Name: string(categorization.ColumnCancelled),
DisplayName: "Cancelled",
ButtonTypes: []string{"uncancel", "delete"},
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
Color: "#8E8E93",
Tasks: categorized[categorization.ColumnCancelled],
Count: len(categorized[categorization.ColumnCancelled]),
},
}
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
}
// 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
}
// TaskStatistics represents aggregated task statistics
type TaskStatistics struct {
TotalTasks int
TotalPending int
TotalOverdue int
TasksDueNextWeek int
TasksDueNextMonth int
}
// GetTaskStatistics returns aggregated task statistics for multiple residences.
// 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) GetTaskStatistics(residenceIDs []uint, now time.Time) (*TaskStatistics, error) {
if len(residenceIDs) == 0 {
return &TaskStatistics{}, nil
}
var totalTasks, totalOverdue, totalPending, tasksDueNextWeek, tasksDueNextMonth int64
// Count total active tasks (not cancelled, not archived)
// Uses: task.ScopeActive, task.ScopeForResidences
err := r.db.Model(&models.Task{}).
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeActive).
Count(&totalTasks).Error
if err != nil {
return nil, err
}
// Count overdue tasks using consistent scope
// Uses: task.ScopeOverdue (which includes ScopeActive and ScopeNotCompleted)
err = r.db.Model(&models.Task{}).
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeOverdue(now)).
Count(&totalOverdue).Error
if err != nil {
return nil, err
}
// Count pending tasks (active, not completed)
// Uses: task.ScopeActive, task.ScopeNotCompleted
err = r.db.Model(&models.Task{}).
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeActive, task.ScopeNotCompleted).
Count(&totalPending).Error
if err != nil {
return nil, err
}
// Count tasks due next week using consistent scope
// Uses: task.ScopeDueSoon with 7-day threshold
err = r.db.Model(&models.Task{}).
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeDueSoon(now, 7)).
Count(&tasksDueNextWeek).Error
if err != nil {
return nil, err
}
// Count tasks due next month using consistent scope
// Uses: task.ScopeDueSoon with 30-day threshold
err = r.db.Model(&models.Task{}).
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeDueSoon(now, 30)).
Count(&tasksDueNextMonth).Error
if err != nil {
return nil, err
}
return &TaskStatistics{
TotalTasks: int(totalTasks),
TotalPending: int(totalPending),
TotalOverdue: int(totalOverdue),
TasksDueNextWeek: int(tasksDueNextWeek),
TasksDueNextMonth: int(tasksDueNextMonth),
}, 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
}