Files
honeyDueAPI/internal/services/task_service.go
T
Trey t 52bf1ff3c7
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
perf(task): offload completion notification fan-out to Asynq worker
POST /api/task-completions/ was spending ~1.5-1.75s synchronously on
APNs push + SMTP email + B2 image fetches inside sendTaskCompletedNotification.
Per-user loop made it scale linearly with residence membership; one image
attached + one residence user is the 1.75s baseline observed in the live
honeydue-eli5-overview Grafana panel.

Replace the inline call (and the fire-and-forget goroutine in QuickComplete,
which violated the project's "no goroutines in handlers" rule) with an
Asynq job:

  - new task type notification:task_completed (worker/scheduler.go)
  - new payload {task_id, completion_id} — IDs only, worker re-reads
    canonical state from Postgres so concurrent edits between enqueue
    and dequeue are reflected
  - new HandleTaskCompletedNotification on jobs.Handler delegates to
    TaskService.SendTaskCompletedNotificationByID
  - new dispatchTaskCompletedNotification in task_service.go picks
    between enqueue (preferred) and inline (fallback) when Redis is
    unreachable or the enqueuer isn't wired (tests / local dev)

Other changes required to wire it up:

  - widen worker.NewTaskClient signature to accept asynq.RedisClientOpt
    so the file-mounted Redis password (audit HIGH-1) can be supplied;
    no prior callers, no breakage
  - extend worker.Enqueuer interface with EnqueueTaskCompletedNotification
  - add TaskEnqueuer field to router.Dependencies; wire from cmd/api/main.go
    with the standard typed-nil interface guard
  - wire a worker-side TaskService in cmd/worker/main.go so the handler
    can use the shared SendTaskCompletedNotificationByID implementation
    (storage service shared with the existing upload-cleanup wiring)

Expected impact on POST /api/task-completions/ p50:
  ~1.75s -> ~120-170ms (DB + tx + Asynq enqueue only)

Notifications still deliver; they just go via the worker instead of in
the request path. MaxRetry=3; "row not found" returns nil so a deleted
task/completion doesn't churn the retry loop.

All 31 test packages pass. No DB migrations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 09:34:52 -05:00

1381 lines
49 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")
)
// TaskCompletedNotificationEnqueuer is the narrow contract TaskService needs
// from the Asynq client to offload completion-notification fan-out (push +
// email + B2 image fetches) into the background worker. Implemented by
// internal/worker.TaskClient; nil when the api is started without a worker
// queue (tests / local dev), in which case CreateCompletion falls back to
// the inline synchronous path.
type TaskCompletedNotificationEnqueuer interface {
EnqueueTaskCompletedNotification(taskID, completionID uint) error
}
// TaskService handles task business logic
type TaskService struct {
taskRepo *repositories.TaskRepository
residenceRepo *repositories.ResidenceRepository
residenceService *ResidenceService
notificationService *NotificationService
emailService *EmailService
storageService *StorageService
uploadService *UploadService // optional — only set when S3 storage is configured
cache *CacheService
notifEnqueuer TaskCompletedNotificationEnqueuer
}
// SetUploadService wires the presigned-URL upload service so CreateCompletion
// can claim pending_uploads rows by id and convert them into completion image
// rows. Optional: with local-disk storage there's no presigned flow and the
// service is left nil.
func (s *TaskService) SetUploadService(us *UploadService) {
s.uploadService = us
}
// SetCacheService wires Redis caching for residence-ID lookups.
func (s *TaskService) SetCacheService(cache *CacheService) {
s.cache = cache
}
// 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
}
// SetTaskCompletedNotificationEnqueuer wires the Asynq enqueuer. When set,
// CreateCompletion/QuickComplete schedule the notification fan-out as a
// background job instead of running it inline (saving ~1-1.5s on the
// user-facing response). When nil, the inline path runs.
func (s *TaskService) SetTaskCompletedNotificationEnqueuer(e TaskCompletedNotificationEnqueuer) {
s.notifEnqueuer = e
}
// 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
}
// Single-round-trip variant: residence-access subquery is folded into the
// task fetch on the Postgres side instead of a separate Go-side round-trip.
// Saves ~110ms on Hetzner→Neon. Empty result naturally handles the
// "user has no residences" case as an empty board.
board, err := s.taskRepo.WithContext(ctx).GetKanbanDataForUser(userID, 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)
}
// tasks_count for every member of this residence just changed.
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, req.ResidenceID)
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))
}
// One residence per batch, so a single fanout invalidation covers all
// affected users.
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, req.ResidenceID)
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)
}
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, task.ResidenceID)
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)
}
// CountByResidenceIDs filters out is_cancelled, so this drops tasks_count.
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, task.ResidenceID)
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)
}
// Reverse of Cancel — tasks_count goes back up.
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, task.ResidenceID)
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)
}
// Same as Cancel — CountByResidenceIDs filters is_archived too.
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, task.ResidenceID)
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)
}
// Reverse of Archive — tasks_count goes back up.
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, task.ResidenceID)
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
}
// New presigned-URL path: claim pending_uploads rows that the client
// already POSTed to B2. We do this BEFORE the txn because VerifyAndClaim
// HEADs each B2 object — we don't want to hold a Postgres transaction
// open across HTTP calls. If the txn rolls back later, the rows stay
// claimed but unreferenced; they're cents of storage and visible via
// admin queries if cleanup ever matters.
var claimedUploads []models.PendingUpload
if len(req.UploadIDs) > 0 && s.uploadService != nil {
var claimErr error
claimedUploads, claimErr = s.uploadService.VerifyAndClaim(ctx, userID, req.UploadIDs)
if claimErr != nil {
return nil, claimErr
}
}
// 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
}
// Create completion image rows from the claimed pending_uploads.
// Bytes already live in B2; we just record the FK + URL.
for i := range claimedUploads {
pu := claimedUploads[i]
img := &models.TaskCompletionImage{
CompletionID: completion.ID,
ImageURL: urlForUploadKey(s.storageService, pu.B2Key),
PendingUploadID: &pu.ID,
}
if err := tx.Create(img).Error; err != nil {
return fmt.Errorf("failed to create completion image from upload %d: %w", pu.ID, 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
}
// Dispatch notification fan-out to the Asynq worker so the api response
// returns without waiting on APNs + SMTP + B2 image fetches (which cost
// ~1-1.5s end-to-end). Falls back to the inline path when no enqueuer is
// wired (tests / local dev) or when Redis is unreachable.
s.dispatchTaskCompletedNotification(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")
// Dispatch notification fan-out to the Asynq worker. Replaces the previous
// fire-and-forget goroutine — which violated the project rule against
// spawning goroutines in request handlers and was unbounded under load.
// Falls back to inline send when no enqueuer is wired.
s.dispatchTaskCompletedNotification(ctx, task, completion)
return nil
}
// dispatchTaskCompletedNotification routes the notification fan-out to either
// the Asynq worker (preferred — keeps the api request path fast) or runs it
// inline as a fallback. The fallback covers:
// - tests / local dev where no enqueuer is wired (notifEnqueuer == nil)
// - Redis outages where Enqueue returns an error
//
// Inline fallback is intentionally synchronous (no goroutines) per the
// project's rule against unbounded goroutine spawning in handlers. The
// caller is expected to be in a request goroutine and accepting the cost.
func (s *TaskService) dispatchTaskCompletedNotification(ctx context.Context, task *models.Task, completion *models.TaskCompletion) {
if s.notifEnqueuer != nil {
if err := s.notifEnqueuer.EnqueueTaskCompletedNotification(task.ID, completion.ID); err == nil {
return
} else {
log.Warn().
Err(err).
Uint("task_id", task.ID).
Uint("completion_id", completion.ID).
Msg("Failed to enqueue completion notification; falling back to inline send")
}
}
s.sendTaskCompletedNotification(ctx, task, completion)
}
// SendTaskCompletedNotificationByID is the public entry point used by the
// Asynq worker. It re-reads the canonical Task + TaskCompletion rows from
// Postgres (cheap with Neon ~10ms away) so the worker reflects any concurrent
// edits between enqueue and dequeue, then delegates to the shared
// sendTaskCompletedNotification implementation.
//
// Returns nil for "row not found" cases (task or completion was deleted before
// the job ran) so Asynq's retry loop doesn't churn on a permanent miss. All
// other errors propagate to Asynq for retry per the queue's MaxRetry setting.
func (s *TaskService) SendTaskCompletedNotificationByID(ctx context.Context, taskID, completionID uint) error {
task, err := s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Warn().Uint("task_id", taskID).Msg("task not found for completion notification (likely deleted between enqueue and dequeue)")
return nil
}
return err
}
completion, err := s.taskRepo.WithContext(ctx).FindCompletionByID(completionID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Warn().Uint("completion_id", completionID).Msg("completion not found for notification (likely deleted between enqueue and dequeue)")
return nil
}
return err
}
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(ctx, 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 := cachedResidenceIDsForUser(ctx, s.cache, s.residenceRepo, 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)
}
// Image-add on update is unsupported in the new flow — clients should
// instead delete and recreate the completion if image attachments need
// to change after the fact. The presigned-URL path requires a single
// "create with attachments" handshake and there's no equivalent attach-
// to-existing pathway today. Add one here when a UI feature requires it.
// 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(ctx context.Context, userID uint, timezone string) {
if s.notificationService != nil {
s.notificationService.UpdateUserTimezone(ctx, userID, timezone)
}
}