package services import ( "context" "errors" "fmt" "time" "github.com/rs/zerolog/log" "gorm.io/gorm" "github.com/treytartt/mycrib-api/internal/dto/requests" "github.com/treytartt/mycrib-api/internal/dto/responses" "github.com/treytartt/mycrib-api/internal/models" "github.com/treytartt/mycrib-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 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 } // === 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.TaskResponse, error) { // Check residence access hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID) if err != nil { return nil, err } if !hasAccess { return nil, ErrResidenceAccessDenied } 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: req.DueDate.ToTimePtr(), 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 } resp := responses.NewTaskResponse(task) return &resp, nil } // UpdateTask updates a task func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest) (*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 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 { task.DueDate = req.DueDate.ToTimePtr() } 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 } resp := responses.NewTaskResponse(task) return &resp, nil } // DeleteTask deletes a task func (s *TaskService) DeleteTask(taskID, userID uint) error { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrTaskNotFound } return err } // Check access hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID) if err != nil { return err } if !hasAccess { return ErrTaskAccessDenied } return s.taskRepo.Delete(taskID) } // === Task Actions === // MarkInProgress marks a task as in progress func (s *TaskService) MarkInProgress(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 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 } resp := responses.NewTaskResponse(task) return &resp, nil } // CancelTask cancels a task func (s *TaskService) CancelTask(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 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 } resp := responses.NewTaskResponse(task) return &resp, nil } // UncancelTask uncancels a task func (s *TaskService) UncancelTask(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 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 } resp := responses.NewTaskResponse(task) return &resp, nil } // ArchiveTask archives a task func (s *TaskService) ArchiveTask(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 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 } resp := responses.NewTaskResponse(task) return &resp, nil } // UnarchiveTask unarchives a task func (s *TaskService) UnarchiveTask(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 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 } resp := responses.NewTaskResponse(task) return &resp, nil } // === Task Completions === // CreateCompletion creates a task completion func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint) (*responses.TaskCompletionResponse, 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, PhotoURL: req.PhotoURL, } if err := s.taskRepo.CreateCompletion(completion); err != nil { return nil, err } // Reload completion with user info completion, err = s.taskRepo.FindCompletionByID(completion.ID) if err != nil { return nil, err } // Send notification to residence owner and other users s.sendTaskCompletedNotification(task, completion) resp := responses.NewTaskCompletionResponse(completion) return &resp, 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() } title := "Task Completed" body := fmt.Sprintf("%s completed: %s", completedByName, task.Title) data := map[string]interface{}{ "task_id": task.ID, "residence_id": task.ResidenceID, "completion_id": completion.ID, } // 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.CreateAndSendNotification( ctx, userID, models.NotificationTaskCompleted, title, body, data, ); 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) if s.emailService != nil && user.Email != "" { 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) error { completion, err := s.taskRepo.FindCompletionByID(completionID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrCompletionNotFound } return err } // Check access hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID) if err != nil { return err } if !hasAccess { return ErrTaskAccessDenied } return s.taskRepo.DeleteCompletion(completionID) } // 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 }