Files
honeyDueAPI/internal/services/task_service.go
Trey t c2e8800312 Fix task due date update not persisting for tasks with completions
When a user explicitly edited a task's due date, the backend was only
updating NextDueDate if the task had no completions. For recurring tasks
with completions, this caused the UI to show stale NextDueDate values
since effectiveDueDate prioritizes NextDueDate over DueDate.

Now always updates NextDueDate when user explicitly edits due date.
Completion logic will still recalculate NextDueDate when task is completed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:50:36 -06:00

1009 lines
31 KiB
Go

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, now time.Time) (*responses.KanbanBoardResponse, error) {
// 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: 30,
ResidenceID: "all",
}, nil
}
// Get kanban data aggregated across all residences using user's timezone-aware time
board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, 30, 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 {
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); err != nil {
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); err != nil {
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); err != nil {
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); err != nil {
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); err != nil {
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,
}
if err := s.taskRepo.CreateCompletion(completion); err != nil {
return nil, apperrors.Internal(err)
}
// Update next_due_date and in_progress based on frequency
// - 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
}
if err := s.taskRepo.Update(task); err != nil {
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after completion")
}
// 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 {
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)
go 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
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 {
go func(userID uint) {
ctx := context.Background()
if err := s.notificationService.CreateAndSendTaskNotification(
ctx,
userID,
models.NotificationTaskCompleted,
task,
); err != nil {
log.Error().Err(err).Uint("user_id", userID).Uint("task_id", task.ID).Msg("Failed to send task completion push notification")
}
}(user.ID)
}
// 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
go func(u models.User, images []EmbeddedImage) {
if err := s.emailService.SendTaskCompletedEmail(
u.Email,
u.GetFullName(),
task.Title,
completedByName,
residenceName,
images,
); err != nil {
log.Error().Err(err).Str("email", u.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
} else {
log.Info().Str("email", u.Email).Uint("task_id", task.ID).Int("images", len(images)).Msg("Task completion email sent")
}
}(user, emailImages)
}
}
}
}
// 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
func (s *TaskService) resolveImageFilePath(storedURL, uploadDir string) string {
if storedURL == "" {
return ""
}
// Handle /uploads/... URLs
if strings.HasPrefix(storedURL, "/uploads/") {
relativePath := strings.TrimPrefix(storedURL, "/uploads/")
return filepath.Join(uploadDir, relativePath)
}
// Handle relative paths
return filepath.Join(uploadDir, storedURL)
}
// 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
}
// DeleteCompletion deletes a task completion
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")
}
if err := s.taskRepo.DeleteCompletion(completionID); err != nil {
return nil, apperrors.Internal(err)
}
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)
}
}