Files
honeyDueAPI/internal/services/task_service.go
T
Trey t e881d37de0
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
Migrate Auth/Contractor/Document/Notification/Subscription services to ctx
Every public method on these five services now takes ctx context.Context as
the first arg and routes its repo calls through .WithContext(ctx). With
TaskService and ResidenceService already migrated, this means every
in-process service that touches Postgres now produces a flame graph in
Jaeger where the SQL spans nest under the parent HTTP request span.

Endpoints now fully traced (HTTP → service → SQL):
- /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification
- /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile
- /api/contractors/* (CRUD + favorite + by-residence + tasks)
- /api/documents/* (CRUD + activate/deactivate + image upload/delete)
- /api/notifications/* (list, count, mark-read, prefs, devices)
- /api/subscription/* (status, purchase, cancel, triggers, promotions)
- All previously-migrated /api/tasks/* and /api/residences/* paths

Internal helpers also threaded:
- TaskService.sendTaskCompletedNotification → forwards ctx
- TaskService.UpdateUserTimezone → forwards ctx to NotificationService
- ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit
- NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx

~75 method signatures, ~120 handler/test call sites updated. Tests pass
green; the only failure is the pre-existing flaky TaskHandler_QuickComplete
SQLite race that fails ~60% of runs on master.

Step 3 of the observability plan is now genuinely complete: every API
endpoint backed by a Go service emits a per-request flame graph with
HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable.

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

1272 lines
43 KiB
Go

package services
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/dto/requests"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/task/categorization"
)
// Task-related errors (DEPRECATED - kept for reference, use apperrors instead)
// TODO: Migrate handlers to use apperrors instead of these constants
var (
ErrTaskNotFound = errors.New("task not found")
ErrTaskAccessDenied = errors.New("you do not have access to this task")
ErrTaskAlreadyCancelled = errors.New("task is already cancelled")
ErrTaskAlreadyArchived = errors.New("task is already archived")
ErrCompletionNotFound = errors.New("task completion not found")
)
// TaskService handles task business logic
type TaskService struct {
taskRepo *repositories.TaskRepository
residenceRepo *repositories.ResidenceRepository
residenceService *ResidenceService
notificationService *NotificationService
emailService *EmailService
storageService *StorageService
}
// NewTaskService creates a new task service
func NewTaskService(taskRepo *repositories.TaskRepository, residenceRepo *repositories.ResidenceRepository) *TaskService {
return &TaskService{
taskRepo: taskRepo,
residenceRepo: residenceRepo,
}
}
// SetNotificationService sets the notification service (for breaking circular dependency)
func (s *TaskService) SetNotificationService(ns *NotificationService) {
s.notificationService = ns
}
// SetEmailService sets the email service
func (s *TaskService) SetEmailService(es *EmailService) {
s.emailService = es
}
// SetResidenceService sets the residence service (for getting summary in CRUD responses)
func (s *TaskService) SetResidenceService(rs *ResidenceService) {
s.residenceService = rs
}
// SetStorageService sets the storage service (for reading completion images for emails)
func (s *TaskService) SetStorageService(ss *StorageService) {
s.storageService = ss
}
// getSummaryForUser returns an empty summary placeholder.
// DEPRECATED: Summary calculation has been removed from CRUD responses for performance.
// Clients should calculate summary from kanban data instead (which already includes all tasks).
// The summary field is kept in responses for backward compatibility but will always be empty.
func (s *TaskService) getSummaryForUser(_ uint) responses.TotalSummary {
// Return empty summary - clients should calculate from kanban data
return responses.TotalSummary{}
}
// === Task CRUD ===
// GetTask gets a task by ID with access check
func (s *TaskService) GetTask(ctx context.Context, taskID, userID uint) (*responses.TaskResponse, error) {
task, err := s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access via residence
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.task_access_denied")
}
resp := responses.NewTaskResponse(task)
return &resp, nil
}
// ListTasks lists all tasks accessible to a user as a kanban board.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
func (s *TaskService) ListTasks(ctx context.Context, userID uint, daysThreshold int, now time.Time) (*responses.KanbanBoardResponse, error) {
if daysThreshold <= 0 {
daysThreshold = 30 // Default
}
// Get all residence IDs accessible to user (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.WithContext(ctx).FindResidenceIDsByUser(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if len(residenceIDs) == 0 {
// Return empty kanban board
return &responses.KanbanBoardResponse{
Columns: []responses.KanbanColumnResponse{},
DaysThreshold: daysThreshold,
ResidenceID: "all",
}, nil
}
// Get kanban data aggregated across all residences using user's timezone-aware time
board, err := s.taskRepo.WithContext(ctx).GetKanbanDataForMultipleResidences(residenceIDs, daysThreshold, now)
if err != nil {
return nil, apperrors.Internal(err)
}
resp := responses.NewKanbanBoardResponseForAll(board, now)
// NOTE: Summary statistics are calculated client-side from kanban data
return &resp, nil
}
// GetTasksByResidence gets tasks for a specific residence (kanban board).
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
func (s *TaskService) GetTasksByResidence(ctx context.Context, residenceID, userID uint, daysThreshold int, now time.Time) (*responses.KanbanBoardResponse, error) {
// Check access — uses repo.WithContext(ctx) so the SQL span is attached
// to the inbound HTTP request's trace via otelgorm.
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(residenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.residence_access_denied")
}
if daysThreshold <= 0 {
daysThreshold = 30 // Default
}
// Get kanban data using user's timezone-aware time
board, err := s.taskRepo.WithContext(ctx).GetKanbanData(residenceID, daysThreshold, now)
if err != nil {
return nil, apperrors.Internal(err)
}
resp := responses.NewKanbanBoardResponse(board, residenceID, now)
// NOTE: Summary statistics are calculated client-side from kanban data
return &resp, nil
}
// CreateTask creates a new task.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) CreateTask(ctx context.Context, req *requests.CreateTaskRequest, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
// Check residence access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(req.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.residence_access_denied")
}
dueDate := req.DueDate.ToTimePtr()
task := &models.Task{
ResidenceID: req.ResidenceID,
CreatedByID: userID,
Title: req.Title,
Description: req.Description,
CategoryID: req.CategoryID,
PriorityID: req.PriorityID,
FrequencyID: req.FrequencyID,
CustomIntervalDays: req.CustomIntervalDays,
InProgress: req.InProgress,
AssignedToID: req.AssignedToID,
DueDate: dueDate,
NextDueDate: dueDate, // Initialize next_due_date to due_date
EstimatedCost: req.EstimatedCost,
ContractorID: req.ContractorID,
TaskTemplateID: req.TemplateID,
}
if err := s.taskRepo.WithContext(ctx).Create(task); err != nil {
return nil, apperrors.Internal(err)
}
// Reload with relations
task, err = s.taskRepo.WithContext(ctx).FindByID(task.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponseWithTime(task, 30, now),
Summary: s.getSummaryForUser(userID),
}, nil
}
// BulkCreateTasks inserts all tasks in a single transaction. If any task
// fails validation or insert, the entire batch is rolled back. The top-level
// ResidenceID overrides whatever was set on individual entries so that a
// single access check covers the whole batch.
//
// `now` should be the start of day in the user's timezone for accurate
// kanban column categorisation on the returned task list.
func (s *TaskService) BulkCreateTasks(ctx context.Context, req *requests.BulkCreateTasksRequest, userID uint, now time.Time) (*responses.BulkCreateTasksResponse, error) {
if len(req.Tasks) == 0 {
return nil, apperrors.BadRequest("error.task_list_empty")
}
// Check residence access once.
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(req.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.residence_access_denied")
}
createdIDs := make([]uint, 0, len(req.Tasks))
err = s.taskRepo.WithContext(ctx).DB().Transaction(func(tx *gorm.DB) error {
for i := range req.Tasks {
entry := req.Tasks[i]
// Force the residence ID to the batch-level value so clients
// can't straddle residences in one call.
entry.ResidenceID = req.ResidenceID
dueDate := entry.DueDate.ToTimePtr()
task := &models.Task{
ResidenceID: req.ResidenceID,
CreatedByID: userID,
Title: entry.Title,
Description: entry.Description,
CategoryID: entry.CategoryID,
PriorityID: entry.PriorityID,
FrequencyID: entry.FrequencyID,
CustomIntervalDays: entry.CustomIntervalDays,
InProgress: entry.InProgress,
AssignedToID: entry.AssignedToID,
DueDate: dueDate,
NextDueDate: dueDate,
EstimatedCost: entry.EstimatedCost,
ContractorID: entry.ContractorID,
TaskTemplateID: entry.TemplateID,
}
if err := s.taskRepo.WithContext(ctx).CreateTx(tx, task); err != nil {
return fmt.Errorf("create task %d of %d: %w", i+1, len(req.Tasks), err)
}
createdIDs = append(createdIDs, task.ID)
}
return nil
})
if err != nil {
return nil, apperrors.Internal(err)
}
// Reload the just-created tasks with preloads for the response. Reads
// happen outside the transaction — rows are already committed.
created := make([]responses.TaskResponse, 0, len(createdIDs))
for _, id := range createdIDs {
t, ferr := s.taskRepo.WithContext(ctx).FindByID(id)
if ferr != nil {
return nil, apperrors.Internal(ferr)
}
created = append(created, responses.NewTaskResponseWithTime(t, 30, now))
}
return &responses.BulkCreateTasksResponse{
Tasks: created,
Summary: s.getSummaryForUser(userID),
CreatedCount: len(created),
}, nil
}
// UpdateTask updates a task.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) UpdateTask(ctx context.Context, taskID, userID uint, req *requests.UpdateTaskRequest, now time.Time) (*responses.TaskWithSummaryResponse, error) {
task, err := s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.task_access_denied")
}
// Apply updates
if req.Title != nil {
task.Title = *req.Title
}
if req.Description != nil {
task.Description = *req.Description
}
if req.CategoryID != nil {
task.CategoryID = req.CategoryID
}
if req.PriorityID != nil {
task.PriorityID = req.PriorityID
}
if req.FrequencyID != nil {
task.FrequencyID = req.FrequencyID
}
if req.CustomIntervalDays != nil {
task.CustomIntervalDays = req.CustomIntervalDays
}
if req.InProgress != nil {
task.InProgress = *req.InProgress
}
if req.AssignedToID != nil {
task.AssignedToID = req.AssignedToID
}
if req.DueDate != nil {
newDueDate := req.DueDate.ToTimePtr()
task.DueDate = newDueDate
// Always update NextDueDate when user explicitly edits due date.
// Completion logic will recalculate NextDueDate when task is completed,
// but manual edits should take precedence.
task.NextDueDate = newDueDate
}
if req.EstimatedCost != nil {
task.EstimatedCost = req.EstimatedCost
}
if req.ActualCost != nil {
task.ActualCost = req.ActualCost
}
if req.ContractorID != nil {
task.ContractorID = req.ContractorID
}
if err := s.taskRepo.WithContext(ctx).Update(task); err != nil {
if errors.Is(err, repositories.ErrVersionConflict) {
return nil, apperrors.Conflict("error.version_conflict")
}
return nil, apperrors.Internal(err)
}
// Reload
task, err = s.taskRepo.WithContext(ctx).FindByID(task.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponseWithTime(task, 30, now),
Summary: s.getSummaryForUser(userID),
}, nil
}
// DeleteTask deletes a task
func (s *TaskService) DeleteTask(ctx context.Context, taskID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
task, err := s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.task_access_denied")
}
if err := s.taskRepo.WithContext(ctx).Delete(taskID); err != nil {
return nil, apperrors.Internal(err)
}
return &responses.DeleteWithSummaryResponse{
Data: "task deleted",
Summary: s.getSummaryForUser(userID),
}, nil
}
// === Task Actions ===
// MarkInProgress marks a task as in progress.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) MarkInProgress(ctx context.Context, taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
task, err := s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.task_access_denied")
}
if err := s.taskRepo.WithContext(ctx).MarkInProgress(taskID, task.Version); err != nil {
if errors.Is(err, repositories.ErrVersionConflict) {
return nil, apperrors.Conflict("error.version_conflict")
}
return nil, apperrors.Internal(err)
}
// Reload
task, err = s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponseWithTime(task, 30, now),
Summary: s.getSummaryForUser(userID),
}, nil
}
// CancelTask cancels a task.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) CancelTask(ctx context.Context, taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
task, err := s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.task_access_denied")
}
if task.IsCancelled {
return nil, apperrors.BadRequest("error.task_already_cancelled")
}
if err := s.taskRepo.WithContext(ctx).Cancel(taskID, task.Version); err != nil {
if errors.Is(err, repositories.ErrVersionConflict) {
return nil, apperrors.Conflict("error.version_conflict")
}
return nil, apperrors.Internal(err)
}
// Reload
task, err = s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponseWithTime(task, 30, now),
Summary: s.getSummaryForUser(userID),
}, nil
}
// UncancelTask uncancels a task.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) UncancelTask(ctx context.Context, taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
task, err := s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.task_access_denied")
}
if err := s.taskRepo.WithContext(ctx).Uncancel(taskID, task.Version); err != nil {
if errors.Is(err, repositories.ErrVersionConflict) {
return nil, apperrors.Conflict("error.version_conflict")
}
return nil, apperrors.Internal(err)
}
// Reload
task, err = s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponseWithTime(task, 30, now),
Summary: s.getSummaryForUser(userID),
}, nil
}
// ArchiveTask archives a task.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) ArchiveTask(ctx context.Context, taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
task, err := s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.task_access_denied")
}
if task.IsArchived {
return nil, apperrors.BadRequest("error.task_already_archived")
}
if err := s.taskRepo.WithContext(ctx).Archive(taskID, task.Version); err != nil {
if errors.Is(err, repositories.ErrVersionConflict) {
return nil, apperrors.Conflict("error.version_conflict")
}
return nil, apperrors.Internal(err)
}
// Reload
task, err = s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponseWithTime(task, 30, now),
Summary: s.getSummaryForUser(userID),
}, nil
}
// UnarchiveTask unarchives a task.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) UnarchiveTask(ctx context.Context, taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
task, err := s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.task_access_denied")
}
if err := s.taskRepo.WithContext(ctx).Unarchive(taskID, task.Version); err != nil {
if errors.Is(err, repositories.ErrVersionConflict) {
return nil, apperrors.Conflict("error.version_conflict")
}
return nil, apperrors.Internal(err)
}
// Reload
task, err = s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponseWithTime(task, 30, now),
Summary: s.getSummaryForUser(userID),
}, nil
}
// === Task Completions ===
// CreateCompletion creates a task completion.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) CreateCompletion(ctx context.Context, req *requests.CreateTaskCompletionRequest, userID uint, now time.Time) (*responses.TaskCompletionWithSummaryResponse, error) {
// Get the task
task, err := s.taskRepo.WithContext(ctx).FindByID(req.TaskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.task_access_denied")
}
completedAt := time.Now().UTC()
if req.CompletedAt != nil {
completedAt = *req.CompletedAt
}
// Capture the kanban column BEFORE mutating NextDueDate/InProgress,
// so we know what state the task was in when the user completed it.
completedFromColumn := categorization.DetermineKanbanColumnWithTime(task, 30, now)
completion := &models.TaskCompletion{
TaskID: req.TaskID,
CompletedByID: userID,
CompletedAt: completedAt,
Notes: req.Notes,
ActualCost: req.ActualCost,
Rating: req.Rating,
CompletedFromColumn: completedFromColumn,
}
// Determine interval days for NextDueDate calculation before entering the transaction.
// - If frequency is "Once" (days = nil or 0), set next_due_date to nil (marks as completed)
// - If frequency is "Custom", use task.CustomIntervalDays for recurrence
// - If frequency is recurring, calculate next_due_date = completion_date + frequency_days
// and reset in_progress to false so task shows in correct kanban column
//
// Note: Frequency is no longer preloaded for performance, so we load it separately if needed
var intervalDays *int
if task.FrequencyID != nil {
frequency, err := s.taskRepo.WithContext(ctx).GetFrequencyByID(*task.FrequencyID)
if err == nil && frequency != nil {
if frequency.Name == "Custom" {
// Custom frequency - use task's custom_interval_days
intervalDays = task.CustomIntervalDays
} else {
// Standard frequency - use frequency's days
intervalDays = frequency.Days
}
}
}
if intervalDays == nil || *intervalDays == 0 {
// One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions)
task.NextDueDate = nil
task.InProgress = false
} else {
// Recurring task - calculate next due date from completion date + interval
nextDue := completedAt.AddDate(0, 0, *intervalDays)
task.NextDueDate = &nextDue
// Reset in_progress to false so task appears in upcoming/due_soon
// instead of staying in "In Progress" column
task.InProgress = false
}
// P1-5 + B-07: Wrap completion creation, task update, and image creation
// in a single transaction for atomicity. If any operation fails, all are rolled back.
txErr := s.taskRepo.WithContext(ctx).DB().Transaction(func(tx *gorm.DB) error {
if err := s.taskRepo.WithContext(ctx).CreateCompletionTx(tx, completion); err != nil {
return err
}
if err := s.taskRepo.WithContext(ctx).UpdateTx(tx, task); err != nil {
return err
}
// B-07: Create images inside the same transaction as completion
for _, imageURL := range req.ImageURLs {
if imageURL != "" {
img := &models.TaskCompletionImage{
CompletionID: completion.ID,
ImageURL: imageURL,
}
if err := tx.Create(img).Error; err != nil {
return fmt.Errorf("failed to create completion image: %w", err)
}
}
}
return nil
})
if txErr != nil {
// P1-6: Return the error instead of swallowing it.
if errors.Is(txErr, repositories.ErrVersionConflict) {
return nil, apperrors.Conflict("error.version_conflict")
}
log.Error().Err(txErr).Uint("task_id", task.ID).Msg("Failed to create completion and update task")
return nil, apperrors.Internal(txErr)
}
// Reload completion with user info and images
completion, err = s.taskRepo.WithContext(ctx).FindCompletionByID(completion.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
// Reload task with updated completions (so client can update kanban column)
task, err = s.taskRepo.WithContext(ctx).FindByID(req.TaskID)
if err != nil {
// Non-fatal - still return the completion, just without the task
log.Warn().Err(err).Uint("task_id", req.TaskID).Msg("Failed to reload task after completion")
resp := responses.NewTaskCompletionResponse(completion)
return &responses.TaskCompletionWithSummaryResponse{
Data: resp,
Summary: s.getSummaryForUser(userID),
}, nil
}
// Send notification to residence owner and other users
s.sendTaskCompletedNotification(ctx, task, completion)
// Return completion with updated task (includes kanban_column for UI update)
resp := responses.NewTaskCompletionWithTaskResponseWithTime(completion, task, 30, now)
return &responses.TaskCompletionWithSummaryResponse{
Data: resp,
Summary: s.getSummaryForUser(userID),
}, nil
}
// QuickComplete creates a minimal task completion (for widget use).
// LE-01: The entire operation (completion creation + task update) is wrapped in a
// transaction for atomicity.
// Returns only success/error, no response body.
func (s *TaskService) QuickComplete(ctx context.Context, taskID uint, userID uint) error {
// Get the task
task, err := s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperrors.NotFound("error.task_not_found")
}
return apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(task.ResidenceID, userID)
if err != nil {
return apperrors.Internal(err)
}
if !hasAccess {
return apperrors.Forbidden("error.task_access_denied")
}
completedAt := time.Now().UTC()
// Capture kanban column before state mutation
completedFromColumn := categorization.DetermineKanbanColumn(task, 30)
completion := &models.TaskCompletion{
TaskID: taskID,
CompletedByID: userID,
CompletedAt: completedAt,
Notes: "Completed from widget",
CompletedFromColumn: completedFromColumn,
}
// Update next_due_date and in_progress based on frequency
// Determine interval days: Custom frequency uses task.CustomIntervalDays, otherwise use frequency.Days
// Note: Frequency is no longer preloaded for performance, so we load it separately if needed
var quickIntervalDays *int
var frequencyName = "unknown"
if task.FrequencyID != nil {
frequency, err := s.taskRepo.WithContext(ctx).GetFrequencyByID(*task.FrequencyID)
if err == nil && frequency != nil {
frequencyName = frequency.Name
if frequency.Name == "Custom" {
quickIntervalDays = task.CustomIntervalDays
} else {
quickIntervalDays = frequency.Days
}
}
}
if quickIntervalDays == nil || *quickIntervalDays == 0 {
// One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions)
log.Info().
Uint("task_id", task.ID).
Bool("has_frequency", task.FrequencyID != nil).
Msg("QuickComplete: One-time task, clearing next_due_date")
task.NextDueDate = nil
task.InProgress = false
} else {
// Recurring task - calculate next due date from completion date + interval
nextDue := completedAt.AddDate(0, 0, *quickIntervalDays)
log.Info().
Uint("task_id", task.ID).
Str("frequency_name", frequencyName).
Int("interval_days", *quickIntervalDays).
Time("completed_at", completedAt).
Time("next_due_date", nextDue).
Msg("QuickComplete: Recurring task, setting next_due_date")
task.NextDueDate = &nextDue
// Reset in_progress to false
task.InProgress = false
}
// LE-01: Wrap completion creation and task update in a transaction for atomicity
txErr := s.taskRepo.WithContext(ctx).DB().Transaction(func(tx *gorm.DB) error {
if err := s.taskRepo.WithContext(ctx).CreateCompletionTx(tx, completion); err != nil {
return err
}
if err := s.taskRepo.WithContext(ctx).UpdateTx(tx, task); err != nil {
return err
}
return nil
})
if txErr != nil {
if errors.Is(txErr, repositories.ErrVersionConflict) {
return apperrors.Conflict("error.version_conflict")
}
log.Error().Err(txErr).Uint("task_id", task.ID).Msg("Failed to create completion and update task in QuickComplete")
return apperrors.Internal(txErr)
}
log.Info().Uint("task_id", task.ID).Msg("QuickComplete: Task updated successfully")
// Send notification (fire and forget with panic recovery)
go func() {
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Uint("task_id", task.ID).Msg("Panic in quick-complete notification goroutine")
}
}()
s.sendTaskCompletedNotification(ctx, task, completion)
}()
return nil
}
// sendTaskCompletedNotification sends notifications when a task is completed
func (s *TaskService) sendTaskCompletedNotification(ctx context.Context, task *models.Task, completion *models.TaskCompletion) {
// Get all users with access to this residence
users, err := s.residenceRepo.WithContext(ctx).GetResidenceUsers(task.ResidenceID)
if err != nil {
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to get residence users for notification")
return
}
// Get residence name
residence, err := s.residenceRepo.WithContext(ctx).FindByIDSimple(task.ResidenceID)
residenceName := "your property"
if err == nil && residence != nil {
residenceName = residence.Name
}
completedByName := "Someone"
if completion.CompletedBy.ID > 0 {
completedByName = completion.CompletedBy.GetFullName()
}
// Load completion images for email (only if storage service is available)
var emailImages []EmbeddedImage
if s.storageService != nil && len(completion.Images) > 0 {
emailImages = s.loadCompletionImagesForEmail(completion.Images)
}
// Notify all users synchronously to avoid unbounded goroutine spawning.
// This method is already called from a goroutine (QuickComplete) or inline
// (CreateCompletion) where blocking is acceptable for notification delivery.
for _, user := range users {
isCompleter := user.ID == completion.CompletedByID
// Send push notification (to everyone EXCEPT the person who completed it)
if !isCompleter && s.notificationService != nil {
ctx := context.Background()
if err := s.notificationService.CreateAndSendTaskNotification(
ctx,
user.ID,
models.NotificationTaskCompleted,
task,
); err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Uint("task_id", task.ID).Msg("Failed to send task completion push notification")
}
}
// Send email notification (to everyone INCLUDING the person who completed it)
// Check user's email notification preferences first
if s.emailService != nil && user.Email != "" && s.notificationService != nil {
prefs, prefsErr := s.notificationService.GetPreferences(ctx, user.ID)
// LE-06: Log fail-open behavior when preferences cannot be loaded
if prefsErr != nil {
log.Warn().
Err(prefsErr).
Uint("user_id", user.ID).
Uint("task_id", task.ID).
Msg("Failed to load notification preferences, falling back to sending email (fail-open)")
}
if prefsErr != nil || (prefs != nil && prefs.EmailTaskCompleted) {
// Send email if we couldn't get prefs (fail-open) or if email notifications are enabled
if err := s.emailService.SendTaskCompletedEmail(
user.Email,
user.GetFullName(),
task.Title,
completedByName,
residenceName,
emailImages,
); err != nil {
log.Error().Err(err).Str("email", user.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
} else {
log.Info().Str("email", user.Email).Uint("task_id", task.ID).Int("images", len(emailImages)).Msg("Task completion email sent")
}
}
}
}
}
// loadCompletionImagesForEmail reads completion images from disk and prepares them for email embedding.
// Uses StorageService.ReadFile to transparently handle encrypted files.
func (s *TaskService) loadCompletionImagesForEmail(images []models.TaskCompletionImage) []EmbeddedImage {
var emailImages []EmbeddedImage
for i, img := range images {
// Read file via storage service (handles encryption transparently)
data, mimeType, err := s.storageService.ReadFile(img.ImageURL)
if err != nil {
log.Warn().Err(err).Str("image_url", img.ImageURL).Msg("Failed to read completion image for email")
continue
}
// Use detected MIME type, fall back to extension-based detection
if mimeType == "application/octet-stream" {
mimeType = s.getContentTypeFromPath(img.ImageURL)
}
// Create embedded image with unique Content-ID
emailImages = append(emailImages, EmbeddedImage{
ContentID: fmt.Sprintf("completion-image-%d", i+1),
Filename: filepath.Base(img.ImageURL),
ContentType: mimeType,
Data: data,
})
log.Debug().Str("image_url", img.ImageURL).Int("size", len(data)).Msg("Loaded completion image for email")
}
return emailImages
}
// resolveImageFilePath converts a stored URL to an actual file path.
// Returns empty string if the URL is empty or the resolved path would escape
// the upload directory (path traversal attempt).
func (s *TaskService) resolveImageFilePath(storedURL, uploadDir string) string {
if storedURL == "" {
return ""
}
// Strip legacy /uploads/ prefix to get relative path
relativePath := storedURL
if strings.HasPrefix(storedURL, "/uploads/") {
relativePath = strings.TrimPrefix(storedURL, "/uploads/")
}
// Use SafeResolvePath to validate containment within upload directory
resolved, err := SafeResolvePath(uploadDir, relativePath)
if err != nil {
// Path traversal or invalid path — return empty to signal file not found
return ""
}
return resolved
}
// getContentTypeFromPath returns the MIME type based on file extension
func (s *TaskService) getContentTypeFromPath(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
default:
return "application/octet-stream"
}
}
// GetCompletion gets a task completion by ID
func (s *TaskService) GetCompletion(ctx context.Context, completionID, userID uint) (*responses.TaskCompletionResponse, error) {
completion, err := s.taskRepo.WithContext(ctx).FindCompletionByID(completionID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.completion_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access via task's residence
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(completion.Task.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.task_access_denied")
}
resp := responses.NewTaskCompletionResponse(completion)
return &resp, nil
}
// ListCompletions lists all task completions for a user
func (s *TaskService) ListCompletions(ctx context.Context, userID uint) ([]responses.TaskCompletionResponse, error) {
// Get all residence IDs (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.WithContext(ctx).FindResidenceIDsByUser(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if len(residenceIDs) == 0 {
return []responses.TaskCompletionResponse{}, nil
}
completions, err := s.taskRepo.WithContext(ctx).FindCompletionsByUser(userID, residenceIDs)
if err != nil {
return nil, apperrors.Internal(err)
}
return responses.NewTaskCompletionListResponse(completions), nil
}
// UpdateCompletion updates an existing task completion
func (s *TaskService) UpdateCompletion(ctx context.Context, completionID, userID uint, req *requests.UpdateTaskCompletionRequest) (*responses.TaskCompletionResponse, error) {
completion, err := s.taskRepo.WithContext(ctx).FindCompletionByID(completionID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.completion_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access via task's residence
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(completion.Task.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.task_access_denied")
}
// Apply updates
if req.Notes != nil {
completion.Notes = *req.Notes
}
if req.ActualCost != nil {
completion.ActualCost = req.ActualCost
}
if req.Rating != nil {
completion.Rating = req.Rating
}
if err := s.taskRepo.WithContext(ctx).UpdateCompletion(completion); err != nil {
return nil, apperrors.Internal(err)
}
// Add any new images
for _, imageURL := range req.ImageURLs {
image := &models.TaskCompletionImage{
CompletionID: completion.ID,
ImageURL: imageURL,
}
if err := s.taskRepo.WithContext(ctx).CreateCompletionImage(image); err != nil {
log.Error().Err(err).Uint("completion_id", completion.ID).Msg("Failed to create completion image during update")
}
}
// Reload to get full associations
updated, err := s.taskRepo.WithContext(ctx).FindCompletionByID(completionID)
if err != nil {
return nil, apperrors.Internal(err)
}
resp := responses.NewTaskCompletionResponse(updated)
return &resp, nil
}
// DeleteCompletion deletes a task completion and recalculates the task's NextDueDate.
//
// P1-7: After deleting a completion, NextDueDate must be recalculated:
// - If no completions remain: restore NextDueDate = DueDate (original schedule)
// - If completions remain (recurring): recalculate from latest remaining completion + frequency days
func (s *TaskService) DeleteCompletion(ctx context.Context, completionID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
completion, err := s.taskRepo.WithContext(ctx).FindCompletionByID(completionID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.completion_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(completion.Task.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.task_access_denied")
}
taskID := completion.TaskID
if err := s.taskRepo.WithContext(ctx).DeleteCompletion(completionID); err != nil {
return nil, apperrors.Internal(err)
}
// Recalculate NextDueDate based on remaining completions
task, err := s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
// Non-fatal for the delete operation itself, but log the error
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to reload task after completion deletion for NextDueDate recalculation")
return &responses.DeleteWithSummaryResponse{
Data: "completion deleted",
Summary: s.getSummaryForUser(userID),
}, nil
}
// Get remaining completions for this task
remainingCompletions, err := s.taskRepo.WithContext(ctx).FindCompletionsByTask(taskID)
if err != nil {
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to query remaining completions after deletion")
return &responses.DeleteWithSummaryResponse{
Data: "completion deleted",
Summary: s.getSummaryForUser(userID),
}, nil
}
// Determine the task's frequency interval
var intervalDays *int
if task.FrequencyID != nil {
frequency, freqErr := s.taskRepo.WithContext(ctx).GetFrequencyByID(*task.FrequencyID)
if freqErr == nil && frequency != nil {
if frequency.Name == "Custom" {
intervalDays = task.CustomIntervalDays
} else {
intervalDays = frequency.Days
}
}
}
if len(remainingCompletions) == 0 {
// No completions remain: restore NextDueDate to the original DueDate
task.NextDueDate = task.DueDate
} else if intervalDays != nil && *intervalDays > 0 {
// Recurring task with remaining completions: recalculate from the latest completion
// remainingCompletions is ordered by completed_at DESC, so index 0 is the latest
latestCompletion := remainingCompletions[0]
nextDue := latestCompletion.CompletedAt.AddDate(0, 0, *intervalDays)
task.NextDueDate = &nextDue
} else {
// One-time task with remaining completions (unusual case): keep NextDueDate as nil
// since the task is still considered completed
task.NextDueDate = nil
}
if err := s.taskRepo.WithContext(ctx).Update(task); err != nil {
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to update task NextDueDate after completion deletion")
// The completion was already deleted; return success but log the update failure
}
return &responses.DeleteWithSummaryResponse{
Data: "completion deleted",
Summary: s.getSummaryForUser(userID),
}, nil
}
// GetCompletionsByTask gets all completions for a specific task
func (s *TaskService) GetCompletionsByTask(ctx context.Context, taskID, userID uint) ([]responses.TaskCompletionResponse, error) {
// Get the task to check access
task, err := s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access via residence
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.task_access_denied")
}
// Get completions for the task
completions, err := s.taskRepo.WithContext(ctx).FindCompletionsByTask(taskID)
if err != nil {
return nil, apperrors.Internal(err)
}
return responses.NewTaskCompletionListResponse(completions), nil
}
// === Lookups ===
// GetCategories returns all task categories
func (s *TaskService) GetCategories(ctx context.Context) ([]responses.TaskCategoryResponse, error) {
categories, err := s.taskRepo.WithContext(ctx).GetAllCategories()
if err != nil {
return nil, apperrors.Internal(err)
}
result := make([]responses.TaskCategoryResponse, len(categories))
for i, c := range categories {
result[i] = *responses.NewTaskCategoryResponse(&c)
}
return result, nil
}
// GetPriorities returns all task priorities
func (s *TaskService) GetPriorities(ctx context.Context) ([]responses.TaskPriorityResponse, error) {
priorities, err := s.taskRepo.WithContext(ctx).GetAllPriorities()
if err != nil {
return nil, apperrors.Internal(err)
}
result := make([]responses.TaskPriorityResponse, len(priorities))
for i, p := range priorities {
result[i] = *responses.NewTaskPriorityResponse(&p)
}
return result, nil
}
// GetFrequencies returns all task frequencies
func (s *TaskService) GetFrequencies(ctx context.Context) ([]responses.TaskFrequencyResponse, error) {
frequencies, err := s.taskRepo.WithContext(ctx).GetAllFrequencies()
if err != nil {
return nil, apperrors.Internal(err)
}
result := make([]responses.TaskFrequencyResponse, len(frequencies))
for i, f := range frequencies {
result[i] = *responses.NewTaskFrequencyResponse(&f)
}
return result, nil
}
// === Timezone ===
// UpdateUserTimezone updates the user's timezone for background job calculations.
// This is called from handlers when the X-Timezone header is present.
// Delegates to NotificationService since timezone is stored in notification preferences.
func (s *TaskService) UpdateUserTimezone(ctx context.Context, userID uint, timezone string) {
if s.notificationService != nil {
s.notificationService.UpdateUserTimezone(ctx, userID, timezone)
}
}