Some checks failed
Clients that send users through a multi-task onboarding step no longer loop N POST /api/tasks/ calls and no longer create "orphan" tasks with no reference to the TaskTemplate they came from. Task model - New task_template_id column + GORM FK (migration 000016) - CreateTaskRequest.template_id, TaskResponse.template_id - task_service.CreateTask persists the backlink Bulk endpoint - POST /api/tasks/bulk/ — 1-50 tasks in a single transaction, returns every created row + TotalSummary. Single residence access check, per-entry residence_id is overridden with batch value - task_handler.BulkCreateTasks + task_service.BulkCreateTasks using db.Transaction; task_repo.CreateTx + FindByIDTx helpers Climate-region scoring - templateConditions gains ClimateRegionID; suggestion_service scores residence.PostalCode -> ZipToState -> GetClimateRegionIDByState against the template's conditions JSON (no penalty on mismatch / unknown ZIP) - regionMatchBonus 0.35, totalProfileFields 14 -> 15 - Standalone GET /api/tasks/templates/by-region/ removed; legacy task_tasktemplate_regions many-to-many dropped (migration 000017). Region affinity now lives entirely in the template's conditions JSON Tests - +11 cases across task_service_test, task_handler_test, suggestion_ service_test: template_id persistence, bulk rollback + cap + auth, region match / mismatch / no-ZIP / unknown-ZIP / stacks-with-others Docs - docs/openapi.yaml: /tasks/bulk/ + BulkCreateTasks schemas, template_id on TaskResponse + CreateTaskRequest, /templates/by-region/ removed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1271 lines
41 KiB
Go
1271 lines
41 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/apperrors"
|
|
"github.com/treytartt/honeydue-api/internal/dto/requests"
|
|
"github.com/treytartt/honeydue-api/internal/dto/responses"
|
|
"github.com/treytartt/honeydue-api/internal/models"
|
|
"github.com/treytartt/honeydue-api/internal/repositories"
|
|
"github.com/treytartt/honeydue-api/internal/task/categorization"
|
|
)
|
|
|
|
// Task-related errors (DEPRECATED - kept for reference, use apperrors instead)
|
|
// TODO: Migrate handlers to use apperrors instead of these constants
|
|
var (
|
|
ErrTaskNotFound = errors.New("task not found")
|
|
ErrTaskAccessDenied = errors.New("you do not have access to this task")
|
|
ErrTaskAlreadyCancelled = errors.New("task is already cancelled")
|
|
ErrTaskAlreadyArchived = errors.New("task is already archived")
|
|
ErrCompletionNotFound = errors.New("task completion not found")
|
|
)
|
|
|
|
// TaskService handles task business logic
|
|
type TaskService struct {
|
|
taskRepo *repositories.TaskRepository
|
|
residenceRepo *repositories.ResidenceRepository
|
|
residenceService *ResidenceService
|
|
notificationService *NotificationService
|
|
emailService *EmailService
|
|
storageService *StorageService
|
|
}
|
|
|
|
// NewTaskService creates a new task service
|
|
func NewTaskService(taskRepo *repositories.TaskRepository, residenceRepo *repositories.ResidenceRepository) *TaskService {
|
|
return &TaskService{
|
|
taskRepo: taskRepo,
|
|
residenceRepo: residenceRepo,
|
|
}
|
|
}
|
|
|
|
// SetNotificationService sets the notification service (for breaking circular dependency)
|
|
func (s *TaskService) SetNotificationService(ns *NotificationService) {
|
|
s.notificationService = ns
|
|
}
|
|
|
|
// SetEmailService sets the email service
|
|
func (s *TaskService) SetEmailService(es *EmailService) {
|
|
s.emailService = es
|
|
}
|
|
|
|
// SetResidenceService sets the residence service (for getting summary in CRUD responses)
|
|
func (s *TaskService) SetResidenceService(rs *ResidenceService) {
|
|
s.residenceService = rs
|
|
}
|
|
|
|
// SetStorageService sets the storage service (for reading completion images for emails)
|
|
func (s *TaskService) SetStorageService(ss *StorageService) {
|
|
s.storageService = ss
|
|
}
|
|
|
|
// getSummaryForUser returns an empty summary placeholder.
|
|
// DEPRECATED: Summary calculation has been removed from CRUD responses for performance.
|
|
// Clients should calculate summary from kanban data instead (which already includes all tasks).
|
|
// The summary field is kept in responses for backward compatibility but will always be empty.
|
|
func (s *TaskService) getSummaryForUser(_ uint) responses.TotalSummary {
|
|
// Return empty summary - clients should calculate from kanban data
|
|
return responses.TotalSummary{}
|
|
}
|
|
|
|
// === Task CRUD ===
|
|
|
|
// GetTask gets a task by ID with access check
|
|
func (s *TaskService) GetTask(taskID, userID uint) (*responses.TaskResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access via residence
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
resp := responses.NewTaskResponse(task)
|
|
return &resp, nil
|
|
}
|
|
|
|
// ListTasks lists all tasks accessible to a user as a kanban board.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
|
func (s *TaskService) ListTasks(userID uint, daysThreshold int, now time.Time) (*responses.KanbanBoardResponse, error) {
|
|
if daysThreshold <= 0 {
|
|
daysThreshold = 30 // Default
|
|
}
|
|
|
|
// Get all residence IDs accessible to user (lightweight - no preloads)
|
|
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
if len(residenceIDs) == 0 {
|
|
// Return empty kanban board
|
|
return &responses.KanbanBoardResponse{
|
|
Columns: []responses.KanbanColumnResponse{},
|
|
DaysThreshold: daysThreshold,
|
|
ResidenceID: "all",
|
|
}, nil
|
|
}
|
|
|
|
// Get kanban data aggregated across all residences using user's timezone-aware time
|
|
board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, daysThreshold, now)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
resp := responses.NewKanbanBoardResponseForAll(board, now)
|
|
// 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, now)
|
|
// 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,
|
|
TaskTemplateID: req.TemplateID,
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// BulkCreateTasks inserts all tasks in a single transaction. If any task
|
|
// fails validation or insert, the entire batch is rolled back. The top-level
|
|
// ResidenceID overrides whatever was set on individual entries so that a
|
|
// single access check covers the whole batch.
|
|
//
|
|
// `now` should be the start of day in the user's timezone for accurate
|
|
// kanban column categorisation on the returned task list.
|
|
func (s *TaskService) BulkCreateTasks(req *requests.BulkCreateTasksRequest, userID uint, now time.Time) (*responses.BulkCreateTasksResponse, error) {
|
|
if len(req.Tasks) == 0 {
|
|
return nil, apperrors.BadRequest("error.task_list_empty")
|
|
}
|
|
|
|
// Check residence access once.
|
|
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")
|
|
}
|
|
|
|
createdIDs := make([]uint, 0, len(req.Tasks))
|
|
|
|
err = s.taskRepo.DB().Transaction(func(tx *gorm.DB) error {
|
|
for i := range req.Tasks {
|
|
entry := req.Tasks[i]
|
|
// Force the residence ID to the batch-level value so clients
|
|
// can't straddle residences in one call.
|
|
entry.ResidenceID = req.ResidenceID
|
|
|
|
dueDate := entry.DueDate.ToTimePtr()
|
|
task := &models.Task{
|
|
ResidenceID: req.ResidenceID,
|
|
CreatedByID: userID,
|
|
Title: entry.Title,
|
|
Description: entry.Description,
|
|
CategoryID: entry.CategoryID,
|
|
PriorityID: entry.PriorityID,
|
|
FrequencyID: entry.FrequencyID,
|
|
CustomIntervalDays: entry.CustomIntervalDays,
|
|
InProgress: entry.InProgress,
|
|
AssignedToID: entry.AssignedToID,
|
|
DueDate: dueDate,
|
|
NextDueDate: dueDate,
|
|
EstimatedCost: entry.EstimatedCost,
|
|
ContractorID: entry.ContractorID,
|
|
TaskTemplateID: entry.TemplateID,
|
|
}
|
|
if err := s.taskRepo.CreateTx(tx, task); err != nil {
|
|
return fmt.Errorf("create task %d of %d: %w", i+1, len(req.Tasks), err)
|
|
}
|
|
createdIDs = append(createdIDs, task.ID)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload the just-created tasks with preloads for the response. Reads
|
|
// happen outside the transaction — rows are already committed.
|
|
created := make([]responses.TaskResponse, 0, len(createdIDs))
|
|
for _, id := range createdIDs {
|
|
t, ferr := s.taskRepo.FindByID(id)
|
|
if ferr != nil {
|
|
return nil, apperrors.Internal(ferr)
|
|
}
|
|
created = append(created, responses.NewTaskResponseWithTime(t, 30, now))
|
|
}
|
|
|
|
return &responses.BulkCreateTasksResponse{
|
|
Tasks: created,
|
|
Summary: s.getSummaryForUser(userID),
|
|
CreatedCount: len(created),
|
|
}, nil
|
|
}
|
|
|
|
// UpdateTask updates a task.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
// Apply updates
|
|
if req.Title != nil {
|
|
task.Title = *req.Title
|
|
}
|
|
if req.Description != nil {
|
|
task.Description = *req.Description
|
|
}
|
|
if req.CategoryID != nil {
|
|
task.CategoryID = req.CategoryID
|
|
}
|
|
if req.PriorityID != nil {
|
|
task.PriorityID = req.PriorityID
|
|
}
|
|
if req.FrequencyID != nil {
|
|
task.FrequencyID = req.FrequencyID
|
|
}
|
|
if req.CustomIntervalDays != nil {
|
|
task.CustomIntervalDays = req.CustomIntervalDays
|
|
}
|
|
if req.InProgress != nil {
|
|
task.InProgress = *req.InProgress
|
|
}
|
|
if req.AssignedToID != nil {
|
|
task.AssignedToID = req.AssignedToID
|
|
}
|
|
if req.DueDate != nil {
|
|
newDueDate := req.DueDate.ToTimePtr()
|
|
task.DueDate = newDueDate
|
|
// Always update NextDueDate when user explicitly edits due date.
|
|
// Completion logic will recalculate NextDueDate when task is completed,
|
|
// but manual edits should take precedence.
|
|
task.NextDueDate = newDueDate
|
|
}
|
|
if req.EstimatedCost != nil {
|
|
task.EstimatedCost = req.EstimatedCost
|
|
}
|
|
if req.ActualCost != nil {
|
|
task.ActualCost = req.ActualCost
|
|
}
|
|
if req.ContractorID != nil {
|
|
task.ContractorID = req.ContractorID
|
|
}
|
|
|
|
if err := s.taskRepo.Update(task); err != nil {
|
|
if errors.Is(err, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(task.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.TaskWithSummaryResponse{
|
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// DeleteTask deletes a task
|
|
func (s *TaskService) DeleteTask(taskID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
if err := s.taskRepo.Delete(taskID); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.DeleteWithSummaryResponse{
|
|
Data: "task deleted",
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// === Task Actions ===
|
|
|
|
// MarkInProgress marks a task as in progress.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) MarkInProgress(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
if err := s.taskRepo.MarkInProgress(taskID, task.Version); err != nil {
|
|
if errors.Is(err, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.TaskWithSummaryResponse{
|
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// CancelTask cancels a task.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) CancelTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
if task.IsCancelled {
|
|
return nil, apperrors.BadRequest("error.task_already_cancelled")
|
|
}
|
|
|
|
if err := s.taskRepo.Cancel(taskID, task.Version); err != nil {
|
|
if errors.Is(err, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.TaskWithSummaryResponse{
|
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// UncancelTask uncancels a task.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) UncancelTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
if err := s.taskRepo.Uncancel(taskID, task.Version); err != nil {
|
|
if errors.Is(err, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.TaskWithSummaryResponse{
|
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// ArchiveTask archives a task.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) ArchiveTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
if task.IsArchived {
|
|
return nil, apperrors.BadRequest("error.task_already_archived")
|
|
}
|
|
|
|
if err := s.taskRepo.Archive(taskID, task.Version); err != nil {
|
|
if errors.Is(err, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.TaskWithSummaryResponse{
|
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// UnarchiveTask unarchives a task.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) UnarchiveTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
if err := s.taskRepo.Unarchive(taskID, task.Version); err != nil {
|
|
if errors.Is(err, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.TaskWithSummaryResponse{
|
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// === Task Completions ===
|
|
|
|
// CreateCompletion creates a task completion.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint, now time.Time) (*responses.TaskCompletionWithSummaryResponse, error) {
|
|
// Get the task
|
|
task, err := s.taskRepo.FindByID(req.TaskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
completedAt := time.Now().UTC()
|
|
if req.CompletedAt != nil {
|
|
completedAt = *req.CompletedAt
|
|
}
|
|
|
|
// Capture the kanban column BEFORE mutating NextDueDate/InProgress,
|
|
// so we know what state the task was in when the user completed it.
|
|
completedFromColumn := categorization.DetermineKanbanColumnWithTime(task, 30, now)
|
|
|
|
completion := &models.TaskCompletion{
|
|
TaskID: req.TaskID,
|
|
CompletedByID: userID,
|
|
CompletedAt: completedAt,
|
|
Notes: req.Notes,
|
|
ActualCost: req.ActualCost,
|
|
Rating: req.Rating,
|
|
CompletedFromColumn: completedFromColumn,
|
|
}
|
|
|
|
// Determine interval days for NextDueDate calculation before entering the transaction.
|
|
// - If frequency is "Once" (days = nil or 0), set next_due_date to nil (marks as completed)
|
|
// - If frequency is "Custom", use task.CustomIntervalDays for recurrence
|
|
// - If frequency is recurring, calculate next_due_date = completion_date + frequency_days
|
|
// and reset in_progress to false so task shows in correct kanban column
|
|
//
|
|
// Note: Frequency is no longer preloaded for performance, so we load it separately if needed
|
|
var intervalDays *int
|
|
if task.FrequencyID != nil {
|
|
frequency, err := s.taskRepo.GetFrequencyByID(*task.FrequencyID)
|
|
if err == nil && frequency != nil {
|
|
if frequency.Name == "Custom" {
|
|
// Custom frequency - use task's custom_interval_days
|
|
intervalDays = task.CustomIntervalDays
|
|
} else {
|
|
// Standard frequency - use frequency's days
|
|
intervalDays = frequency.Days
|
|
}
|
|
}
|
|
}
|
|
|
|
if intervalDays == nil || *intervalDays == 0 {
|
|
// One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions)
|
|
task.NextDueDate = nil
|
|
task.InProgress = false
|
|
} else {
|
|
// Recurring task - calculate next due date from completion date + interval
|
|
nextDue := completedAt.AddDate(0, 0, *intervalDays)
|
|
task.NextDueDate = &nextDue
|
|
|
|
// Reset in_progress to false so task appears in upcoming/due_soon
|
|
// instead of staying in "In Progress" column
|
|
task.InProgress = false
|
|
}
|
|
|
|
// P1-5 + B-07: Wrap completion creation, task update, and image creation
|
|
// in a single transaction for atomicity. If any operation fails, all are rolled back.
|
|
txErr := s.taskRepo.DB().Transaction(func(tx *gorm.DB) error {
|
|
if err := s.taskRepo.CreateCompletionTx(tx, completion); err != nil {
|
|
return err
|
|
}
|
|
if err := s.taskRepo.UpdateTx(tx, task); err != nil {
|
|
return err
|
|
}
|
|
// B-07: Create images inside the same transaction as completion
|
|
for _, imageURL := range req.ImageURLs {
|
|
if imageURL != "" {
|
|
img := &models.TaskCompletionImage{
|
|
CompletionID: completion.ID,
|
|
ImageURL: imageURL,
|
|
}
|
|
if err := tx.Create(img).Error; err != nil {
|
|
return fmt.Errorf("failed to create completion image: %w", err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if txErr != nil {
|
|
// P1-6: Return the error instead of swallowing it.
|
|
if errors.Is(txErr, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
log.Error().Err(txErr).Uint("task_id", task.ID).Msg("Failed to create completion and update task")
|
|
return nil, apperrors.Internal(txErr)
|
|
}
|
|
|
|
// 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).
|
|
// LE-01: The entire operation (completion creation + task update) is wrapped in a
|
|
// transaction for atomicity.
|
|
// 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()
|
|
|
|
// Capture kanban column before state mutation
|
|
completedFromColumn := categorization.DetermineKanbanColumn(task, 30)
|
|
|
|
completion := &models.TaskCompletion{
|
|
TaskID: taskID,
|
|
CompletedByID: userID,
|
|
CompletedAt: completedAt,
|
|
Notes: "Completed from widget",
|
|
CompletedFromColumn: completedFromColumn,
|
|
}
|
|
|
|
// 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)
|
|
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
|
|
}
|
|
|
|
// LE-01: Wrap completion creation and task update in a transaction for atomicity
|
|
txErr := s.taskRepo.DB().Transaction(func(tx *gorm.DB) error {
|
|
if err := s.taskRepo.CreateCompletionTx(tx, completion); err != nil {
|
|
return err
|
|
}
|
|
if err := s.taskRepo.UpdateTx(tx, task); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
if txErr != nil {
|
|
if errors.Is(txErr, repositories.ErrVersionConflict) {
|
|
return apperrors.Conflict("error.version_conflict")
|
|
}
|
|
log.Error().Err(txErr).Uint("task_id", task.ID).Msg("Failed to create completion and update task in QuickComplete")
|
|
return apperrors.Internal(txErr)
|
|
}
|
|
log.Info().Uint("task_id", task.ID).Msg("QuickComplete: Task updated successfully")
|
|
|
|
// Send notification (fire and forget with panic recovery)
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Error().Interface("panic", r).Uint("task_id", task.ID).Msg("Panic in quick-complete notification goroutine")
|
|
}
|
|
}()
|
|
s.sendTaskCompletedNotification(task, completion)
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// sendTaskCompletedNotification sends notifications when a task is completed
|
|
func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completion *models.TaskCompletion) {
|
|
// Get all users with access to this residence
|
|
users, err := s.residenceRepo.GetResidenceUsers(task.ResidenceID)
|
|
if err != nil {
|
|
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to get residence users for notification")
|
|
return
|
|
}
|
|
|
|
// Get residence name
|
|
residence, err := s.residenceRepo.FindByIDSimple(task.ResidenceID)
|
|
residenceName := "your property"
|
|
if err == nil && residence != nil {
|
|
residenceName = residence.Name
|
|
}
|
|
|
|
completedByName := "Someone"
|
|
if completion.CompletedBy.ID > 0 {
|
|
completedByName = completion.CompletedBy.GetFullName()
|
|
}
|
|
|
|
// Load completion images for email (only if storage service is available)
|
|
var emailImages []EmbeddedImage
|
|
if s.storageService != nil && len(completion.Images) > 0 {
|
|
emailImages = s.loadCompletionImagesForEmail(completion.Images)
|
|
}
|
|
|
|
// Notify all users synchronously to avoid unbounded goroutine spawning.
|
|
// This method is already called from a goroutine (QuickComplete) or inline
|
|
// (CreateCompletion) where blocking is acceptable for notification delivery.
|
|
for _, user := range users {
|
|
isCompleter := user.ID == completion.CompletedByID
|
|
|
|
// Send push notification (to everyone EXCEPT the person who completed it)
|
|
if !isCompleter && s.notificationService != nil {
|
|
ctx := context.Background()
|
|
if err := s.notificationService.CreateAndSendTaskNotification(
|
|
ctx,
|
|
user.ID,
|
|
models.NotificationTaskCompleted,
|
|
task,
|
|
); err != nil {
|
|
log.Error().Err(err).Uint("user_id", user.ID).Uint("task_id", task.ID).Msg("Failed to send task completion push notification")
|
|
}
|
|
}
|
|
|
|
// Send email notification (to everyone INCLUDING the person who completed it)
|
|
// Check user's email notification preferences first
|
|
if s.emailService != nil && user.Email != "" && s.notificationService != nil {
|
|
prefs, prefsErr := s.notificationService.GetPreferences(user.ID)
|
|
// LE-06: Log fail-open behavior when preferences cannot be loaded
|
|
if prefsErr != nil {
|
|
log.Warn().
|
|
Err(prefsErr).
|
|
Uint("user_id", user.ID).
|
|
Uint("task_id", task.ID).
|
|
Msg("Failed to load notification preferences, falling back to sending email (fail-open)")
|
|
}
|
|
if prefsErr != nil || (prefs != nil && prefs.EmailTaskCompleted) {
|
|
// Send email if we couldn't get prefs (fail-open) or if email notifications are enabled
|
|
if err := s.emailService.SendTaskCompletedEmail(
|
|
user.Email,
|
|
user.GetFullName(),
|
|
task.Title,
|
|
completedByName,
|
|
residenceName,
|
|
emailImages,
|
|
); err != nil {
|
|
log.Error().Err(err).Str("email", user.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
|
|
} else {
|
|
log.Info().Str("email", user.Email).Uint("task_id", task.ID).Int("images", len(emailImages)).Msg("Task completion email sent")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// loadCompletionImagesForEmail reads completion images from disk and prepares them for email embedding.
|
|
// Uses StorageService.ReadFile to transparently handle encrypted files.
|
|
func (s *TaskService) loadCompletionImagesForEmail(images []models.TaskCompletionImage) []EmbeddedImage {
|
|
var emailImages []EmbeddedImage
|
|
|
|
for i, img := range images {
|
|
// Read file via storage service (handles encryption transparently)
|
|
data, mimeType, err := s.storageService.ReadFile(img.ImageURL)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("image_url", img.ImageURL).Msg("Failed to read completion image for email")
|
|
continue
|
|
}
|
|
|
|
// Use detected MIME type, fall back to extension-based detection
|
|
if mimeType == "application/octet-stream" {
|
|
mimeType = s.getContentTypeFromPath(img.ImageURL)
|
|
}
|
|
|
|
// Create embedded image with unique Content-ID
|
|
emailImages = append(emailImages, EmbeddedImage{
|
|
ContentID: fmt.Sprintf("completion-image-%d", i+1),
|
|
Filename: filepath.Base(img.ImageURL),
|
|
ContentType: mimeType,
|
|
Data: data,
|
|
})
|
|
|
|
log.Debug().Str("image_url", img.ImageURL).Int("size", len(data)).Msg("Loaded completion image for email")
|
|
}
|
|
|
|
return emailImages
|
|
}
|
|
|
|
// resolveImageFilePath converts a stored URL to an actual file path.
|
|
// Returns empty string if the URL is empty or the resolved path would escape
|
|
// the upload directory (path traversal attempt).
|
|
func (s *TaskService) resolveImageFilePath(storedURL, uploadDir string) string {
|
|
if storedURL == "" {
|
|
return ""
|
|
}
|
|
|
|
// Strip legacy /uploads/ prefix to get relative path
|
|
relativePath := storedURL
|
|
if strings.HasPrefix(storedURL, "/uploads/") {
|
|
relativePath = strings.TrimPrefix(storedURL, "/uploads/")
|
|
}
|
|
|
|
// Use SafeResolvePath to validate containment within upload directory
|
|
resolved, err := SafeResolvePath(uploadDir, relativePath)
|
|
if err != nil {
|
|
// Path traversal or invalid path — return empty to signal file not found
|
|
return ""
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
|
|
// getContentTypeFromPath returns the MIME type based on file extension
|
|
func (s *TaskService) getContentTypeFromPath(path string) string {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
switch ext {
|
|
case ".jpg", ".jpeg":
|
|
return "image/jpeg"
|
|
case ".png":
|
|
return "image/png"
|
|
case ".gif":
|
|
return "image/gif"
|
|
case ".webp":
|
|
return "image/webp"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
// GetCompletion gets a task completion by ID
|
|
func (s *TaskService) GetCompletion(completionID, userID uint) (*responses.TaskCompletionResponse, error) {
|
|
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.completion_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access via task's residence
|
|
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
resp := responses.NewTaskCompletionResponse(completion)
|
|
return &resp, nil
|
|
}
|
|
|
|
// ListCompletions lists all task completions for a user
|
|
func (s *TaskService) ListCompletions(userID uint) ([]responses.TaskCompletionResponse, error) {
|
|
// Get all residence IDs (lightweight - no preloads)
|
|
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
if len(residenceIDs) == 0 {
|
|
return []responses.TaskCompletionResponse{}, nil
|
|
}
|
|
|
|
completions, err := s.taskRepo.FindCompletionsByUser(userID, residenceIDs)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return responses.NewTaskCompletionListResponse(completions), nil
|
|
}
|
|
|
|
// UpdateCompletion updates an existing task completion
|
|
func (s *TaskService) UpdateCompletion(completionID, userID uint, req *requests.UpdateTaskCompletionRequest) (*responses.TaskCompletionResponse, error) {
|
|
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.completion_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access via task's residence
|
|
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
// Apply updates
|
|
if req.Notes != nil {
|
|
completion.Notes = *req.Notes
|
|
}
|
|
if req.ActualCost != nil {
|
|
completion.ActualCost = req.ActualCost
|
|
}
|
|
if req.Rating != nil {
|
|
completion.Rating = req.Rating
|
|
}
|
|
|
|
if err := s.taskRepo.UpdateCompletion(completion); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Add any new images
|
|
for _, imageURL := range req.ImageURLs {
|
|
image := &models.TaskCompletionImage{
|
|
CompletionID: completion.ID,
|
|
ImageURL: imageURL,
|
|
}
|
|
if err := s.taskRepo.CreateCompletionImage(image); err != nil {
|
|
log.Error().Err(err).Uint("completion_id", completion.ID).Msg("Failed to create completion image during update")
|
|
}
|
|
}
|
|
|
|
// Reload to get full associations
|
|
updated, err := s.taskRepo.FindCompletionByID(completionID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
resp := responses.NewTaskCompletionResponse(updated)
|
|
return &resp, nil
|
|
}
|
|
|
|
// DeleteCompletion deletes a task completion and recalculates the task's NextDueDate.
|
|
//
|
|
// P1-7: After deleting a completion, NextDueDate must be recalculated:
|
|
// - If no completions remain: restore NextDueDate = DueDate (original schedule)
|
|
// - If completions remain (recurring): recalculate from latest remaining completion + frequency days
|
|
func (s *TaskService) DeleteCompletion(completionID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
|
|
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.completion_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
taskID := completion.TaskID
|
|
|
|
if err := s.taskRepo.DeleteCompletion(completionID); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Recalculate NextDueDate based on remaining completions
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
// Non-fatal for the delete operation itself, but log the error
|
|
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to reload task after completion deletion for NextDueDate recalculation")
|
|
return &responses.DeleteWithSummaryResponse{
|
|
Data: "completion deleted",
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// Get remaining completions for this task
|
|
remainingCompletions, err := s.taskRepo.FindCompletionsByTask(taskID)
|
|
if err != nil {
|
|
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to query remaining completions after deletion")
|
|
return &responses.DeleteWithSummaryResponse{
|
|
Data: "completion deleted",
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// Determine the task's frequency interval
|
|
var intervalDays *int
|
|
if task.FrequencyID != nil {
|
|
frequency, freqErr := s.taskRepo.GetFrequencyByID(*task.FrequencyID)
|
|
if freqErr == nil && frequency != nil {
|
|
if frequency.Name == "Custom" {
|
|
intervalDays = task.CustomIntervalDays
|
|
} else {
|
|
intervalDays = frequency.Days
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(remainingCompletions) == 0 {
|
|
// No completions remain: restore NextDueDate to the original DueDate
|
|
task.NextDueDate = task.DueDate
|
|
} else if intervalDays != nil && *intervalDays > 0 {
|
|
// Recurring task with remaining completions: recalculate from the latest completion
|
|
// remainingCompletions is ordered by completed_at DESC, so index 0 is the latest
|
|
latestCompletion := remainingCompletions[0]
|
|
nextDue := latestCompletion.CompletedAt.AddDate(0, 0, *intervalDays)
|
|
task.NextDueDate = &nextDue
|
|
} else {
|
|
// One-time task with remaining completions (unusual case): keep NextDueDate as nil
|
|
// since the task is still considered completed
|
|
task.NextDueDate = nil
|
|
}
|
|
|
|
if err := s.taskRepo.Update(task); err != nil {
|
|
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to update task NextDueDate after completion deletion")
|
|
// The completion was already deleted; return success but log the update failure
|
|
}
|
|
|
|
return &responses.DeleteWithSummaryResponse{
|
|
Data: "completion deleted",
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// GetCompletionsByTask gets all completions for a specific task
|
|
func (s *TaskService) GetCompletionsByTask(taskID, userID uint) ([]responses.TaskCompletionResponse, error) {
|
|
// Get the task to check access
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access via residence
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
// Get completions for the task
|
|
completions, err := s.taskRepo.FindCompletionsByTask(taskID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return responses.NewTaskCompletionListResponse(completions), nil
|
|
}
|
|
|
|
// === Lookups ===
|
|
|
|
// GetCategories returns all task categories
|
|
func (s *TaskService) GetCategories() ([]responses.TaskCategoryResponse, error) {
|
|
categories, err := s.taskRepo.GetAllCategories()
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
result := make([]responses.TaskCategoryResponse, len(categories))
|
|
for i, c := range categories {
|
|
result[i] = *responses.NewTaskCategoryResponse(&c)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetPriorities returns all task priorities
|
|
func (s *TaskService) GetPriorities() ([]responses.TaskPriorityResponse, error) {
|
|
priorities, err := s.taskRepo.GetAllPriorities()
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
result := make([]responses.TaskPriorityResponse, len(priorities))
|
|
for i, p := range priorities {
|
|
result[i] = *responses.NewTaskPriorityResponse(&p)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetFrequencies returns all task frequencies
|
|
func (s *TaskService) GetFrequencies() ([]responses.TaskFrequencyResponse, error) {
|
|
frequencies, err := s.taskRepo.GetAllFrequencies()
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
result := make([]responses.TaskFrequencyResponse, len(frequencies))
|
|
for i, f := range frequencies {
|
|
result[i] = *responses.NewTaskFrequencyResponse(&f)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// === Timezone ===
|
|
|
|
// UpdateUserTimezone updates the user's timezone for background job calculations.
|
|
// This is called from handlers when the X-Timezone header is present.
|
|
// Delegates to NotificationService since timezone is stored in notification preferences.
|
|
func (s *TaskService) UpdateUserTimezone(userID uint, timezone string) {
|
|
if s.notificationService != nil {
|
|
s.notificationService.UpdateUserTimezone(userID, timezone)
|
|
}
|
|
}
|