Files
honeyDueAPI/internal/services/task_service.go
Trey t 1a48fbfb20 Include TotalSummary in CRUD responses to eliminate redundant API calls
Backend changes:
- Add WithSummaryResponse wrappers for Task, TaskCompletion, and Residence CRUD
- Update services to return summary with all mutations (create, update, delete)
- Update handlers to pass through new response types
- Add getSummaryForUser helper for fetching summary in CRUD operations
- Wire ResidenceService into TaskService for summary access
- Add summary field to JoinResidenceResponse

This optimization eliminates the need for a separate getSummary() call after
every task/residence mutation, reducing network calls from 2 to 1.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 10:39:33 -06:00

823 lines
22 KiB
Go

package services
import (
"context"
"errors"
"time"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"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
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
}
// 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
}
// getSummaryForUser gets the total summary for a user (helper for CRUD responses)
func (s *TaskService) getSummaryForUser(userID uint) responses.TotalSummary {
if s.residenceService == nil {
return responses.TotalSummary{}
}
summary, err := s.residenceService.GetSummary(userID)
if err != nil || summary == nil {
return responses.TotalSummary{}
}
return *summary
}
// === 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, ErrTaskNotFound
}
return nil, err
}
// Check access via residence
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrTaskAccessDenied
}
resp := responses.NewTaskResponse(task)
return &resp, nil
}
// ListTasks lists all tasks accessible to a user as a kanban board
func (s *TaskService) ListTasks(userID uint) (*responses.KanbanBoardResponse, error) {
// Get all residence IDs accessible to user
residences, err := s.residenceRepo.FindByUser(userID)
if err != nil {
return nil, err
}
residenceIDs := make([]uint, len(residences))
for i, r := range residences {
residenceIDs[i] = r.ID
}
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
board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, 30)
if err != nil {
return nil, err
}
resp := responses.NewKanbanBoardResponseForAll(board)
return &resp, nil
}
// GetTasksByResidence gets tasks for a specific residence (kanban board)
func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshold int) (*responses.KanbanBoardResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrResidenceAccessDenied
}
if daysThreshold <= 0 {
daysThreshold = 30 // Default
}
board, err := s.taskRepo.GetKanbanData(residenceID, daysThreshold)
if err != nil {
return nil, err
}
resp := responses.NewKanbanBoardResponse(board, residenceID)
return &resp, nil
}
// CreateTask creates a new task
func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (*responses.TaskWithSummaryResponse, error) {
// Check residence access
hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrResidenceAccessDenied
}
dueDate := req.DueDate.ToTimePtr()
task := &models.Task{
ResidenceID: req.ResidenceID,
CreatedByID: userID,
Title: req.Title,
Description: req.Description,
CategoryID: req.CategoryID,
PriorityID: req.PriorityID,
StatusID: req.StatusID,
FrequencyID: req.FrequencyID,
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, err
}
// Reload with relations
task, err = s.taskRepo.FindByID(task.ID)
if err != nil {
return nil, err
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponse(task),
Summary: s.getSummaryForUser(userID),
}, nil
}
// UpdateTask updates a task
func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest) (*responses.TaskWithSummaryResponse, error) {
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
}
return nil, err
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrTaskAccessDenied
}
// 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.StatusID != nil {
task.StatusID = req.StatusID
}
if req.FrequencyID != nil {
task.FrequencyID = req.FrequencyID
}
if req.AssignedToID != nil {
task.AssignedToID = req.AssignedToID
}
if req.DueDate != nil {
newDueDate := req.DueDate.ToTimePtr()
task.DueDate = newDueDate
// Also update NextDueDate if the task doesn't have completions yet
// (if it has completions, NextDueDate should be managed by completion logic)
if len(task.Completions) == 0 {
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, err
}
// Reload
task, err = s.taskRepo.FindByID(task.ID)
if err != nil {
return nil, err
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponse(task),
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, ErrTaskNotFound
}
return nil, err
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrTaskAccessDenied
}
if err := s.taskRepo.Delete(taskID); err != nil {
return nil, err
}
return &responses.DeleteWithSummaryResponse{
Data: "task deleted",
Summary: s.getSummaryForUser(userID),
}, nil
}
// === Task Actions ===
// MarkInProgress marks a task as in progress
func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
}
return nil, err
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrTaskAccessDenied
}
// Find "In Progress" status
status, err := s.taskRepo.FindStatusByName("In Progress")
if err != nil {
return nil, err
}
if err := s.taskRepo.MarkInProgress(taskID, status.ID); err != nil {
return nil, err
}
// Reload
task, err = s.taskRepo.FindByID(taskID)
if err != nil {
return nil, err
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponse(task),
Summary: s.getSummaryForUser(userID),
}, nil
}
// CancelTask cancels a task
func (s *TaskService) CancelTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
}
return nil, err
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrTaskAccessDenied
}
if task.IsCancelled {
return nil, ErrTaskAlreadyCancelled
}
if err := s.taskRepo.Cancel(taskID); err != nil {
return nil, err
}
// Reload
task, err = s.taskRepo.FindByID(taskID)
if err != nil {
return nil, err
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponse(task),
Summary: s.getSummaryForUser(userID),
}, nil
}
// UncancelTask uncancels a task
func (s *TaskService) UncancelTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
}
return nil, err
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrTaskAccessDenied
}
if err := s.taskRepo.Uncancel(taskID); err != nil {
return nil, err
}
// Reload
task, err = s.taskRepo.FindByID(taskID)
if err != nil {
return nil, err
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponse(task),
Summary: s.getSummaryForUser(userID),
}, nil
}
// ArchiveTask archives a task
func (s *TaskService) ArchiveTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
}
return nil, err
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrTaskAccessDenied
}
if task.IsArchived {
return nil, ErrTaskAlreadyArchived
}
if err := s.taskRepo.Archive(taskID); err != nil {
return nil, err
}
// Reload
task, err = s.taskRepo.FindByID(taskID)
if err != nil {
return nil, err
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponse(task),
Summary: s.getSummaryForUser(userID),
}, nil
}
// UnarchiveTask unarchives a task
func (s *TaskService) UnarchiveTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
}
return nil, err
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrTaskAccessDenied
}
if err := s.taskRepo.Unarchive(taskID); err != nil {
return nil, err
}
// Reload
task, err = s.taskRepo.FindByID(taskID)
if err != nil {
return nil, err
}
return &responses.TaskWithSummaryResponse{
Data: responses.NewTaskResponse(task),
Summary: s.getSummaryForUser(userID),
}, nil
}
// === Task Completions ===
// CreateCompletion creates a task completion
func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint) (*responses.TaskCompletionWithSummaryResponse, error) {
// Get the task
task, err := s.taskRepo.FindByID(req.TaskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
}
return nil, err
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrTaskAccessDenied
}
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, err
}
// Update next_due_date and status based on frequency
// - If frequency is "Once" (days = nil or 0), set next_due_date to nil and status to "Completed"
// - If frequency is recurring, calculate next_due_date = completion_date + frequency_days
// and reset status to "Pending" so task shows in correct kanban column
if task.Frequency == nil || task.Frequency.Days == nil || *task.Frequency.Days == 0 {
// One-time task - clear next_due_date and set status to "Completed" (ID=3)
task.NextDueDate = nil
completedStatusID := uint(3)
task.StatusID = &completedStatusID
} else {
// Recurring task - calculate next due date from completion date + frequency
nextDue := completedAt.AddDate(0, 0, *task.Frequency.Days)
task.NextDueDate = &nextDue
// Reset status to "Pending" (ID=1) so task appears in upcoming/due_soon
// instead of staying in "In Progress" column
pendingStatusID := uint(1)
task.StatusID = &pendingStatusID
}
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, 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.NewTaskCompletionWithTaskResponse(completion, task, 30)
return &responses.TaskCompletionWithSummaryResponse{
Data: resp,
Summary: s.getSummaryForUser(userID),
}, 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()
}
// 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) {
if err := s.emailService.SendTaskCompletedEmail(
u.Email,
u.GetFullName(),
task.Title,
completedByName,
residenceName,
); 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).Msg("Task completion email sent")
}
}(user)
}
}
}
}
// 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, ErrCompletionNotFound
}
return nil, err
}
// Check access via task's residence
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrTaskAccessDenied
}
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
residences, err := s.residenceRepo.FindByUser(userID)
if err != nil {
return nil, err
}
residenceIDs := make([]uint, len(residences))
for i, r := range residences {
residenceIDs[i] = r.ID
}
if len(residenceIDs) == 0 {
return []responses.TaskCompletionResponse{}, nil
}
completions, err := s.taskRepo.FindCompletionsByUser(userID, residenceIDs)
if err != nil {
return nil, 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, ErrCompletionNotFound
}
return nil, err
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrTaskAccessDenied
}
if err := s.taskRepo.DeleteCompletion(completionID); err != nil {
return nil, 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, ErrTaskNotFound
}
return nil, err
}
// Check access via residence
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrTaskAccessDenied
}
// Get completions for the task
completions, err := s.taskRepo.FindCompletionsByTask(taskID)
if err != nil {
return nil, 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, 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, err
}
result := make([]responses.TaskPriorityResponse, len(priorities))
for i, p := range priorities {
result[i] = *responses.NewTaskPriorityResponse(&p)
}
return result, nil
}
// GetStatuses returns all task statuses
func (s *TaskService) GetStatuses() ([]responses.TaskStatusResponse, error) {
statuses, err := s.taskRepo.GetAllStatuses()
if err != nil {
return nil, err
}
result := make([]responses.TaskStatusResponse, len(statuses))
for i, st := range statuses {
result[i] = *responses.NewTaskStatusResponse(&st)
}
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, err
}
result := make([]responses.TaskFrequencyResponse, len(frequencies))
for i, f := range frequencies {
result[i] = *responses.NewTaskFrequencyResponse(&f)
}
return result, nil
}