All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m20s
1034 lines
29 KiB
Go
1034 lines
29 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")
|
|
}
|
|
|
|
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"task_list_id": taskListID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(tasks) > 0 {
|
|
return errors.New("cannot delete task list with tasks")
|
|
}
|
|
|
|
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
|
|
}
|