Files
notely/backend/internal/application/services/task_service.go
domrichardson b09137eca5
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m48s
feat: Added the ability to delete task lists
2026-03-30 10:14:07 +01:00

1030 lines
28 KiB
Go

package services
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
"go.mongodb.org/mongo-driver/v2/bson"
)
// TaskService handles task and task status operations.
type TaskService struct {
taskRepo repositories.TaskRepository
taskListRepo repositories.TaskListRepository
taskStatusRepo repositories.TaskStatusRepository
noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository
membershipRepo repositories.MembershipRepository
permissionService *PermissionService
}
// NewTaskService creates a task service.
func NewTaskService(
taskRepo repositories.TaskRepository,
taskListRepo repositories.TaskListRepository,
taskStatusRepo repositories.TaskStatusRepository,
noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository,
membershipRepo repositories.MembershipRepository,
permissionService *PermissionService,
) *TaskService {
return &TaskService{
taskRepo: taskRepo,
taskListRepo: taskListRepo,
taskStatusRepo: taskStatusRepo,
noteRepo: noteRepo,
categoryRepo: categoryRepo,
membershipRepo: membershipRepo,
permissionService: permissionService,
}
}
func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.ObjectID) error {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return err
}
if len(statuses) > 0 {
return nil
}
defaults := []struct {
name string
color string
}{
{name: "Pending", color: "#7c8596"},
{name: "In Progress", color: "#3b82f6"},
{name: "Done", color: "#22c55e"},
}
for idx, status := range defaults {
if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{
SpaceID: spaceID,
Name: status.name,
Color: status.color,
Order: idx,
}); err != nil {
return err
}
}
return nil
}
func (s *TaskService) hasTaskPermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
if s.permissionService == nil {
return false, errors.New("permission service unavailable")
}
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
}
func (s *TaskService) requireSpaceAccess(ctx context.Context, userID, spaceID bson.ObjectID) error {
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return errors.New("unauthorized")
}
return nil
}
func toObjectIDPtr(hexID *string) (*bson.ObjectID, error) {
if hexID == nil || strings.TrimSpace(*hexID) == "" {
return nil, nil
}
id, err := bson.ObjectIDFromHex(*hexID)
if err != nil {
return nil, errors.New("invalid object id")
}
return &id, nil
}
func toObjectIDs(hexIDs []string) ([]bson.ObjectID, error) {
result := make([]bson.ObjectID, 0, len(hexIDs))
seen := map[bson.ObjectID]struct{}{}
for _, hexID := range hexIDs {
hexID = strings.TrimSpace(hexID)
if hexID == "" {
continue
}
id, err := bson.ObjectIDFromHex(hexID)
if err != nil {
return nil, errors.New("invalid linked note id")
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result, nil
}
func (s *TaskService) validateTaskList(ctx context.Context, spaceID, taskListID bson.ObjectID) error {
taskList, err := s.taskListRepo.GetTaskListByID(ctx, taskListID)
if err != nil || taskList.SpaceID != spaceID {
return errors.New("invalid task list")
}
return nil
}
func (s *TaskService) validateNoteLinks(ctx context.Context, spaceID bson.ObjectID, noteLinks []bson.ObjectID) error {
for _, noteID := range noteLinks {
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil || note.SpaceID != spaceID {
return errors.New("invalid linked note")
}
}
return nil
}
func (s *TaskService) validateStatus(ctx context.Context, spaceID, statusID bson.ObjectID) (*entities.TaskStatus, error) {
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
if err != nil || status.SpaceID != spaceID {
return nil, errors.New("invalid task status")
}
return status, nil
}
func (s *TaskService) resolveDepthAndParent(ctx context.Context, spaceID bson.ObjectID, parentTaskID *bson.ObjectID) (int, error) {
if parentTaskID == nil {
return 0, nil
}
parent, err := s.taskRepo.GetTaskByID(ctx, *parentTaskID)
if err != nil || parent.SpaceID != spaceID {
return 0, errors.New("invalid parent task")
}
depth := parent.Depth + 1
if depth > entities.MaxTaskDepth {
return 0, fmt.Errorf("max task depth is %d", entities.MaxTaskDepth+1)
}
return depth, nil
}
func (s *TaskService) isAdjacentStatusMove(ctx context.Context, spaceID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) {
if currentStatusID == requestedStatusID {
return true, nil
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return false, err
}
currentIdx := -1
requestedIdx := -1
for idx, status := range statuses {
if status.ID == currentStatusID {
currentIdx = idx
}
if status.ID == requestedStatusID {
requestedIdx = idx
}
}
if currentIdx == -1 || requestedIdx == -1 {
return false, errors.New("status not found in sequence")
}
delta := requestedIdx - currentIdx
if delta < 0 {
delta = -delta
}
return delta == 1, nil
}
func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskRequest) (*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.create")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
parentTaskID, err := toObjectIDPtr(req.ParentTaskID)
if err != nil {
return nil, errors.New("invalid parent task")
}
depth, err := s.resolveDepthAndParent(ctx, spaceID, parentTaskID)
if err != nil {
return nil, err
}
taskListID, err := bson.ObjectIDFromHex(strings.TrimSpace(req.TaskListID))
if err != nil {
return nil, errors.New("invalid task list")
}
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
noteLinks, err := toObjectIDs(req.NoteLinks)
if err != nil {
return nil, err
}
if err := s.validateNoteLinks(ctx, spaceID, noteLinks); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
if len(statuses) == 0 {
return nil, errors.New("no task statuses configured")
}
sort.Slice(statuses, func(i, j int) bool {
return statuses[i].Order < statuses[j].Order
})
statusID := statuses[0].ID
requestedStatusID := strings.TrimSpace(req.StatusID)
if requestedStatusID != "" && parentTaskID == nil {
parsedStatusID, parseErr := bson.ObjectIDFromHex(requestedStatusID)
if parseErr != nil {
return nil, errors.New("invalid task status")
}
if _, validateErr := s.validateStatus(ctx, spaceID, parsedStatusID); validateErr != nil {
return nil, validateErr
}
statusID = parsedStatusID
}
if parentTaskID != nil {
parent, parentErr := s.taskRepo.GetTaskByID(ctx, *parentTaskID)
if parentErr != nil || parent.SpaceID != spaceID {
return nil, errors.New("invalid parent task")
}
taskListID = parent.TaskListID
}
task := &entities.Task{
SpaceID: spaceID,
Title: strings.TrimSpace(req.Title),
Description: strings.TrimSpace(req.Description),
TaskListID: taskListID,
StatusID: statusID,
ParentTaskID: parentTaskID,
Depth: depth,
NoteLinks: noteLinks,
CreatedBy: userID,
UpdatedBy: userID,
}
if task.Title == "" {
return nil, errors.New("title is required")
}
if err := s.taskRepo.CreateTask(ctx, task); err != nil {
return nil, err
}
return dto.NewTaskDTO(task), nil
}
func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID bson.ObjectID) (*dto.TaskWithStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
if err != nil || task.SpaceID != spaceID {
return nil, errors.New("task not found")
}
status, err := s.validateStatus(ctx, spaceID, task.StatusID)
if err != nil {
return nil, err
}
subtasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"parent_task_id": task.ID})
if err != nil {
return nil, err
}
subtaskDTOs := make([]*dto.TaskDTO, 0, len(subtasks))
for _, subtask := range subtasks {
subtaskDTOs = append(subtaskDTOs, dto.NewTaskDTO(subtask))
}
return &dto.TaskWithStatusDTO{
TaskDTO: dto.NewTaskDTO(task),
StatusName: status.Name,
StatusColor: status.Color,
StatusOrder: status.Order,
Subtasks: subtaskDTOs,
}, nil
}
func (s *TaskService) ListTasks(
ctx context.Context,
spaceID, userID bson.ObjectID,
taskListID, statusID, parentTaskID *string,
) ([]*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
filters := map[string]any{}
if taskListID != nil && strings.TrimSpace(*taskListID) != "" {
id, err := bson.ObjectIDFromHex(*taskListID)
if err != nil {
return nil, errors.New("invalid task list filter")
}
filters["task_list_id"] = id
}
if statusID != nil && strings.TrimSpace(*statusID) != "" {
id, err := bson.ObjectIDFromHex(*statusID)
if err != nil {
return nil, errors.New("invalid status filter")
}
filters["status_id"] = id
}
if parentTaskID != nil {
parentFilter := strings.TrimSpace(*parentTaskID)
switch parentFilter {
case "":
case "root":
filters["parent_task_id"] = nil
default:
id, err := bson.ObjectIDFromHex(parentFilter)
if err != nil {
return nil, errors.New("invalid parent task filter")
}
filters["parent_task_id"] = id
}
}
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, filters)
if err != nil {
return nil, err
}
result := make([]*dto.TaskDTO, 0, len(tasks))
for _, task := range tasks {
result = append(result, dto.NewTaskDTO(task))
}
return result, nil
}
func (s *TaskService) SearchTasks(ctx context.Context, spaceID, userID bson.ObjectID, query string) ([]*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
query = strings.TrimSpace(query)
if query == "" {
return []*dto.TaskDTO{}, nil
}
tasks, err := s.taskRepo.SearchTasks(ctx, spaceID, query)
if err != nil {
return nil, err
}
result := make([]*dto.TaskDTO, 0, len(tasks))
for _, task := range tasks {
result = append(result, dto.NewTaskDTO(task))
}
return result, nil
}
func (s *TaskService) ListTasksLinkedToNote(ctx context.Context, spaceID, noteID, userID bson.ObjectID) ([]*dto.TaskWithStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil {
return nil, errors.New("note not found")
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
statusByID := map[bson.ObjectID]*entities.TaskStatus{}
for _, status := range statuses {
statusByID[status.ID] = status
}
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"note_links": noteID})
if err != nil {
return nil, err
}
result := make([]*dto.TaskWithStatusDTO, 0, len(tasks))
for _, task := range tasks {
status := statusByID[task.StatusID]
if status == nil {
continue
}
result = append(result, &dto.TaskWithStatusDTO{
TaskDTO: dto.NewTaskDTO(task),
StatusName: status.Name,
StatusColor: status.Color,
StatusOrder: status.Order,
Subtasks: []*dto.TaskDTO{},
})
}
return result, nil
}
func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bson.ObjectID, req *dto.UpdateTaskRequest) (*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
if err != nil || task.SpaceID != spaceID {
return nil, errors.New("task not found")
}
if req.Title != nil {
task.Title = strings.TrimSpace(*req.Title)
if task.Title == "" {
return nil, errors.New("title is required")
}
}
if req.Description != nil {
task.Description = strings.TrimSpace(*req.Description)
}
if req.TaskListID != nil {
if strings.TrimSpace(*req.TaskListID) == "" {
return nil, errors.New("task list is required")
}
taskListID, parseErr := bson.ObjectIDFromHex(*req.TaskListID)
if parseErr != nil {
return nil, errors.New("invalid task list")
}
if task.ParentTaskID != nil {
return nil, errors.New("subtasks inherit task list from parent")
}
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
task.TaskListID = taskListID
}
if req.ParentTaskID != nil {
if strings.TrimSpace(*req.ParentTaskID) == "" {
task.ParentTaskID = nil
task.Depth = 0
} else {
parentID, parseErr := bson.ObjectIDFromHex(*req.ParentTaskID)
if parseErr != nil {
return nil, errors.New("invalid parent task")
}
if parentID == task.ID {
return nil, errors.New("task cannot be its own parent")
}
depth, depthErr := s.resolveDepthAndParent(ctx, spaceID, &parentID)
if depthErr != nil {
return nil, depthErr
}
task.ParentTaskID = &parentID
task.Depth = depth
parentTask, parentErr := s.taskRepo.GetTaskByID(ctx, parentID)
if parentErr != nil || parentTask.SpaceID != spaceID {
return nil, errors.New("invalid parent task")
}
task.TaskListID = parentTask.TaskListID
}
}
if req.StatusID != nil {
statusID, parseErr := bson.ObjectIDFromHex(*req.StatusID)
if parseErr != nil {
return nil, errors.New("invalid status")
}
if _, err := s.validateStatus(ctx, spaceID, statusID); err != nil {
return nil, err
}
adjacent, err := s.isAdjacentStatusMove(ctx, spaceID, task.StatusID, statusID)
if err != nil {
return nil, err
}
if !adjacent {
return nil, errors.New("status transition must follow adjacent order sequence")
}
task.StatusID = statusID
}
if req.NoteLinks != nil {
noteLinks, convertErr := toObjectIDs(req.NoteLinks)
if convertErr != nil {
return nil, convertErr
}
if err := s.validateNoteLinks(ctx, spaceID, noteLinks); err != nil {
return nil, err
}
task.NoteLinks = noteLinks
}
task.UpdatedBy = userID
task.UpdatedAt = time.Now()
if err := s.taskRepo.UpdateTask(ctx, task); err != nil {
return nil, err
}
return dto.NewTaskDTO(task), nil
}
func (s *TaskService) DeleteTask(ctx context.Context, spaceID, taskID, userID bson.ObjectID) error {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.delete")
if err != nil {
return err
}
if !hasPermission {
return errors.New("insufficient permissions")
}
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
if err != nil || task.SpaceID != spaceID {
return errors.New("task not found")
}
if childCount, err := s.taskRepo.CountChildren(ctx, task.ID); err == nil && childCount > 0 {
return errors.New("cannot delete task with subtasks")
}
return s.taskRepo.DeleteTask(ctx, taskID)
}
func (s *TaskService) TransitionTaskStatus(ctx context.Context, spaceID, taskID, userID bson.ObjectID, direction string) (*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
if err != nil || task.SpaceID != spaceID {
return nil, errors.New("task not found")
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
current := -1
for idx, status := range statuses {
if status.ID == task.StatusID {
current = idx
break
}
}
if current == -1 {
return nil, errors.New("task has invalid status")
}
switch strings.ToLower(strings.TrimSpace(direction)) {
case "forward":
if current+1 >= len(statuses) {
return nil, errors.New("task is already at final status")
}
task.StatusID = statuses[current+1].ID
case "backward":
if current-1 < 0 {
return nil, errors.New("task is already at initial status")
}
task.StatusID = statuses[current-1].ID
default:
return nil, errors.New("invalid transition direction")
}
task.UpdatedBy = userID
if err := s.taskRepo.UpdateTask(ctx, task); err != nil {
return nil, err
}
return dto.NewTaskDTO(task), nil
}
func (s *TaskService) LinkNoteToTask(ctx context.Context, spaceID, taskID, noteID, userID bson.ObjectID) (*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
if err != nil || task.SpaceID != spaceID {
return nil, errors.New("task not found")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil || note.SpaceID != spaceID {
return nil, errors.New("note not found")
}
for _, linkedNoteID := range task.NoteLinks {
if linkedNoteID == noteID {
return dto.NewTaskDTO(task), nil
}
}
task.NoteLinks = append(task.NoteLinks, noteID)
task.UpdatedBy = userID
if err := s.taskRepo.UpdateTask(ctx, task); err != nil {
return nil, err
}
return dto.NewTaskDTO(task), nil
}
func (s *TaskService) UnlinkNoteFromTask(ctx context.Context, spaceID, taskID, noteID, userID bson.ObjectID) (*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
if err != nil || task.SpaceID != spaceID {
return nil, errors.New("task not found")
}
filtered := make([]bson.ObjectID, 0, len(task.NoteLinks))
for _, linkedNoteID := range task.NoteLinks {
if linkedNoteID != noteID {
filtered = append(filtered, linkedNoteID)
}
}
task.NoteLinks = filtered
task.UpdatedBy = userID
if err := s.taskRepo.UpdateTask(ctx, task); err != nil {
return nil, err
}
return dto.NewTaskDTO(task), nil
}
func (s *TaskService) ListTaskLists(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskListDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
lists, err := s.taskListRepo.ListTaskLists(ctx, spaceID)
if err != nil {
return nil, err
}
result := make([]*dto.TaskListDTO, 0, len(lists))
for _, list := range lists {
result = append(result, dto.NewTaskListDTO(list))
}
return result, nil
}
func (s *TaskService) CreateTaskList(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskListRequest) (*dto.TaskListDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.create")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, errors.New("task list name is required")
}
var categoryID *bson.ObjectID
if req.CategoryID != nil && strings.TrimSpace(*req.CategoryID) != "" {
id, parseErr := bson.ObjectIDFromHex(*req.CategoryID)
if parseErr != nil {
return nil, errors.New("invalid category")
}
category, categoryErr := s.categoryRepo.GetCategoryByID(ctx, id)
if categoryErr != nil || category.SpaceID != spaceID {
return nil, errors.New("invalid category")
}
categoryID = &id
}
list := &entities.TaskList{
SpaceID: spaceID,
CategoryID: categoryID,
Name: name,
Description: strings.TrimSpace(req.Description),
CreatedBy: userID,
UpdatedBy: userID,
}
if err := s.taskListRepo.CreateTaskList(ctx, list); err != nil {
return nil, err
}
return dto.NewTaskListDTO(list), nil
}
func (s *TaskService) UpdateTaskList(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, req *dto.UpdateTaskListRequest) (*dto.TaskListDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
list, err := s.taskListRepo.GetTaskListByID(ctx, taskListID)
if err != nil || list.SpaceID != spaceID {
return nil, errors.New("task list not found")
}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return nil, errors.New("task list name is required")
}
list.Name = name
}
if req.Description != nil {
list.Description = strings.TrimSpace(*req.Description)
}
if req.CategoryID != nil {
if strings.TrimSpace(*req.CategoryID) == "" {
list.CategoryID = nil
} else {
categoryID, parseErr := bson.ObjectIDFromHex(*req.CategoryID)
if parseErr != nil {
return nil, errors.New("invalid category")
}
category, categoryErr := s.categoryRepo.GetCategoryByID(ctx, categoryID)
if categoryErr != nil || category.SpaceID != spaceID {
return nil, errors.New("invalid category")
}
list.CategoryID = &categoryID
}
}
list.UpdatedBy = userID
if err := s.taskListRepo.UpdateTaskList(ctx, list); err != nil {
return nil, err
}
return dto.NewTaskListDTO(list), nil
}
func (s *TaskService) DeleteTaskList(ctx context.Context, spaceID, taskListID, userID bson.ObjectID) error {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.delete")
if err != nil {
return err
}
if !hasPermission {
return errors.New("insufficient permissions")
}
list, err := s.taskListRepo.GetTaskListByID(ctx, taskListID)
if err != nil || list.SpaceID != spaceID {
return errors.New("task list not found")
}
if err := s.taskRepo.DeleteTasksByTaskListID(ctx, taskListID); err != nil {
return err
}
return s.taskListRepo.DeleteTaskList(ctx, taskListID)
}
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
result := make([]*dto.TaskStatusDTO, 0, len(statuses))
for _, status := range statuses {
result = append(result, dto.NewTaskStatusDTO(status))
}
return result, nil
}
func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.status.manage")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
status := &entities.TaskStatus{
SpaceID: spaceID,
Name: strings.TrimSpace(req.Name),
Color: strings.TrimSpace(req.Color),
Order: len(statuses),
}
if status.Name == "" {
return nil, errors.New("status name is required")
}
if err := s.taskStatusRepo.CreateStatus(ctx, status); err != nil {
return nil, err
}
return dto.NewTaskStatusDTO(status), nil
}
func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.status.manage")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
if err != nil || status.SpaceID != spaceID {
return nil, errors.New("task status not found")
}
status.Name = strings.TrimSpace(req.Name)
status.Color = strings.TrimSpace(req.Color)
if status.Name == "" {
return nil, errors.New("status name is required")
}
if err := s.taskStatusRepo.UpdateStatus(ctx, status); err != nil {
return nil, err
}
return dto.NewTaskStatusDTO(status), nil
}
func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID) error {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.status.manage")
if err != nil {
return err
}
if !hasPermission {
return errors.New("insufficient permissions")
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return err
}
if len(statuses) <= 1 {
return errors.New("at least one status is required")
}
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"status_id": statusID})
if err != nil {
return err
}
if len(tasks) > 0 {
return errors.New("cannot delete status used by tasks")
}
if err := s.taskStatusRepo.DeleteStatus(ctx, statusID); err != nil {
return err
}
return s.normalizeStatusOrder(ctx, spaceID)
}
func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.status.manage")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
if len(statuses) != len(orderedStatusIDs) {
return nil, errors.New("ordered status list must include every status exactly once")
}
statusByID := make(map[bson.ObjectID]*entities.TaskStatus, len(statuses))
for _, status := range statuses {
statusByID[status.ID] = status
}
seen := map[bson.ObjectID]struct{}{}
orderedStatuses := make([]*entities.TaskStatus, 0, len(orderedStatusIDs))
for _, statusIDHex := range orderedStatusIDs {
statusID, parseErr := bson.ObjectIDFromHex(statusIDHex)
if parseErr != nil {
return nil, errors.New("invalid status id in ordered_status_ids")
}
status := statusByID[statusID]
if status == nil {
return nil, errors.New("status id does not belong to this space")
}
if _, exists := seen[statusID]; exists {
return nil, errors.New("duplicate status id in ordered_status_ids")
}
seen[statusID] = struct{}{}
orderedStatuses = append(orderedStatuses, status)
}
minOrder := statuses[0].Order
for _, status := range statuses {
if status.Order < minOrder {
minOrder = status.Order
}
}
tempBase := minOrder - len(statuses) - 1
for idx, status := range orderedStatuses {
status.Order = tempBase + idx
if err := s.taskStatusRepo.UpdateStatus(ctx, status); err != nil {
return nil, err
}
}
for idx, status := range orderedStatuses {
status.Order = idx
if err := s.taskStatusRepo.UpdateStatus(ctx, status); err != nil {
return nil, err
}
}
updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
result := make([]*dto.TaskStatusDTO, 0, len(updatedStatuses))
for _, status := range updatedStatuses {
result = append(result, dto.NewTaskStatusDTO(status))
}
return result, nil
}
func (s *TaskService) normalizeStatusOrder(ctx context.Context, spaceID bson.ObjectID) error {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return err
}
sort.SliceStable(statuses, func(i, j int) bool {
return statuses[i].Order < statuses[j].Order
})
for idx, status := range statuses {
if status.Order == idx {
continue
}
status.Order = idx
if err := s.taskStatusRepo.UpdateStatus(ctx, status); err != nil {
return err
}
}
return nil
}