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 }