feat: task system
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m55s
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m55s
This commit is contained in:
@@ -452,6 +452,141 @@ func NewCategoryDTO(category *entities.Category) *CategoryDTO {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== TASK DTOs ==========
|
||||
|
||||
// CreateTaskRequest represents task creation input.
|
||||
type CreateTaskRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=255"`
|
||||
Description string `json:"description" validate:"max=2000"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
StatusID string `json:"status_id" validate:"required"`
|
||||
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||
NoteLinks []string `json:"note_links"`
|
||||
}
|
||||
|
||||
// UpdateTaskRequest represents task update input.
|
||||
type UpdateTaskRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
StatusID *string `json:"status_id,omitempty"`
|
||||
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||
NoteLinks []string `json:"note_links,omitempty"`
|
||||
}
|
||||
|
||||
// TaskTransitionRequest allows moving task status by one step.
|
||||
type TaskTransitionRequest struct {
|
||||
Direction string `json:"direction" validate:"required,oneof=forward backward"`
|
||||
}
|
||||
|
||||
// LinkTaskNoteRequest links/unlinks a note from a task.
|
||||
type LinkTaskNoteRequest struct {
|
||||
NoteID string `json:"note_id" validate:"required"`
|
||||
}
|
||||
|
||||
// TaskDTO represents a task in API responses.
|
||||
type TaskDTO struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"space_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
StatusID string `json:"status_id"`
|
||||
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||
Depth int `json:"depth"`
|
||||
NoteLinks []string `json:"note_links"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskWithStatusDTO includes status details and child tasks for detail views.
|
||||
type TaskWithStatusDTO struct {
|
||||
*TaskDTO
|
||||
StatusName string `json:"status_name"`
|
||||
StatusColor string `json:"status_color,omitempty"`
|
||||
StatusOrder int `json:"status_order"`
|
||||
Subtasks []*TaskDTO `json:"subtasks"`
|
||||
}
|
||||
|
||||
// CreateTaskStatusRequest represents task status creation input.
|
||||
type CreateTaskStatusRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Color string `json:"color,omitempty" validate:"max=20"`
|
||||
}
|
||||
|
||||
// UpdateTaskStatusRequest represents task status updates.
|
||||
type UpdateTaskStatusRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Color string `json:"color,omitempty" validate:"max=20"`
|
||||
}
|
||||
|
||||
// ReorderTaskStatusesRequest represents a full ordered status ID list.
|
||||
type ReorderTaskStatusesRequest struct {
|
||||
OrderedStatusIDs []string `json:"ordered_status_ids"`
|
||||
}
|
||||
|
||||
// TaskStatusDTO represents a task status in API responses.
|
||||
type TaskStatusDTO struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"space_id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Order int `json:"order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NewTaskDTO creates a DTO from a task entity.
|
||||
func NewTaskDTO(task *entities.Task) *TaskDTO {
|
||||
var categoryID *string
|
||||
if task.CategoryID != nil {
|
||||
id := task.CategoryID.Hex()
|
||||
categoryID = &id
|
||||
}
|
||||
|
||||
var parentTaskID *string
|
||||
if task.ParentTaskID != nil {
|
||||
id := task.ParentTaskID.Hex()
|
||||
parentTaskID = &id
|
||||
}
|
||||
|
||||
noteLinks := make([]string, 0, len(task.NoteLinks))
|
||||
for _, noteID := range task.NoteLinks {
|
||||
noteLinks = append(noteLinks, noteID.Hex())
|
||||
}
|
||||
|
||||
return &TaskDTO{
|
||||
ID: task.ID.Hex(),
|
||||
SpaceID: task.SpaceID.Hex(),
|
||||
Title: task.Title,
|
||||
Description: task.Description,
|
||||
CategoryID: categoryID,
|
||||
StatusID: task.StatusID.Hex(),
|
||||
ParentTaskID: parentTaskID,
|
||||
Depth: task.Depth,
|
||||
NoteLinks: noteLinks,
|
||||
CreatedBy: task.CreatedBy.Hex(),
|
||||
UpdatedBy: task.UpdatedBy.Hex(),
|
||||
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: task.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// NewTaskStatusDTO creates a DTO from a task status entity.
|
||||
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
|
||||
return &TaskStatusDTO{
|
||||
ID: status.ID.Hex(),
|
||||
SpaceID: status.SpaceID.Hex(),
|
||||
Name: status.Name,
|
||||
Color: status.Color,
|
||||
Order: status.Order,
|
||||
CreatedAt: status.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: status.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// ========== ERROR DTOs ==========
|
||||
|
||||
// ErrorResponse represents an error response
|
||||
|
||||
880
backend/internal/application/services/task_service.go
Normal file
880
backend/internal/application/services/task_service.go
Normal file
@@ -0,0 +1,880 @@
|
||||
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
|
||||
taskStatusRepo repositories.TaskStatusRepository
|
||||
noteRepo repositories.NoteRepository
|
||||
categoryRepo repositories.CategoryRepository
|
||||
membershipRepo repositories.MembershipRepository
|
||||
permissionService *PermissionService
|
||||
}
|
||||
|
||||
// NewTaskService creates a task service.
|
||||
func NewTaskService(
|
||||
taskRepo repositories.TaskRepository,
|
||||
taskStatusRepo repositories.TaskStatusRepository,
|
||||
noteRepo repositories.NoteRepository,
|
||||
categoryRepo repositories.CategoryRepository,
|
||||
membershipRepo repositories.MembershipRepository,
|
||||
permissionService *PermissionService,
|
||||
) *TaskService {
|
||||
return &TaskService{
|
||||
taskRepo: taskRepo,
|
||||
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) validateCategory(ctx context.Context, spaceID bson.ObjectID, categoryID *bson.ObjectID) error {
|
||||
if categoryID == nil {
|
||||
return nil
|
||||
}
|
||||
category, err := s.categoryRepo.GetCategoryByID(ctx, *categoryID)
|
||||
if err != nil || category.SpaceID != spaceID {
|
||||
return errors.New("invalid category")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
categoryID, err := toObjectIDPtr(req.CategoryID)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid category")
|
||||
}
|
||||
if err := s.validateCategory(ctx, spaceID, categoryID); 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
task := &entities.Task{
|
||||
SpaceID: spaceID,
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
CategoryID: categoryID,
|
||||
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,
|
||||
categoryID, 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 categoryID != nil && strings.TrimSpace(*categoryID) != "" {
|
||||
id, err := bson.ObjectIDFromHex(*categoryID)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid category filter")
|
||||
}
|
||||
filters["category_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.CategoryID != nil {
|
||||
if strings.TrimSpace(*req.CategoryID) == "" {
|
||||
task.CategoryID = nil
|
||||
} else {
|
||||
categoryID, parseErr := bson.ObjectIDFromHex(*req.CategoryID)
|
||||
if parseErr != nil {
|
||||
return nil, errors.New("invalid category")
|
||||
}
|
||||
task.CategoryID = &categoryID
|
||||
}
|
||||
if err := s.validateCategory(ctx, spaceID, task.CategoryID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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) 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
|
||||
}
|
||||
Reference in New Issue
Block a user