package services import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "time" "github.com/rs/zerolog/log" "gorm.io/gorm" "github.com/treytartt/casera-api/internal/apperrors" "github.com/treytartt/casera-api/internal/dto/requests" "github.com/treytartt/casera-api/internal/dto/responses" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/repositories" ) // 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(taskID, userID uint) (*responses.TaskResponse, error) { task, err := s.taskRepo.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.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(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.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.GetKanbanDataForMultipleResidences(residenceIDs, daysThreshold, now) if err != nil { return nil, apperrors.Internal(err) } resp := responses.NewKanbanBoardResponseForAll(board) // 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(residenceID, userID uint, daysThreshold int, now time.Time) (*responses.KanbanBoardResponse, error) { // Check access hasAccess, err := s.residenceRepo.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.GetKanbanData(residenceID, daysThreshold, now) if err != nil { return nil, apperrors.Internal(err) } resp := responses.NewKanbanBoardResponse(board, residenceID) // 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(req *requests.CreateTaskRequest, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) { // Check residence access hasAccess, err := s.residenceRepo.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, } if err := s.taskRepo.Create(task); err != nil { return nil, apperrors.Internal(err) } // Reload with relations task, err = s.taskRepo.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 } // 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(taskID, userID uint, req *requests.UpdateTaskRequest, now time.Time) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.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.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.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.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(taskID, userID uint) (*responses.DeleteWithSummaryResponse, error) { task, err := s.taskRepo.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.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.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(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.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.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.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.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(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.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.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.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.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(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.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.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.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.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(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.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.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.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.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(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.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.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.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.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(req *requests.CreateTaskCompletionRequest, userID uint, now time.Time) (*responses.TaskCompletionWithSummaryResponse, error) { // Get the task task, err := s.taskRepo.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.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 } completion := &models.TaskCompletion{ TaskID: req.TaskID, CompletedByID: userID, CompletedAt: completedAt, Notes: req.Notes, ActualCost: req.ActualCost, Rating: req.Rating, } // 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.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: Wrap completion creation and task update in a transaction. // If either operation fails, both are rolled back to prevent orphaned completions. txErr := s.taskRepo.DB().Transaction(func(tx *gorm.DB) error { if err := s.taskRepo.CreateCompletionTx(tx, completion); err != nil { return err } if err := s.taskRepo.UpdateTx(tx, task); err != nil { return 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) } // Create images if provided for _, imageURL := range req.ImageURLs { if imageURL != "" { img := &models.TaskCompletionImage{ CompletionID: completion.ID, ImageURL: imageURL, } if err := s.taskRepo.CreateCompletionImage(img); err != nil { log.Error().Err(err).Uint("completion_id", completion.ID).Msg("Failed to create completion image") } } } // Reload completion with user info and images completion, err = s.taskRepo.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.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(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) // Returns only success/error, no response body func (s *TaskService) QuickComplete(taskID uint, userID uint) error { // Get the task task, err := s.taskRepo.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.HasAccess(task.ResidenceID, userID) if err != nil { return apperrors.Internal(err) } if !hasAccess { return apperrors.Forbidden("error.task_access_denied") } completedAt := time.Now().UTC() completion := &models.TaskCompletion{ TaskID: taskID, CompletedByID: userID, CompletedAt: completedAt, Notes: "Completed from widget", } if err := s.taskRepo.CreateCompletion(completion); err != nil { return apperrors.Internal(err) } // 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.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) // frequencyName was already set when loading frequency above 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 } if err := s.taskRepo.Update(task); err != nil { if errors.Is(err, repositories.ErrVersionConflict) { return apperrors.Conflict("error.version_conflict") } log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after quick completion") return apperrors.Internal(err) // Return error so caller knows the update failed } 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(task, completion) }() return nil } // sendTaskCompletedNotification sends notifications when a task is completed func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completion *models.TaskCompletion) { // Get all users with access to this residence users, err := s.residenceRepo.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.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, err := s.notificationService.GetPreferences(user.ID) if err != 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 func (s *TaskService) loadCompletionImagesForEmail(images []models.TaskCompletionImage) []EmbeddedImage { var emailImages []EmbeddedImage uploadDir := s.storageService.GetUploadDir() for i, img := range images { // Resolve file path from stored URL filePath := s.resolveImageFilePath(img.ImageURL, uploadDir) if filePath == "" { log.Warn().Str("image_url", img.ImageURL).Msg("Could not resolve image file path") continue } // Read file from disk data, err := os.ReadFile(filePath) if err != nil { log.Warn().Err(err).Str("path", filePath).Msg("Failed to read completion image for email") continue } // Determine content type from extension contentType := s.getContentTypeFromPath(filePath) // Create embedded image with unique Content-ID emailImages = append(emailImages, EmbeddedImage{ ContentID: fmt.Sprintf("completion-image-%d", i+1), Filename: filepath.Base(filePath), ContentType: contentType, Data: data, }) log.Debug().Str("path", filePath).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(completionID, userID uint) (*responses.TaskCompletionResponse, error) { completion, err := s.taskRepo.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.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(userID uint) ([]responses.TaskCompletionResponse, error) { // Get all residence IDs (lightweight - no preloads) residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID) if err != nil { return nil, apperrors.Internal(err) } if len(residenceIDs) == 0 { return []responses.TaskCompletionResponse{}, nil } completions, err := s.taskRepo.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(completionID, userID uint, req *requests.UpdateTaskCompletionRequest) (*responses.TaskCompletionResponse, error) { completion, err := s.taskRepo.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.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.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.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.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(completionID, userID uint) (*responses.DeleteWithSummaryResponse, error) { completion, err := s.taskRepo.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.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.DeleteCompletion(completionID); err != nil { return nil, apperrors.Internal(err) } // Recalculate NextDueDate based on remaining completions task, err := s.taskRepo.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.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.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.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(taskID, userID uint) ([]responses.TaskCompletionResponse, error) { // Get the task to check access task, err := s.taskRepo.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.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.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() ([]responses.TaskCategoryResponse, error) { categories, err := s.taskRepo.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() ([]responses.TaskPriorityResponse, error) { priorities, err := s.taskRepo.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() ([]responses.TaskFrequencyResponse, error) { frequencies, err := s.taskRepo.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(userID uint, timezone string) { if s.notificationService != nil { s.notificationService.UpdateUserTimezone(userID, timezone) } }