bc3da007db
Step 1 — OTel SDK: cmd/api and cmd/worker initialize a tracer provider that exports OTLP/HTTP to obs.88oakapps.com (Jaeger all-in-one). Sampling is AlwaysSample in dev (DEBUG=true) and TraceIDRatioBased(0.1) in prod, overridable via OTEL_TRACES_SAMPLER_ARG. Service names are honeydue-api and honeydue-worker. otelecho.Middleware opens a span per HTTP request. Step 2 — Manual spans: storage_service.Upload now takes ctx and emits storage.upload + b2.PutObject spans (size_bytes, key, mime_type, bucket, result attrs). APNs Send/SendWithCategory and FCM sendOne emit per-token spans with topic, status_code, reason. Asynq middleware emits asynq.handle:<task_type> per job with retry/payload attrs and records asynq_job_duration_seconds. Step 3 — Database: otelgorm plugin registered in database.Connect, so any SQL emitted via db.WithContext(ctx) attaches to the request span. Every repository now exposes WithContext(ctx) *XRepository as the migration helper. TaskService.ListTasks and GetTasksByResidence are migrated end-to-end (ctx threaded through handler → service → repo); remaining services adopt the same pattern incrementally — pre-migration methods still emit untraced SQL via the unchanged db field. OBS_TRACES_URL and OBS_INGEST_TOKEN flow from deploy/prod.env → honeydue-secrets → api+worker Deployments via secretKeyRef (optional). 02-setup-secrets.sh sources them from prod.env on next run; manifests mark both env vars optional so the deployment rolls without traces if the secret is absent. ch15 observability doc now lists what produces spans today vs the remaining migration work, with the explicit per-method pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1272 lines
41 KiB
Go
1272 lines
41 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/apperrors"
|
|
"github.com/treytartt/honeydue-api/internal/dto/requests"
|
|
"github.com/treytartt/honeydue-api/internal/dto/responses"
|
|
"github.com/treytartt/honeydue-api/internal/models"
|
|
"github.com/treytartt/honeydue-api/internal/repositories"
|
|
"github.com/treytartt/honeydue-api/internal/task/categorization"
|
|
)
|
|
|
|
// Task-related errors (DEPRECATED - kept for reference, use apperrors instead)
|
|
// TODO: Migrate handlers to use apperrors instead of these constants
|
|
var (
|
|
ErrTaskNotFound = errors.New("task not found")
|
|
ErrTaskAccessDenied = errors.New("you do not have access to this task")
|
|
ErrTaskAlreadyCancelled = errors.New("task is already cancelled")
|
|
ErrTaskAlreadyArchived = errors.New("task is already archived")
|
|
ErrCompletionNotFound = errors.New("task completion not found")
|
|
)
|
|
|
|
// TaskService handles task business logic
|
|
type TaskService struct {
|
|
taskRepo *repositories.TaskRepository
|
|
residenceRepo *repositories.ResidenceRepository
|
|
residenceService *ResidenceService
|
|
notificationService *NotificationService
|
|
emailService *EmailService
|
|
storageService *StorageService
|
|
}
|
|
|
|
// NewTaskService creates a new task service
|
|
func NewTaskService(taskRepo *repositories.TaskRepository, residenceRepo *repositories.ResidenceRepository) *TaskService {
|
|
return &TaskService{
|
|
taskRepo: taskRepo,
|
|
residenceRepo: residenceRepo,
|
|
}
|
|
}
|
|
|
|
// SetNotificationService sets the notification service (for breaking circular dependency)
|
|
func (s *TaskService) SetNotificationService(ns *NotificationService) {
|
|
s.notificationService = ns
|
|
}
|
|
|
|
// SetEmailService sets the email service
|
|
func (s *TaskService) SetEmailService(es *EmailService) {
|
|
s.emailService = es
|
|
}
|
|
|
|
// SetResidenceService sets the residence service (for getting summary in CRUD responses)
|
|
func (s *TaskService) SetResidenceService(rs *ResidenceService) {
|
|
s.residenceService = rs
|
|
}
|
|
|
|
// SetStorageService sets the storage service (for reading completion images for emails)
|
|
func (s *TaskService) SetStorageService(ss *StorageService) {
|
|
s.storageService = ss
|
|
}
|
|
|
|
// getSummaryForUser returns an empty summary placeholder.
|
|
// DEPRECATED: Summary calculation has been removed from CRUD responses for performance.
|
|
// Clients should calculate summary from kanban data instead (which already includes all tasks).
|
|
// The summary field is kept in responses for backward compatibility but will always be empty.
|
|
func (s *TaskService) getSummaryForUser(_ uint) responses.TotalSummary {
|
|
// Return empty summary - clients should calculate from kanban data
|
|
return responses.TotalSummary{}
|
|
}
|
|
|
|
// === Task CRUD ===
|
|
|
|
// GetTask gets a task by ID with access check
|
|
func (s *TaskService) GetTask(taskID, userID uint) (*responses.TaskResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access via residence
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
resp := responses.NewTaskResponse(task)
|
|
return &resp, nil
|
|
}
|
|
|
|
// ListTasks lists all tasks accessible to a user as a kanban board.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
|
func (s *TaskService) ListTasks(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(req *requests.CreateTaskRequest, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
// Check residence access
|
|
hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.residence_access_denied")
|
|
}
|
|
|
|
dueDate := req.DueDate.ToTimePtr()
|
|
task := &models.Task{
|
|
ResidenceID: req.ResidenceID,
|
|
CreatedByID: userID,
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
CategoryID: req.CategoryID,
|
|
PriorityID: req.PriorityID,
|
|
FrequencyID: req.FrequencyID,
|
|
CustomIntervalDays: req.CustomIntervalDays,
|
|
InProgress: req.InProgress,
|
|
AssignedToID: req.AssignedToID,
|
|
DueDate: dueDate,
|
|
NextDueDate: dueDate, // Initialize next_due_date to due_date
|
|
EstimatedCost: req.EstimatedCost,
|
|
ContractorID: req.ContractorID,
|
|
TaskTemplateID: req.TemplateID,
|
|
}
|
|
|
|
if err := s.taskRepo.Create(task); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload with relations
|
|
task, err = s.taskRepo.FindByID(task.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.TaskWithSummaryResponse{
|
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// BulkCreateTasks inserts all tasks in a single transaction. If any task
|
|
// fails validation or insert, the entire batch is rolled back. The top-level
|
|
// ResidenceID overrides whatever was set on individual entries so that a
|
|
// single access check covers the whole batch.
|
|
//
|
|
// `now` should be the start of day in the user's timezone for accurate
|
|
// kanban column categorisation on the returned task list.
|
|
func (s *TaskService) BulkCreateTasks(req *requests.BulkCreateTasksRequest, userID uint, now time.Time) (*responses.BulkCreateTasksResponse, error) {
|
|
if len(req.Tasks) == 0 {
|
|
return nil, apperrors.BadRequest("error.task_list_empty")
|
|
}
|
|
|
|
// Check residence access once.
|
|
hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.residence_access_denied")
|
|
}
|
|
|
|
createdIDs := make([]uint, 0, len(req.Tasks))
|
|
|
|
err = s.taskRepo.DB().Transaction(func(tx *gorm.DB) error {
|
|
for i := range req.Tasks {
|
|
entry := req.Tasks[i]
|
|
// Force the residence ID to the batch-level value so clients
|
|
// can't straddle residences in one call.
|
|
entry.ResidenceID = req.ResidenceID
|
|
|
|
dueDate := entry.DueDate.ToTimePtr()
|
|
task := &models.Task{
|
|
ResidenceID: req.ResidenceID,
|
|
CreatedByID: userID,
|
|
Title: entry.Title,
|
|
Description: entry.Description,
|
|
CategoryID: entry.CategoryID,
|
|
PriorityID: entry.PriorityID,
|
|
FrequencyID: entry.FrequencyID,
|
|
CustomIntervalDays: entry.CustomIntervalDays,
|
|
InProgress: entry.InProgress,
|
|
AssignedToID: entry.AssignedToID,
|
|
DueDate: dueDate,
|
|
NextDueDate: dueDate,
|
|
EstimatedCost: entry.EstimatedCost,
|
|
ContractorID: entry.ContractorID,
|
|
TaskTemplateID: entry.TemplateID,
|
|
}
|
|
if err := s.taskRepo.CreateTx(tx, task); err != nil {
|
|
return fmt.Errorf("create task %d of %d: %w", i+1, len(req.Tasks), err)
|
|
}
|
|
createdIDs = append(createdIDs, task.ID)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload the just-created tasks with preloads for the response. Reads
|
|
// happen outside the transaction — rows are already committed.
|
|
created := make([]responses.TaskResponse, 0, len(createdIDs))
|
|
for _, id := range createdIDs {
|
|
t, ferr := s.taskRepo.FindByID(id)
|
|
if ferr != nil {
|
|
return nil, apperrors.Internal(ferr)
|
|
}
|
|
created = append(created, responses.NewTaskResponseWithTime(t, 30, now))
|
|
}
|
|
|
|
return &responses.BulkCreateTasksResponse{
|
|
Tasks: created,
|
|
Summary: s.getSummaryForUser(userID),
|
|
CreatedCount: len(created),
|
|
}, nil
|
|
}
|
|
|
|
// UpdateTask updates a task.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
// Apply updates
|
|
if req.Title != nil {
|
|
task.Title = *req.Title
|
|
}
|
|
if req.Description != nil {
|
|
task.Description = *req.Description
|
|
}
|
|
if req.CategoryID != nil {
|
|
task.CategoryID = req.CategoryID
|
|
}
|
|
if req.PriorityID != nil {
|
|
task.PriorityID = req.PriorityID
|
|
}
|
|
if req.FrequencyID != nil {
|
|
task.FrequencyID = req.FrequencyID
|
|
}
|
|
if req.CustomIntervalDays != nil {
|
|
task.CustomIntervalDays = req.CustomIntervalDays
|
|
}
|
|
if req.InProgress != nil {
|
|
task.InProgress = *req.InProgress
|
|
}
|
|
if req.AssignedToID != nil {
|
|
task.AssignedToID = req.AssignedToID
|
|
}
|
|
if req.DueDate != nil {
|
|
newDueDate := req.DueDate.ToTimePtr()
|
|
task.DueDate = newDueDate
|
|
// Always update NextDueDate when user explicitly edits due date.
|
|
// Completion logic will recalculate NextDueDate when task is completed,
|
|
// but manual edits should take precedence.
|
|
task.NextDueDate = newDueDate
|
|
}
|
|
if req.EstimatedCost != nil {
|
|
task.EstimatedCost = req.EstimatedCost
|
|
}
|
|
if req.ActualCost != nil {
|
|
task.ActualCost = req.ActualCost
|
|
}
|
|
if req.ContractorID != nil {
|
|
task.ContractorID = req.ContractorID
|
|
}
|
|
|
|
if err := s.taskRepo.Update(task); err != nil {
|
|
if errors.Is(err, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(task.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.TaskWithSummaryResponse{
|
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// DeleteTask deletes a task
|
|
func (s *TaskService) DeleteTask(taskID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
if err := s.taskRepo.Delete(taskID); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.DeleteWithSummaryResponse{
|
|
Data: "task deleted",
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// === Task Actions ===
|
|
|
|
// MarkInProgress marks a task as in progress.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) MarkInProgress(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
if err := s.taskRepo.MarkInProgress(taskID, task.Version); err != nil {
|
|
if errors.Is(err, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.TaskWithSummaryResponse{
|
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// CancelTask cancels a task.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) CancelTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
if task.IsCancelled {
|
|
return nil, apperrors.BadRequest("error.task_already_cancelled")
|
|
}
|
|
|
|
if err := s.taskRepo.Cancel(taskID, task.Version); err != nil {
|
|
if errors.Is(err, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.TaskWithSummaryResponse{
|
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// UncancelTask uncancels a task.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) UncancelTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
if err := s.taskRepo.Uncancel(taskID, task.Version); err != nil {
|
|
if errors.Is(err, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.TaskWithSummaryResponse{
|
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// ArchiveTask archives a task.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) ArchiveTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
if task.IsArchived {
|
|
return nil, apperrors.BadRequest("error.task_already_archived")
|
|
}
|
|
|
|
if err := s.taskRepo.Archive(taskID, task.Version); err != nil {
|
|
if errors.Is(err, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.TaskWithSummaryResponse{
|
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// UnarchiveTask unarchives a task.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) UnarchiveTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
if err := s.taskRepo.Unarchive(taskID, task.Version); err != nil {
|
|
if errors.Is(err, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &responses.TaskWithSummaryResponse{
|
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// === Task Completions ===
|
|
|
|
// CreateCompletion creates a task completion.
|
|
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
|
|
func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint, now time.Time) (*responses.TaskCompletionWithSummaryResponse, error) {
|
|
// Get the task
|
|
task, err := s.taskRepo.FindByID(req.TaskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
completedAt := time.Now().UTC()
|
|
if req.CompletedAt != nil {
|
|
completedAt = *req.CompletedAt
|
|
}
|
|
|
|
// Capture the kanban column BEFORE mutating NextDueDate/InProgress,
|
|
// so we know what state the task was in when the user completed it.
|
|
completedFromColumn := categorization.DetermineKanbanColumnWithTime(task, 30, now)
|
|
|
|
completion := &models.TaskCompletion{
|
|
TaskID: req.TaskID,
|
|
CompletedByID: userID,
|
|
CompletedAt: completedAt,
|
|
Notes: req.Notes,
|
|
ActualCost: req.ActualCost,
|
|
Rating: req.Rating,
|
|
CompletedFromColumn: completedFromColumn,
|
|
}
|
|
|
|
// Determine interval days for NextDueDate calculation before entering the transaction.
|
|
// - If frequency is "Once" (days = nil or 0), set next_due_date to nil (marks as completed)
|
|
// - If frequency is "Custom", use task.CustomIntervalDays for recurrence
|
|
// - If frequency is recurring, calculate next_due_date = completion_date + frequency_days
|
|
// and reset in_progress to false so task shows in correct kanban column
|
|
//
|
|
// Note: Frequency is no longer preloaded for performance, so we load it separately if needed
|
|
var intervalDays *int
|
|
if task.FrequencyID != nil {
|
|
frequency, err := s.taskRepo.GetFrequencyByID(*task.FrequencyID)
|
|
if err == nil && frequency != nil {
|
|
if frequency.Name == "Custom" {
|
|
// Custom frequency - use task's custom_interval_days
|
|
intervalDays = task.CustomIntervalDays
|
|
} else {
|
|
// Standard frequency - use frequency's days
|
|
intervalDays = frequency.Days
|
|
}
|
|
}
|
|
}
|
|
|
|
if intervalDays == nil || *intervalDays == 0 {
|
|
// One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions)
|
|
task.NextDueDate = nil
|
|
task.InProgress = false
|
|
} else {
|
|
// Recurring task - calculate next due date from completion date + interval
|
|
nextDue := completedAt.AddDate(0, 0, *intervalDays)
|
|
task.NextDueDate = &nextDue
|
|
|
|
// Reset in_progress to false so task appears in upcoming/due_soon
|
|
// instead of staying in "In Progress" column
|
|
task.InProgress = false
|
|
}
|
|
|
|
// P1-5 + B-07: Wrap completion creation, task update, and image creation
|
|
// in a single transaction for atomicity. If any operation fails, all are rolled back.
|
|
txErr := s.taskRepo.DB().Transaction(func(tx *gorm.DB) error {
|
|
if err := s.taskRepo.CreateCompletionTx(tx, completion); err != nil {
|
|
return err
|
|
}
|
|
if err := s.taskRepo.UpdateTx(tx, task); err != nil {
|
|
return err
|
|
}
|
|
// B-07: Create images inside the same transaction as completion
|
|
for _, imageURL := range req.ImageURLs {
|
|
if imageURL != "" {
|
|
img := &models.TaskCompletionImage{
|
|
CompletionID: completion.ID,
|
|
ImageURL: imageURL,
|
|
}
|
|
if err := tx.Create(img).Error; err != nil {
|
|
return fmt.Errorf("failed to create completion image: %w", err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if txErr != nil {
|
|
// P1-6: Return the error instead of swallowing it.
|
|
if errors.Is(txErr, repositories.ErrVersionConflict) {
|
|
return nil, apperrors.Conflict("error.version_conflict")
|
|
}
|
|
log.Error().Err(txErr).Uint("task_id", task.ID).Msg("Failed to create completion and update task")
|
|
return nil, apperrors.Internal(txErr)
|
|
}
|
|
|
|
// Reload completion with user info and images
|
|
completion, err = s.taskRepo.FindCompletionByID(completion.ID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Reload task with updated completions (so client can update kanban column)
|
|
task, err = s.taskRepo.FindByID(req.TaskID)
|
|
if err != nil {
|
|
// Non-fatal - still return the completion, just without the task
|
|
log.Warn().Err(err).Uint("task_id", req.TaskID).Msg("Failed to reload task after completion")
|
|
resp := responses.NewTaskCompletionResponse(completion)
|
|
return &responses.TaskCompletionWithSummaryResponse{
|
|
Data: resp,
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// Send notification to residence owner and other users
|
|
s.sendTaskCompletedNotification(task, completion)
|
|
|
|
// Return completion with updated task (includes kanban_column for UI update)
|
|
resp := responses.NewTaskCompletionWithTaskResponseWithTime(completion, task, 30, now)
|
|
return &responses.TaskCompletionWithSummaryResponse{
|
|
Data: resp,
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// QuickComplete creates a minimal task completion (for widget use).
|
|
// LE-01: The entire operation (completion creation + task update) is wrapped in a
|
|
// transaction for atomicity.
|
|
// Returns only success/error, no response body.
|
|
func (s *TaskService) QuickComplete(taskID uint, userID uint) error {
|
|
// Get the task
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
completedAt := time.Now().UTC()
|
|
|
|
// Capture kanban column before state mutation
|
|
completedFromColumn := categorization.DetermineKanbanColumn(task, 30)
|
|
|
|
completion := &models.TaskCompletion{
|
|
TaskID: taskID,
|
|
CompletedByID: userID,
|
|
CompletedAt: completedAt,
|
|
Notes: "Completed from widget",
|
|
CompletedFromColumn: completedFromColumn,
|
|
}
|
|
|
|
// Update next_due_date and in_progress based on frequency
|
|
// Determine interval days: Custom frequency uses task.CustomIntervalDays, otherwise use frequency.Days
|
|
// Note: Frequency is no longer preloaded for performance, so we load it separately if needed
|
|
var quickIntervalDays *int
|
|
var frequencyName = "unknown"
|
|
if task.FrequencyID != nil {
|
|
frequency, err := s.taskRepo.GetFrequencyByID(*task.FrequencyID)
|
|
if err == nil && frequency != nil {
|
|
frequencyName = frequency.Name
|
|
if frequency.Name == "Custom" {
|
|
quickIntervalDays = task.CustomIntervalDays
|
|
} else {
|
|
quickIntervalDays = frequency.Days
|
|
}
|
|
}
|
|
}
|
|
|
|
if quickIntervalDays == nil || *quickIntervalDays == 0 {
|
|
// One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions)
|
|
log.Info().
|
|
Uint("task_id", task.ID).
|
|
Bool("has_frequency", task.FrequencyID != nil).
|
|
Msg("QuickComplete: One-time task, clearing next_due_date")
|
|
task.NextDueDate = nil
|
|
task.InProgress = false
|
|
} else {
|
|
// Recurring task - calculate next due date from completion date + interval
|
|
nextDue := completedAt.AddDate(0, 0, *quickIntervalDays)
|
|
log.Info().
|
|
Uint("task_id", task.ID).
|
|
Str("frequency_name", frequencyName).
|
|
Int("interval_days", *quickIntervalDays).
|
|
Time("completed_at", completedAt).
|
|
Time("next_due_date", nextDue).
|
|
Msg("QuickComplete: Recurring task, setting next_due_date")
|
|
task.NextDueDate = &nextDue
|
|
|
|
// Reset in_progress to false
|
|
task.InProgress = false
|
|
}
|
|
|
|
// LE-01: Wrap completion creation and task update in a transaction for atomicity
|
|
txErr := s.taskRepo.DB().Transaction(func(tx *gorm.DB) error {
|
|
if err := s.taskRepo.CreateCompletionTx(tx, completion); err != nil {
|
|
return err
|
|
}
|
|
if err := s.taskRepo.UpdateTx(tx, task); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
if txErr != nil {
|
|
if errors.Is(txErr, repositories.ErrVersionConflict) {
|
|
return apperrors.Conflict("error.version_conflict")
|
|
}
|
|
log.Error().Err(txErr).Uint("task_id", task.ID).Msg("Failed to create completion and update task in QuickComplete")
|
|
return apperrors.Internal(txErr)
|
|
}
|
|
log.Info().Uint("task_id", task.ID).Msg("QuickComplete: Task updated successfully")
|
|
|
|
// Send notification (fire and forget with panic recovery)
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Error().Interface("panic", r).Uint("task_id", task.ID).Msg("Panic in quick-complete notification goroutine")
|
|
}
|
|
}()
|
|
s.sendTaskCompletedNotification(task, completion)
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// sendTaskCompletedNotification sends notifications when a task is completed
|
|
func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completion *models.TaskCompletion) {
|
|
// Get all users with access to this residence
|
|
users, err := s.residenceRepo.GetResidenceUsers(task.ResidenceID)
|
|
if err != nil {
|
|
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to get residence users for notification")
|
|
return
|
|
}
|
|
|
|
// Get residence name
|
|
residence, err := s.residenceRepo.FindByIDSimple(task.ResidenceID)
|
|
residenceName := "your property"
|
|
if err == nil && residence != nil {
|
|
residenceName = residence.Name
|
|
}
|
|
|
|
completedByName := "Someone"
|
|
if completion.CompletedBy.ID > 0 {
|
|
completedByName = completion.CompletedBy.GetFullName()
|
|
}
|
|
|
|
// Load completion images for email (only if storage service is available)
|
|
var emailImages []EmbeddedImage
|
|
if s.storageService != nil && len(completion.Images) > 0 {
|
|
emailImages = s.loadCompletionImagesForEmail(completion.Images)
|
|
}
|
|
|
|
// Notify all users synchronously to avoid unbounded goroutine spawning.
|
|
// This method is already called from a goroutine (QuickComplete) or inline
|
|
// (CreateCompletion) where blocking is acceptable for notification delivery.
|
|
for _, user := range users {
|
|
isCompleter := user.ID == completion.CompletedByID
|
|
|
|
// Send push notification (to everyone EXCEPT the person who completed it)
|
|
if !isCompleter && s.notificationService != nil {
|
|
ctx := context.Background()
|
|
if err := s.notificationService.CreateAndSendTaskNotification(
|
|
ctx,
|
|
user.ID,
|
|
models.NotificationTaskCompleted,
|
|
task,
|
|
); err != nil {
|
|
log.Error().Err(err).Uint("user_id", user.ID).Uint("task_id", task.ID).Msg("Failed to send task completion push notification")
|
|
}
|
|
}
|
|
|
|
// Send email notification (to everyone INCLUDING the person who completed it)
|
|
// Check user's email notification preferences first
|
|
if s.emailService != nil && user.Email != "" && s.notificationService != nil {
|
|
prefs, prefsErr := s.notificationService.GetPreferences(user.ID)
|
|
// LE-06: Log fail-open behavior when preferences cannot be loaded
|
|
if prefsErr != nil {
|
|
log.Warn().
|
|
Err(prefsErr).
|
|
Uint("user_id", user.ID).
|
|
Uint("task_id", task.ID).
|
|
Msg("Failed to load notification preferences, falling back to sending email (fail-open)")
|
|
}
|
|
if prefsErr != nil || (prefs != nil && prefs.EmailTaskCompleted) {
|
|
// Send email if we couldn't get prefs (fail-open) or if email notifications are enabled
|
|
if err := s.emailService.SendTaskCompletedEmail(
|
|
user.Email,
|
|
user.GetFullName(),
|
|
task.Title,
|
|
completedByName,
|
|
residenceName,
|
|
emailImages,
|
|
); err != nil {
|
|
log.Error().Err(err).Str("email", user.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
|
|
} else {
|
|
log.Info().Str("email", user.Email).Uint("task_id", task.ID).Int("images", len(emailImages)).Msg("Task completion email sent")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// loadCompletionImagesForEmail reads completion images from disk and prepares them for email embedding.
|
|
// Uses StorageService.ReadFile to transparently handle encrypted files.
|
|
func (s *TaskService) loadCompletionImagesForEmail(images []models.TaskCompletionImage) []EmbeddedImage {
|
|
var emailImages []EmbeddedImage
|
|
|
|
for i, img := range images {
|
|
// Read file via storage service (handles encryption transparently)
|
|
data, mimeType, err := s.storageService.ReadFile(img.ImageURL)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("image_url", img.ImageURL).Msg("Failed to read completion image for email")
|
|
continue
|
|
}
|
|
|
|
// Use detected MIME type, fall back to extension-based detection
|
|
if mimeType == "application/octet-stream" {
|
|
mimeType = s.getContentTypeFromPath(img.ImageURL)
|
|
}
|
|
|
|
// Create embedded image with unique Content-ID
|
|
emailImages = append(emailImages, EmbeddedImage{
|
|
ContentID: fmt.Sprintf("completion-image-%d", i+1),
|
|
Filename: filepath.Base(img.ImageURL),
|
|
ContentType: mimeType,
|
|
Data: data,
|
|
})
|
|
|
|
log.Debug().Str("image_url", img.ImageURL).Int("size", len(data)).Msg("Loaded completion image for email")
|
|
}
|
|
|
|
return emailImages
|
|
}
|
|
|
|
// resolveImageFilePath converts a stored URL to an actual file path.
|
|
// Returns empty string if the URL is empty or the resolved path would escape
|
|
// the upload directory (path traversal attempt).
|
|
func (s *TaskService) resolveImageFilePath(storedURL, uploadDir string) string {
|
|
if storedURL == "" {
|
|
return ""
|
|
}
|
|
|
|
// Strip legacy /uploads/ prefix to get relative path
|
|
relativePath := storedURL
|
|
if strings.HasPrefix(storedURL, "/uploads/") {
|
|
relativePath = strings.TrimPrefix(storedURL, "/uploads/")
|
|
}
|
|
|
|
// Use SafeResolvePath to validate containment within upload directory
|
|
resolved, err := SafeResolvePath(uploadDir, relativePath)
|
|
if err != nil {
|
|
// Path traversal or invalid path — return empty to signal file not found
|
|
return ""
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
|
|
// getContentTypeFromPath returns the MIME type based on file extension
|
|
func (s *TaskService) getContentTypeFromPath(path string) string {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
switch ext {
|
|
case ".jpg", ".jpeg":
|
|
return "image/jpeg"
|
|
case ".png":
|
|
return "image/png"
|
|
case ".gif":
|
|
return "image/gif"
|
|
case ".webp":
|
|
return "image/webp"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
// GetCompletion gets a task completion by ID
|
|
func (s *TaskService) GetCompletion(completionID, userID uint) (*responses.TaskCompletionResponse, error) {
|
|
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.completion_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access via task's residence
|
|
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
resp := responses.NewTaskCompletionResponse(completion)
|
|
return &resp, nil
|
|
}
|
|
|
|
// ListCompletions lists all task completions for a user
|
|
func (s *TaskService) ListCompletions(userID uint) ([]responses.TaskCompletionResponse, error) {
|
|
// Get all residence IDs (lightweight - no preloads)
|
|
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
if len(residenceIDs) == 0 {
|
|
return []responses.TaskCompletionResponse{}, nil
|
|
}
|
|
|
|
completions, err := s.taskRepo.FindCompletionsByUser(userID, residenceIDs)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return responses.NewTaskCompletionListResponse(completions), nil
|
|
}
|
|
|
|
// UpdateCompletion updates an existing task completion
|
|
func (s *TaskService) UpdateCompletion(completionID, userID uint, req *requests.UpdateTaskCompletionRequest) (*responses.TaskCompletionResponse, error) {
|
|
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.completion_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access via task's residence
|
|
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
// Apply updates
|
|
if req.Notes != nil {
|
|
completion.Notes = *req.Notes
|
|
}
|
|
if req.ActualCost != nil {
|
|
completion.ActualCost = req.ActualCost
|
|
}
|
|
if req.Rating != nil {
|
|
completion.Rating = req.Rating
|
|
}
|
|
|
|
if err := s.taskRepo.UpdateCompletion(completion); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Add any new images
|
|
for _, imageURL := range req.ImageURLs {
|
|
image := &models.TaskCompletionImage{
|
|
CompletionID: completion.ID,
|
|
ImageURL: imageURL,
|
|
}
|
|
if err := s.taskRepo.CreateCompletionImage(image); err != nil {
|
|
log.Error().Err(err).Uint("completion_id", completion.ID).Msg("Failed to create completion image during update")
|
|
}
|
|
}
|
|
|
|
// Reload to get full associations
|
|
updated, err := s.taskRepo.FindCompletionByID(completionID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
resp := responses.NewTaskCompletionResponse(updated)
|
|
return &resp, nil
|
|
}
|
|
|
|
// DeleteCompletion deletes a task completion and recalculates the task's NextDueDate.
|
|
//
|
|
// P1-7: After deleting a completion, NextDueDate must be recalculated:
|
|
// - If no completions remain: restore NextDueDate = DueDate (original schedule)
|
|
// - If completions remain (recurring): recalculate from latest remaining completion + frequency days
|
|
func (s *TaskService) DeleteCompletion(completionID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
|
|
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.completion_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
taskID := completion.TaskID
|
|
|
|
if err := s.taskRepo.DeleteCompletion(completionID); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Recalculate NextDueDate based on remaining completions
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
// Non-fatal for the delete operation itself, but log the error
|
|
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to reload task after completion deletion for NextDueDate recalculation")
|
|
return &responses.DeleteWithSummaryResponse{
|
|
Data: "completion deleted",
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// Get remaining completions for this task
|
|
remainingCompletions, err := s.taskRepo.FindCompletionsByTask(taskID)
|
|
if err != nil {
|
|
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to query remaining completions after deletion")
|
|
return &responses.DeleteWithSummaryResponse{
|
|
Data: "completion deleted",
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// Determine the task's frequency interval
|
|
var intervalDays *int
|
|
if task.FrequencyID != nil {
|
|
frequency, freqErr := s.taskRepo.GetFrequencyByID(*task.FrequencyID)
|
|
if freqErr == nil && frequency != nil {
|
|
if frequency.Name == "Custom" {
|
|
intervalDays = task.CustomIntervalDays
|
|
} else {
|
|
intervalDays = frequency.Days
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(remainingCompletions) == 0 {
|
|
// No completions remain: restore NextDueDate to the original DueDate
|
|
task.NextDueDate = task.DueDate
|
|
} else if intervalDays != nil && *intervalDays > 0 {
|
|
// Recurring task with remaining completions: recalculate from the latest completion
|
|
// remainingCompletions is ordered by completed_at DESC, so index 0 is the latest
|
|
latestCompletion := remainingCompletions[0]
|
|
nextDue := latestCompletion.CompletedAt.AddDate(0, 0, *intervalDays)
|
|
task.NextDueDate = &nextDue
|
|
} else {
|
|
// One-time task with remaining completions (unusual case): keep NextDueDate as nil
|
|
// since the task is still considered completed
|
|
task.NextDueDate = nil
|
|
}
|
|
|
|
if err := s.taskRepo.Update(task); err != nil {
|
|
log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to update task NextDueDate after completion deletion")
|
|
// The completion was already deleted; return success but log the update failure
|
|
}
|
|
|
|
return &responses.DeleteWithSummaryResponse{
|
|
Data: "completion deleted",
|
|
Summary: s.getSummaryForUser(userID),
|
|
}, nil
|
|
}
|
|
|
|
// GetCompletionsByTask gets all completions for a specific task
|
|
func (s *TaskService) GetCompletionsByTask(taskID, userID uint) ([]responses.TaskCompletionResponse, error) {
|
|
// Get the task to check access
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.task_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Check access via residence
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if !hasAccess {
|
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
|
}
|
|
|
|
// Get completions for the task
|
|
completions, err := s.taskRepo.FindCompletionsByTask(taskID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return responses.NewTaskCompletionListResponse(completions), nil
|
|
}
|
|
|
|
// === Lookups ===
|
|
|
|
// GetCategories returns all task categories
|
|
func (s *TaskService) GetCategories() ([]responses.TaskCategoryResponse, error) {
|
|
categories, err := s.taskRepo.GetAllCategories()
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
result := make([]responses.TaskCategoryResponse, len(categories))
|
|
for i, c := range categories {
|
|
result[i] = *responses.NewTaskCategoryResponse(&c)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetPriorities returns all task priorities
|
|
func (s *TaskService) GetPriorities() ([]responses.TaskPriorityResponse, error) {
|
|
priorities, err := s.taskRepo.GetAllPriorities()
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
result := make([]responses.TaskPriorityResponse, len(priorities))
|
|
for i, p := range priorities {
|
|
result[i] = *responses.NewTaskPriorityResponse(&p)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetFrequencies returns all task frequencies
|
|
func (s *TaskService) GetFrequencies() ([]responses.TaskFrequencyResponse, error) {
|
|
frequencies, err := s.taskRepo.GetAllFrequencies()
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
result := make([]responses.TaskFrequencyResponse, len(frequencies))
|
|
for i, f := range frequencies {
|
|
result[i] = *responses.NewTaskFrequencyResponse(&f)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// === Timezone ===
|
|
|
|
// UpdateUserTimezone updates the user's timezone for background job calculations.
|
|
// This is called from handlers when the X-Timezone header is present.
|
|
// Delegates to NotificationService since timezone is stored in notification preferences.
|
|
func (s *TaskService) UpdateUserTimezone(userID uint, timezone string) {
|
|
if s.notificationService != nil {
|
|
s.notificationService.UpdateUserTimezone(userID, timezone)
|
|
}
|
|
}
|