- Webhook event logging repo and subscription webhook idempotency - Pagination helper (echohelpers) with cursor/offset support - Request ID and structured logging middleware - Push client improvements (FCM HTTP v1, better error handling) - Task model version column, business constraint migrations, targeted indexes - Expanded categorization chain tests - Email service and config hardening - CI workflow updates, .gitignore additions, .env.example updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
765 lines
26 KiB
Go
765 lines
26 KiB
Go
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"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"
|
|
)
|
|
|
|
// ErrVersionConflict indicates a concurrent modification was detected
|
|
var ErrVersionConflict = errors.New("version conflict: task was modified by another request")
|
|
|
|
// 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
|
|
PreloadFrequency bool // For smart notifications
|
|
}
|
|
|
|
// 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 (only from active residences)
|
|
query = query.Where(
|
|
"(task_task.assigned_to_id IN ? OR task_task.residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ? AND is_active = true))",
|
|
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")
|
|
})
|
|
}
|
|
if opts.PreloadFrequency {
|
|
query = query.Preload("Frequency")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// GetActiveTasksForUsers returns all active, non-completed tasks for the specified users.
|
|
// This is used by the smart notification system to evaluate all tasks for potential reminders.
|
|
// It includes tasks that are overdue, due soon, or upcoming - the caller determines
|
|
// which reminders to send based on the task's frequency and due date.
|
|
func (r *TaskRepository) GetActiveTasksForUsers(now time.Time, opts TaskFilterOptions) ([]models.Task, error) {
|
|
var tasks []models.Task
|
|
|
|
// Get all active, non-completed tasks
|
|
query := r.db.Model(&models.Task{}).
|
|
Scopes(task.ScopeActive, task.ScopeNotCompleted)
|
|
|
|
// Include in-progress tasks if specified
|
|
if !opts.IncludeInProgress {
|
|
query = query.Scopes(task.ScopeNotInProgress)
|
|
}
|
|
|
|
// Apply filters and preloads
|
|
query = r.applyFilterOptions(query, opts)
|
|
|
|
// Order by due date for consistent processing
|
|
query = query.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST")
|
|
|
|
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 with optimistic locking.
|
|
// The update only succeeds if the task's version in the database matches the expected version.
|
|
// On success, the local task.Version is incremented to reflect the new version.
|
|
func (r *TaskRepository) Update(task *models.Task) error {
|
|
result := r.db.Model(task).
|
|
Where("id = ? AND version = ?", task.ID, task.Version).
|
|
Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions").
|
|
Updates(map[string]interface{}{
|
|
"title": task.Title,
|
|
"description": task.Description,
|
|
"category_id": task.CategoryID,
|
|
"priority_id": task.PriorityID,
|
|
"frequency_id": task.FrequencyID,
|
|
"custom_interval_days": task.CustomIntervalDays,
|
|
"in_progress": task.InProgress,
|
|
"assigned_to_id": task.AssignedToID,
|
|
"due_date": task.DueDate,
|
|
"next_due_date": task.NextDueDate,
|
|
"estimated_cost": task.EstimatedCost,
|
|
"actual_cost": task.ActualCost,
|
|
"contractor_id": task.ContractorID,
|
|
"is_cancelled": task.IsCancelled,
|
|
"is_archived": task.IsArchived,
|
|
"version": gorm.Expr("version + 1"),
|
|
})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return ErrVersionConflict
|
|
}
|
|
task.Version++ // Update local copy
|
|
return nil
|
|
}
|
|
|
|
// 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 with optimistic locking.
|
|
func (r *TaskRepository) MarkInProgress(id uint, version int) error {
|
|
result := r.db.Model(&models.Task{}).
|
|
Where("id = ? AND version = ?", id, version).
|
|
Updates(map[string]interface{}{
|
|
"in_progress": true,
|
|
"version": gorm.Expr("version + 1"),
|
|
})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return ErrVersionConflict
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Cancel cancels a task with optimistic locking.
|
|
func (r *TaskRepository) Cancel(id uint, version int) error {
|
|
result := r.db.Model(&models.Task{}).
|
|
Where("id = ? AND version = ?", id, version).
|
|
Updates(map[string]interface{}{
|
|
"is_cancelled": true,
|
|
"version": gorm.Expr("version + 1"),
|
|
})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return ErrVersionConflict
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Uncancel uncancels a task with optimistic locking.
|
|
func (r *TaskRepository) Uncancel(id uint, version int) error {
|
|
result := r.db.Model(&models.Task{}).
|
|
Where("id = ? AND version = ?", id, version).
|
|
Updates(map[string]interface{}{
|
|
"is_cancelled": false,
|
|
"version": gorm.Expr("version + 1"),
|
|
})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return ErrVersionConflict
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Archive archives a task with optimistic locking.
|
|
func (r *TaskRepository) Archive(id uint, version int) error {
|
|
result := r.db.Model(&models.Task{}).
|
|
Where("id = ? AND version = ?", id, version).
|
|
Updates(map[string]interface{}{
|
|
"is_archived": true,
|
|
"version": gorm.Expr("version + 1"),
|
|
})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return ErrVersionConflict
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Unarchive unarchives a task with optimistic locking.
|
|
func (r *TaskRepository) Unarchive(id uint, version int) error {
|
|
result := r.db.Model(&models.Task{}).
|
|
Where("id = ? AND version = ?", id, version).
|
|
Updates(map[string]interface{}{
|
|
"is_archived": false,
|
|
"version": gorm.Expr("version + 1"),
|
|
})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return ErrVersionConflict
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// === Kanban Board ===
|
|
|
|
// buildKanbanColumns builds the kanban column array from categorized task slices.
|
|
// This is a helper function to reduce duplication between GetKanbanData and GetKanbanDataForMultipleResidences.
|
|
// Note: cancelled/archived tasks are intentionally hidden from the kanban board.
|
|
// They still retain "cancelled_tasks" as task-level categorization for detail views/actions.
|
|
func buildKanbanColumns(
|
|
overdue, inProgress, dueSoon, upcoming, completed []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),
|
|
},
|
|
// Intentionally hidden from board:
|
|
// cancelled/archived tasks are not returned as a kanban column.
|
|
// {
|
|
// 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)
|
|
}
|
|
|
|
// Intentionally hidden from board:
|
|
// cancelled/archived tasks are not returned as a kanban column.
|
|
// cancelled, err := r.GetCancelledTasks(opts)
|
|
// if err != nil {
|
|
// return nil, fmt.Errorf("get cancelled tasks: %w", err)
|
|
// }
|
|
|
|
columns := buildKanbanColumns(overdue, inProgress, dueSoon, upcoming, completed)
|
|
|
|
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)
|
|
}
|
|
|
|
// Intentionally hidden from board:
|
|
// cancelled/archived tasks are not returned as a kanban column.
|
|
// cancelled, err := r.GetCancelledTasks(opts)
|
|
// if err != nil {
|
|
// return nil, fmt.Errorf("get cancelled tasks: %w", err)
|
|
// }
|
|
|
|
columns := buildKanbanColumns(overdue, inProgress, dueSoon, upcoming, completed)
|
|
|
|
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
|
|
}
|
|
|
|
// UpdateCompletion updates an existing task completion
|
|
func (r *TaskRepository) UpdateCompletion(completion *models.TaskCompletion) error {
|
|
return r.db.Omit("Task", "CompletedBy", "Images").Save(completion).Error
|
|
}
|
|
|
|
// 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.
|
|
// Excludes in-progress tasks to match what's displayed in the kanban overdue column.
|
|
// 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), task.ScopeNotInProgress).
|
|
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
|
|
}
|