Task creation/update responses were using UTC time for kanban column categorization, causing tasks to incorrectly appear as overdue when the server had passed midnight UTC but the user's local time was still the previous day. Changes: - Add timezone-aware response functions (NewTaskResponseWithTime, etc.) - Pass userNow from middleware to all task service methods - Update handlers to use timezone-aware time from X-Timezone header - Update tests to pass the now parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
913 lines
26 KiB
Go
913 lines
26 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 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, 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.
|
|
// 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, 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, err
|
|
}
|
|
|
|
resp := responses.NewKanbanBoardResponseForAll(board)
|
|
// Include summary for dashboard stats
|
|
summary := s.getSummaryForUser(userID)
|
|
resp.Summary = &summary
|
|
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, err
|
|
}
|
|
if !hasAccess {
|
|
return nil, ErrResidenceAccessDenied
|
|
}
|
|
|
|
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, err
|
|
}
|
|
|
|
resp := responses.NewKanbanBoardResponse(board, residenceID)
|
|
// Include summary for dashboard stats
|
|
summary := s.getSummaryForUser(userID)
|
|
resp.Summary = &summary
|
|
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, 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,
|
|
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, err
|
|
}
|
|
|
|
// Reload with relations
|
|
task, err = s.taskRepo.FindByID(task.ID)
|
|
if err != nil {
|
|
return nil, 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, 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.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
|
|
// 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.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, 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.
|
|
// 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, 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.MarkInProgress(taskID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, 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, 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.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, 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.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, 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.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, 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.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, 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 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, 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 ErrTaskNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !hasAccess {
|
|
return ErrTaskAccessDenied
|
|
}
|
|
|
|
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 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 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()
|
|
}
|
|
|
|
// 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 (lightweight - no preloads)
|
|
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|