bc3da007db
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>
1068 lines
36 KiB
Go
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)}
|
|
}
|