65a9aae4e5
Every public method on TaskService and ResidenceService now takes ctx context.Context as the first arg and routes its repo calls through .WithContext(ctx). With otelgorm registered, this means every API endpoint backed by these two services produces a flame graph in Jaeger where the SQL spans nest under the parent HTTP request span — instead of appearing as orphaned queries. Endpoints now fully traced (HTTP → service → SQL): - GET /api/tasks/ (already shipped) - GET /api/tasks/by-residence/:id/ (already shipped) - GET /api/tasks/:id/ - POST /api/tasks/ - POST /api/tasks/bulk/ - PUT /api/tasks/:id/ - DELETE /api/tasks/:id/ - POST /api/tasks/:id/in-progress/ - POST /api/tasks/:id/cancel/ - POST /api/tasks/:id/uncancel/ - POST /api/tasks/:id/archive/ - POST /api/tasks/:id/unarchive/ - POST /api/tasks/:id/complete/ - POST /api/tasks/:id/quick-complete/ - GET /api/tasks/completions/* (CRUD) - GET /api/static_data/ (categories, priorities, frequencies) - GET /api/residences/ - GET /api/residences/my/ - GET /api/residences/summary/ - GET /api/residences/:id/ - POST /api/residences/ - PUT /api/residences/:id/ - DELETE /api/residences/:id/ - Share-code + member management endpoints - GET /api/residences/:id/report/ Mechanical work: ~50 method signatures, ~80 handler call sites, ~25 test call sites updated. Internal sendTaskCompletedNotification helper also takes ctx so background notification SQL nests correctly. The remaining services (ContractorService, DocumentService, AuthService, NotificationService, SubscriptionService) follow the same pattern; they continue to emit untraced SQL until migrated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1272 lines
43 KiB
Go
1272 lines
43 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(ctx context.Context, taskID, userID uint) (*responses.TaskResponse, error) {
|
|
task, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, 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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, residenceID, userID uint, daysThreshold int, now time.Time) (*responses.KanbanBoardResponse, error) {
|
|
// Check access — uses repo.WithContext(ctx) so the SQL span is attached
|
|
// to the inbound HTTP request's trace via otelgorm.
|
|
hasAccess, err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, req *requests.CreateTaskRequest, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
// Check residence access
|
|
hasAccess, err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).Create(task); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload with relations
|
|
task, err = s.taskRepo.WithContext(ctx).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(ctx context.Context, 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.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, taskID, userID uint, req *requests.UpdateTaskRequest, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, taskID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, req *requests.CreateTaskCompletionRequest, userID uint, now time.Time) (*responses.TaskCompletionWithSummaryResponse, error) {
|
|
// Get the task
|
|
task, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).DB().Transaction(func(tx *gorm.DB) error {
|
|
if err := s.taskRepo.WithContext(ctx).CreateCompletionTx(tx, completion); err != nil {
|
|
return err
|
|
}
|
|
if err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx, 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(ctx context.Context, taskID uint, userID uint) error {
|
|
// Get the task
|
|
task, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).DB().Transaction(func(tx *gorm.DB) error {
|
|
if err := s.taskRepo.WithContext(ctx).CreateCompletionTx(tx, completion); err != nil {
|
|
return err
|
|
}
|
|
if err := s.taskRepo.WithContext(ctx).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(ctx, task, completion)
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// sendTaskCompletedNotification sends notifications when a task is completed
|
|
func (s *TaskService) sendTaskCompletedNotification(ctx context.Context, task *models.Task, completion *models.TaskCompletion) {
|
|
// Get all users with access to this residence
|
|
users, err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, completionID, userID uint) (*responses.TaskCompletionResponse, error) {
|
|
completion, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, userID uint) ([]responses.TaskCompletionResponse, error) {
|
|
// Get all residence IDs (lightweight - no preloads)
|
|
residenceIDs, err := s.residenceRepo.WithContext(ctx).FindResidenceIDsByUser(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
if len(residenceIDs) == 0 {
|
|
return []responses.TaskCompletionResponse{}, nil
|
|
}
|
|
|
|
completions, err := s.taskRepo.WithContext(ctx).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(ctx context.Context, completionID, userID uint, req *requests.UpdateTaskCompletionRequest) (*responses.TaskCompletionResponse, error) {
|
|
completion, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, completionID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
|
|
completion, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).DeleteCompletion(completionID); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Recalculate NextDueDate based on remaining completions
|
|
task, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, taskID, userID uint) ([]responses.TaskCompletionResponse, error) {
|
|
// Get the task to check access
|
|
task, err := s.taskRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context) ([]responses.TaskCategoryResponse, error) {
|
|
categories, err := s.taskRepo.WithContext(ctx).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(ctx context.Context) ([]responses.TaskPriorityResponse, error) {
|
|
priorities, err := s.taskRepo.WithContext(ctx).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(ctx context.Context) ([]responses.TaskFrequencyResponse, error) {
|
|
frequencies, err := s.taskRepo.WithContext(ctx).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)
|
|
}
|
|
}
|