Files
honeyDueAPI/internal/repositories/task_repo.go
T
Trey t bc3da007db
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Wire OpenTelemetry tracing — HTTP, B2, APNs, FCM, asynq, GORM (partial)
Step 1 — OTel SDK: cmd/api and cmd/worker initialize a tracer provider
that exports OTLP/HTTP to obs.88oakapps.com (Jaeger all-in-one). Sampling
is AlwaysSample in dev (DEBUG=true) and TraceIDRatioBased(0.1) in prod,
overridable via OTEL_TRACES_SAMPLER_ARG. Service names are honeydue-api
and honeydue-worker. otelecho.Middleware opens a span per HTTP request.

Step 2 — Manual spans: storage_service.Upload now takes ctx and emits
storage.upload + b2.PutObject spans (size_bytes, key, mime_type, bucket,
result attrs). APNs Send/SendWithCategory and FCM sendOne emit per-token
spans with topic, status_code, reason. Asynq middleware emits
asynq.handle:<task_type> per job with retry/payload attrs and records
asynq_job_duration_seconds.

Step 3 — Database: otelgorm plugin registered in database.Connect, so
any SQL emitted via db.WithContext(ctx) attaches to the request span.
Every repository now exposes WithContext(ctx) *XRepository as the
migration helper. TaskService.ListTasks and GetTasksByResidence are
migrated end-to-end (ctx threaded through handler → service → repo);
remaining services adopt the same pattern incrementally — pre-migration
methods still emit untraced SQL via the unchanged db field.

OBS_TRACES_URL and OBS_INGEST_TOKEN flow from deploy/prod.env →
honeydue-secrets → api+worker Deployments via secretKeyRef (optional).
02-setup-secrets.sh sources them from prod.env on next run; manifests
mark both env vars optional so the deployment rolls without traces if
the secret is absent.

ch15 observability doc now lists what produces spans today vs the
remaining migration work, with the explicit per-method pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:28:05 -05:00

1068 lines
36 KiB
Go

package repositories
import (
"context"
"errors"
"fmt"
"time"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/task"
"github.com/treytartt/honeydue-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}
}
// DB returns the underlying database connection.
// Used by services that need to run transactions spanning multiple operations.
func (r *TaskRepository) DB() *gorm.DB {
return r.db
}
// CreateCompletionTx creates a new task completion within an existing transaction.
func (r *TaskRepository) CreateCompletionTx(tx *gorm.DB, completion *models.TaskCompletion) error {
return tx.Create(completion).Error
}
// taskUpdateFields returns the canonical field map used by both Update and UpdateTx.
// Centralised here so the two methods never drift out of sync.
func taskUpdateFields(t *models.Task) map[string]interface{} {
return map[string]interface{}{
"title": t.Title,
"description": t.Description,
"category_id": t.CategoryID,
"priority_id": t.PriorityID,
"frequency_id": t.FrequencyID,
"custom_interval_days": t.CustomIntervalDays,
"in_progress": t.InProgress,
"assigned_to_id": t.AssignedToID,
"due_date": t.DueDate,
"next_due_date": t.NextDueDate,
"estimated_cost": t.EstimatedCost,
"actual_cost": t.ActualCost,
"contractor_id": t.ContractorID,
"is_cancelled": t.IsCancelled,
"is_archived": t.IsArchived,
"version": gorm.Expr("version + 1"),
}
}
// taskUpdateOmitAssociations lists the association fields to omit during task updates.
var taskUpdateOmitAssociations = []string{
"Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions",
}
// UpdateTx updates a task with optimistic locking within an existing transaction.
func (r *TaskRepository) UpdateTx(tx *gorm.DB, task *models.Task) error {
result := tx.Model(task).
Where("id = ? AND version = ?", task.ID, task.Version).
Omit(taskUpdateOmitAssociations...).
Updates(taskUpdateFields(task))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrVersionConflict
}
task.Version++ // Update local copy
return nil
}
// === 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
}
// CreateTx creates a new task within an existing transaction. Used by
// bulk-create flows where multiple inserts must succeed or fail together.
func (r *TaskRepository) CreateTx(tx *gorm.DB, task *models.Task) error {
return tx.Create(task).Error
}
// FindByIDTx loads a task within an existing transaction. Preloads only the
// fields the bulk-create response needs (CreatedBy, AssignedTo). Category /
// Priority / Frequency are resolved client-side from the lookup cache, so
// we skip them here to match the FindByResidence preload set.
func (r *TaskRepository) FindByIDTx(tx *gorm.DB, id uint) (*models.Task, error) {
var task models.Task
err := tx.Preload("CreatedBy").
Preload("AssignedTo").
First(&task, id).Error
if err != nil {
return nil, err
}
return &task, nil
}
// 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(taskUpdateOmitAssociations...).
Updates(taskUpdateFields(task))
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.
// Fetches all non-cancelled, non-archived tasks for the residence in a single query,
// then categorizes them in-memory using the task categorization chain for consistency
// with the predicate-based logic used throughout the application.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
//
// Optimization: Single query with preloads, then in-memory categorization.
// 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) {
// Fetch all tasks for this residence in a single query (excluding cancelled/archived)
var allTasks []models.Task
query := r.db.Model(&models.Task{}).
Where("task_task.residence_id = ?", residenceID).
Preload("CreatedBy").
Preload("AssignedTo").
Preload("Completions", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "task_id", "completed_at")
}).
Scopes(task.ScopeKanbanOrder)
if err := query.Find(&allTasks).Error; err != nil {
return nil, fmt.Errorf("get tasks for kanban: %w", err)
}
// Categorize all tasks in-memory using the categorization chain
columnMap := categorization.CategorizeTasksIntoColumnsWithTime(allTasks, daysThreshold, now)
columns := buildKanbanColumns(
columnMap[categorization.ColumnOverdue],
columnMap[categorization.ColumnInProgress],
columnMap[categorization.ColumnDueSoon],
columnMap[categorization.ColumnUpcoming],
columnMap[categorization.ColumnCompleted],
)
return &models.KanbanBoard{
Columns: columns,
DaysThreshold: daysThreshold,
ResidenceID: fmt.Sprintf("%d", residenceID),
}, nil
}
// GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display.
// Fetches all tasks in a single query, then categorizes them in-memory using the
// task categorization chain for consistency with predicate-based logic.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
//
// Optimization: Single query with preloads, then in-memory categorization.
// 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) {
// Fetch all tasks for these residences in a single query (excluding cancelled/archived)
var allTasks []models.Task
query := r.db.Model(&models.Task{}).
Where("task_task.residence_id IN ?", residenceIDs).
Preload("CreatedBy").
Preload("AssignedTo").
Preload("Residence").
Preload("Completions", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "task_id", "completed_at")
}).
Scopes(task.ScopeKanbanOrder)
if err := query.Find(&allTasks).Error; err != nil {
return nil, fmt.Errorf("get tasks for kanban: %w", err)
}
// Categorize all tasks in-memory using the categorization chain
columnMap := categorization.CategorizeTasksIntoColumnsWithTime(allTasks, daysThreshold, now)
columns := buildKanbanColumns(
columnMap[categorization.ColumnOverdue],
columnMap[categorization.ColumnInProgress],
columnMap[categorization.ColumnDueSoon],
columnMap[categorization.ColumnUpcoming],
columnMap[categorization.ColumnCompleted],
)
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
}
// CountByResidenceIDs counts all active tasks across multiple residences in a single query.
// Returns the total count of non-cancelled, non-archived tasks for the given residence IDs.
func (r *TaskRepository) CountByResidenceIDs(residenceIDs []uint) (int64, error) {
if len(residenceIDs) == 0 {
return 0, nil
}
var count int64
err := r.db.Model(&models.Task{}).
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, 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 and its associated images atomically.
// Wrapped in a transaction so that if the completion delete fails, image
// deletions are rolled back as well.
func (r *TaskRepository) DeleteCompletion(id uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// Delete images first
if err := tx.Where("completion_id = ?", id).Delete(&models.TaskCompletionImage{}).Error; err != nil {
log.Error().Err(err).Uint("completion_id", id).Msg("Failed to delete completion images")
return err
}
return tx.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
}
// kanbanColumnColors maps kanban column names to their hex colors.
var kanbanColumnColors = map[string]string{
"overdue_tasks": "#FF3B30",
"in_progress_tasks": "#5856D6",
"due_soon_tasks": "#FF9500",
"upcoming_tasks": "#007AFF",
"completed_tasks": "#34C759",
"cancelled_tasks": "#8E8E93",
}
// KanbanColumnColor returns the hex color for a kanban column name.
func KanbanColumnColor(column string) string {
if color, ok := kanbanColumnColors[column]; ok {
return color
}
return "#34C759" // default to green
}
// completionAggRow is an internal type for scanning aggregated completion data.
type completionAggRow struct {
ResidenceID uint
CompletedFromColumn string
CompletedMonth string
Count int64
}
// GetCompletionSummary returns completion summary data for a single residence.
// Returns total all-time count and monthly breakdowns (by column) for the last 12 months.
func (r *TaskRepository) GetCompletionSummary(residenceID uint, now time.Time, maxPerMonth int) (*responses.CompletionSummary, error) {
// 1. Total all-time completions for this residence
var totalAllTime int64
err := r.db.Model(&models.TaskCompletion{}).
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
Where("task_task.residence_id = ?", residenceID).
Count(&totalAllTime).Error
if err != nil {
return nil, err
}
// 2. Monthly breakdown for last 12 months
startDate := time.Date(now.Year()-1, now.Month(), 1, 0, 0, 0, 0, now.Location())
// Use dialect-appropriate date formatting (PostgreSQL vs SQLite)
dateExpr := "TO_CHAR(task_taskcompletion.completed_at, 'YYYY-MM')"
if r.db.Dialector.Name() == "sqlite" {
dateExpr = "strftime('%Y-%m', task_taskcompletion.completed_at)"
}
var rows []completionAggRow
err = r.db.Model(&models.TaskCompletion{}).
Select(fmt.Sprintf("task_task.residence_id, task_taskcompletion.completed_from_column, %s as completed_month, COUNT(*) as count", dateExpr)).
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
Where("task_task.residence_id = ? AND task_taskcompletion.completed_at >= ?", residenceID, startDate).
Group(fmt.Sprintf("task_task.residence_id, task_taskcompletion.completed_from_column, %s", dateExpr)).
Order("completed_month ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
// Build month map
type monthData struct {
columns map[string]int
total int
}
monthMap := make(map[string]*monthData)
// Initialize all 12 months
for i := 0; i < 12; i++ {
m := startDate.AddDate(0, i, 0)
key := m.Format("2006-01")
monthMap[key] = &monthData{columns: make(map[string]int)}
}
// Populate from query results
totalLast12 := 0
for _, row := range rows {
md, ok := monthMap[row.CompletedMonth]
if !ok {
continue
}
md.columns[row.CompletedFromColumn] = int(row.Count)
md.total += int(row.Count)
totalLast12 += int(row.Count)
}
// Convert to response DTOs
months := make([]responses.MonthlyCompletionSummary, 0, 12)
for i := 0; i < 12; i++ {
m := startDate.AddDate(0, i, 0)
key := m.Format("2006-01")
md := monthMap[key]
completions := make([]responses.ColumnCompletionCount, 0)
for col, count := range md.columns {
completions = append(completions, responses.ColumnCompletionCount{
Column: col,
Color: KanbanColumnColor(col),
Count: count,
})
}
overflow := 0
if md.total > maxPerMonth {
overflow = md.total - maxPerMonth
}
months = append(months, responses.MonthlyCompletionSummary{
Month: key,
Completions: completions,
Total: md.total,
Overflow: overflow,
})
}
return &responses.CompletionSummary{
TotalAllTime: int(totalAllTime),
TotalLast12Months: totalLast12,
Months: months,
}, nil
}
// GetBatchCompletionSummaries returns completion summaries for multiple residences
// in two queries total (one for all-time counts, one for monthly breakdowns),
// instead of 2*N queries when calling GetCompletionSummary per residence.
func (r *TaskRepository) GetBatchCompletionSummaries(residenceIDs []uint, now time.Time, maxPerMonth int) (map[uint]*responses.CompletionSummary, error) {
result := make(map[uint]*responses.CompletionSummary, len(residenceIDs))
if len(residenceIDs) == 0 {
return result, nil
}
// 1. Total all-time completions per residence (single query)
type allTimeRow struct {
ResidenceID uint
Count int64
}
var allTimeRows []allTimeRow
err := r.db.Model(&models.TaskCompletion{}).
Select("task_task.residence_id, COUNT(*) as count").
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
Where("task_task.residence_id IN ?", residenceIDs).
Group("task_task.residence_id").
Scan(&allTimeRows).Error
if err != nil {
return nil, err
}
allTimeMap := make(map[uint]int64, len(allTimeRows))
for _, row := range allTimeRows {
allTimeMap[row.ResidenceID] = row.Count
}
// 2. Monthly breakdown for last 12 months across all residences (single query)
startDate := time.Date(now.Year()-1, now.Month(), 1, 0, 0, 0, 0, now.Location())
dateExpr := "TO_CHAR(task_taskcompletion.completed_at, 'YYYY-MM')"
if r.db.Dialector.Name() == "sqlite" {
dateExpr = "strftime('%Y-%m', task_taskcompletion.completed_at)"
}
var rows []completionAggRow
err = r.db.Model(&models.TaskCompletion{}).
Select(fmt.Sprintf("task_task.residence_id, task_taskcompletion.completed_from_column, %s as completed_month, COUNT(*) as count", dateExpr)).
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
Where("task_task.residence_id IN ? AND task_taskcompletion.completed_at >= ?", residenceIDs, startDate).
Group(fmt.Sprintf("task_task.residence_id, task_taskcompletion.completed_from_column, %s", dateExpr)).
Order("completed_month ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
// 3. Build per-residence summaries
type monthData struct {
columns map[string]int
total int
}
// Initialize all residences with empty month maps
residenceMonths := make(map[uint]map[string]*monthData, len(residenceIDs))
for _, rid := range residenceIDs {
mm := make(map[string]*monthData, 12)
for i := 0; i < 12; i++ {
m := startDate.AddDate(0, i, 0)
key := m.Format("2006-01")
mm[key] = &monthData{columns: make(map[string]int)}
}
residenceMonths[rid] = mm
}
// Populate from query results
residenceLast12 := make(map[uint]int, len(residenceIDs))
for _, row := range rows {
mm, ok := residenceMonths[row.ResidenceID]
if !ok {
continue
}
md, ok := mm[row.CompletedMonth]
if !ok {
continue
}
md.columns[row.CompletedFromColumn] = int(row.Count)
md.total += int(row.Count)
residenceLast12[row.ResidenceID] += int(row.Count)
}
// Convert to response DTOs per residence
for _, rid := range residenceIDs {
mm := residenceMonths[rid]
months := make([]responses.MonthlyCompletionSummary, 0, 12)
for i := 0; i < 12; i++ {
m := startDate.AddDate(0, i, 0)
key := m.Format("2006-01")
md := mm[key]
completions := make([]responses.ColumnCompletionCount, 0)
for col, count := range md.columns {
completions = append(completions, responses.ColumnCompletionCount{
Column: col,
Color: KanbanColumnColor(col),
Count: count,
})
}
overflow := 0
if md.total > maxPerMonth {
overflow = md.total - maxPerMonth
}
months = append(months, responses.MonthlyCompletionSummary{
Month: key,
Completions: completions,
Total: md.total,
Overflow: overflow,
})
}
result[rid] = &responses.CompletionSummary{
TotalAllTime: int(allTimeMap[rid]),
TotalLast12Months: residenceLast12[rid],
Months: months,
}
}
return result, nil
}
// WithContext returns a copy of the repository whose underlying *gorm.DB carries
// the supplied context. SQL emitted via this copy gets attached to ctx's trace span
// (when otelgorm is registered) and respects ctx cancellation/deadlines.
func (r *TaskRepository) WithContext(ctx context.Context) *TaskRepository {
return &TaskRepository{db: r.db.WithContext(ctx)}
}