diff --git a/PERMISSIONS.md b/PERMISSIONS.md index 286e984..2a6b1a6 100644 --- a/PERMISSIONS.md +++ b/PERMISSIONS.md @@ -23,6 +23,7 @@ space.. - space_permission_key is derived from the space name (normalized token) - Example: - space.product_docs.note.create + - space.product_docs.tasks.create - space.product_docs.settings.member.manage ## Space-Scoped Actions Currently Enforced @@ -49,6 +50,16 @@ space.. - note.edit - note.delete +### Task Management + +- tasks.create +- tasks.edit +- tasks.delete + +### Task Status Management + +- tasks.status.manage + ## Wildcard Support Permissions support wildcard matching with \*. @@ -59,6 +70,8 @@ Examples: - Grants all permissions for the product_docs space - space.\*.note.create - Grants note.create for all spaces +- space.\*.tasks.\* + - Grants all task permissions for all spaces - `*` - Grants all permissions globally diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 8fb190b..c67f04d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -156,6 +156,15 @@ func main() { permissionService, ) + taskService := services.NewTaskService( + db.TaskRepo, + db.TaskStatusRepo, + db.NoteRepo, + db.CategoryRepo, + db.MembershipRepo, + permissionService, + ) + adminService := services.NewAdminService( db.UserRepo, db.GroupRepo, @@ -189,6 +198,7 @@ func main() { spaceHandler := handlers.NewSpaceHandler(spaceService) noteHandler := handlers.NewNoteHandler(noteService) categoryHandler := handlers.NewCategoryHandler(categoryService) + taskHandler := handlers.NewTaskHandler(taskService) adminHandler := handlers.NewAdminHandler(adminService) publicHandler := handlers.NewPublicHandler(spaceService, noteService) settingsHandler := handlers.NewSettingsHandler(authService) @@ -258,6 +268,25 @@ func main() { api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE") api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH") + // Task endpoints + api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.ListTasks).Methods("GET") + api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST") + api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET") + api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.GetTask).Methods("GET") + api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.UpdateTask).Methods("PUT") + api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.DeleteTask).Methods("DELETE") + api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/transition", taskHandler.TransitionTaskStatus).Methods("POST") + api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes", taskHandler.LinkTaskNote).Methods("POST") + api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE") + api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET") + + // Task status endpoints + api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.ListStatuses).Methods("GET") + api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.CreateStatus).Methods("POST") + api.HandleFunc("/spaces/{spaceId}/task-statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT") + api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT") + api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE") + // File explorer endpoints (space-scoped) api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET") api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.GetFile).Methods("GET") diff --git a/backend/internal/application/dto/dto.go b/backend/internal/application/dto/dto.go index ad8ea4f..a188f14 100644 --- a/backend/internal/application/dto/dto.go +++ b/backend/internal/application/dto/dto.go @@ -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 diff --git a/backend/internal/application/services/task_service.go b/backend/internal/application/services/task_service.go new file mode 100644 index 0000000..2809efb --- /dev/null +++ b/backend/internal/application/services/task_service.go @@ -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 +} diff --git a/backend/internal/domain/entities/task.go b/backend/internal/domain/entities/task.go new file mode 100644 index 0000000..40fc291 --- /dev/null +++ b/backend/internal/domain/entities/task.go @@ -0,0 +1,37 @@ +package entities + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +const MaxTaskDepth = 2 + +// Task represents a task and supports up to 3 nesting levels (0,1,2). +type Task struct { + ID bson.ObjectID `bson:"_id,omitempty"` + SpaceID bson.ObjectID `bson:"space_id"` + Title string `bson:"title"` + Description string `bson:"description"` + CategoryID *bson.ObjectID `bson:"category_id,omitempty"` + StatusID bson.ObjectID `bson:"status_id"` + ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"` + Depth int `bson:"depth"` + NoteLinks []bson.ObjectID `bson:"note_links"` + CreatedBy bson.ObjectID `bson:"created_by"` + UpdatedBy bson.ObjectID `bson:"updated_by"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` +} + +// TaskStatus defines the ordered linear status progression for a space. +type TaskStatus struct { + ID bson.ObjectID `bson:"_id,omitempty"` + SpaceID bson.ObjectID `bson:"space_id"` + Name string `bson:"name"` + Color string `bson:"color,omitempty"` + Order int `bson:"order"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` +} diff --git a/backend/internal/domain/repositories/interfaces.go b/backend/internal/domain/repositories/interfaces.go index 62137a6..6be8bb9 100644 --- a/backend/internal/domain/repositories/interfaces.go +++ b/backend/internal/domain/repositories/interfaces.go @@ -216,3 +216,24 @@ type NoteRevisionRepository interface { // GetRevisionByID retrieves a specific revision GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error) } + +// TaskRepository defines task operations +type TaskRepository interface { + CreateTask(ctx context.Context, task *entities.Task) error + GetTaskByID(ctx context.Context, id bson.ObjectID) (*entities.Task, error) + ListTasks(ctx context.Context, spaceID bson.ObjectID, filters map[string]any) ([]*entities.Task, error) + SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error) + UpdateTask(ctx context.Context, task *entities.Task) error + DeleteTask(ctx context.Context, id bson.ObjectID) error + DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error + CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error) +} + +// TaskStatusRepository defines task status operations +type TaskStatusRepository interface { + CreateStatus(ctx context.Context, status *entities.TaskStatus) error + GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error) + ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error) + UpdateStatus(ctx context.Context, status *entities.TaskStatus) error + DeleteStatus(ctx context.Context, id bson.ObjectID) error +} diff --git a/backend/internal/infrastructure/database/database.go b/backend/internal/infrastructure/database/database.go index 093397e..ae850b0 100644 --- a/backend/internal/infrastructure/database/database.go +++ b/backend/internal/infrastructure/database/database.go @@ -16,6 +16,8 @@ type Database struct { MembershipRepo *MembershipRepository NoteRepo *NoteRepository CategoryRepo *CategoryRepository + TaskRepo *TaskRepository + TaskStatusRepo *TaskStatusRepository RevisionRepo *NoteRevisionRepository GroupRepo *PermissionGroupRepository ProviderRepo *AuthProviderRepository @@ -47,6 +49,8 @@ func NewDatabase(ctx context.Context, mongoURL string) (*Database, error) { MembershipRepo: NewMembershipRepository(db), NoteRepo: NewNoteRepository(db), CategoryRepo: NewCategoryRepository(db), + TaskRepo: NewTaskRepository(db), + TaskStatusRepo: NewTaskStatusRepository(db), RevisionRepo: NewNoteRevisionRepository(db), GroupRepo: NewPermissionGroupRepository(db), ProviderRepo: NewAuthProviderRepository(db), @@ -80,6 +84,12 @@ func (d *Database) EnsureIndexes(ctx context.Context) error { if err := d.CategoryRepo.EnsureIndexes(ctx); err != nil { return err } + if err := d.TaskRepo.EnsureIndexes(ctx); err != nil { + return err + } + if err := d.TaskStatusRepo.EnsureIndexes(ctx); err != nil { + return err + } if err := d.GroupRepo.EnsureIndexes(ctx); err != nil { return err } diff --git a/backend/internal/infrastructure/database/task_repository.go b/backend/internal/infrastructure/database/task_repository.go new file mode 100644 index 0000000..742f586 --- /dev/null +++ b/backend/internal/infrastructure/database/task_repository.go @@ -0,0 +1,185 @@ +package database + +import ( + "context" + "errors" + "time" + + "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// TaskRepository implements task data access. +type TaskRepository struct { + collection *mongo.Collection +} + +// NewTaskRepository creates a new task repository. +func NewTaskRepository(db *mongo.Database) *TaskRepository { + return &TaskRepository{collection: db.Collection("tasks")} +} + +func (r *TaskRepository) CreateTask(ctx context.Context, task *entities.Task) error { + task.ID = bson.NewObjectID() + task.CreatedAt = time.Now() + task.UpdatedAt = time.Now() + if task.NoteLinks == nil { + task.NoteLinks = []bson.ObjectID{} + } + _, err := r.collection.InsertOne(ctx, task) + return err +} + +func (r *TaskRepository) GetTaskByID(ctx context.Context, id bson.ObjectID) (*entities.Task, error) { + var task entities.Task + err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&task) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("task not found") + } + return nil, err + } + return &task, nil +} + +func (r *TaskRepository) ListTasks(ctx context.Context, spaceID bson.ObjectID, filters map[string]any) ([]*entities.Task, error) { + query := bson.M{"space_id": spaceID} + for k, v := range filters { + query[k] = v + } + + opts := options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}) + cursor, err := r.collection.Find(ctx, query, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var tasks []*entities.Task + if err := cursor.All(ctx, &tasks); err != nil { + return nil, err + } + return tasks, nil +} + +func (r *TaskRepository) SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error) { + cursor, err := r.collection.Find(ctx, bson.M{ + "space_id": spaceID, + "$or": []bson.M{ + {"title": bson.M{"$regex": query, "$options": "i"}}, + {"description": bson.M{"$regex": query, "$options": "i"}}, + }, + }, options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetLimit(30)) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var tasks []*entities.Task + if err := cursor.All(ctx, &tasks); err != nil { + return nil, err + } + return tasks, nil +} + +func (r *TaskRepository) UpdateTask(ctx context.Context, task *entities.Task) error { + task.UpdatedAt = time.Now() + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": task.ID}, task) + return err +} + +func (r *TaskRepository) DeleteTask(ctx context.Context, id bson.ObjectID) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +func (r *TaskRepository) DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error { + _, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID}) + return err +} + +func (r *TaskRepository) CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error) { + return r.collection.CountDocuments(ctx, bson.M{"parent_task_id": parentTaskID}) +} + +func (r *TaskRepository) EnsureIndexes(ctx context.Context) error { + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "updated_at", Value: -1}}}, + {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "status_id", Value: 1}}}, + {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}}}, + {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "parent_task_id", Value: 1}}}, + {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "note_links", Value: 1}}}, + }) + return err +} + +// TaskStatusRepository implements task status data access. +type TaskStatusRepository struct { + collection *mongo.Collection +} + +// NewTaskStatusRepository creates a new task status repository. +func NewTaskStatusRepository(db *mongo.Database) *TaskStatusRepository { + return &TaskStatusRepository{collection: db.Collection("task_statuses")} +} + +func (r *TaskStatusRepository) CreateStatus(ctx context.Context, status *entities.TaskStatus) error { + status.ID = bson.NewObjectID() + status.CreatedAt = time.Now() + status.UpdatedAt = time.Now() + _, err := r.collection.InsertOne(ctx, status) + return err +} + +func (r *TaskStatusRepository) GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error) { + var status entities.TaskStatus + err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&status) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("task status not found") + } + return nil, err + } + return &status, nil +} + +func (r *TaskStatusRepository) ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error) { + cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}})) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var statuses []*entities.TaskStatus + if err := cursor.All(ctx, &statuses); err != nil { + return nil, err + } + return statuses, nil +} + +func (r *TaskStatusRepository) UpdateStatus(ctx context.Context, status *entities.TaskStatus) error { + status.UpdatedAt = time.Now() + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": status.ID}, status) + return err +} + +func (r *TaskStatusRepository) DeleteStatus(ctx context.Context, id bson.ObjectID) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error { + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "name", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "order", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + }) + return err +} diff --git a/backend/internal/interfaces/handlers/task_handler.go b/backend/internal/interfaces/handlers/task_handler.go new file mode 100644 index 0000000..b31aa81 --- /dev/null +++ b/backend/internal/interfaces/handlers/task_handler.go @@ -0,0 +1,398 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto" + "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services" + "github.com/gorilla/mux" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// TaskHandler handles task and task status endpoints. +type TaskHandler struct { + taskService *services.TaskService +} + +// NewTaskHandler creates a task handler. +func NewTaskHandler(taskService *services.TaskService) *TaskHandler { + return &TaskHandler{taskService: taskService} +} + +func parseIDsFromRequest(r *http.Request) (bson.ObjectID, bson.ObjectID, error) { + userID, err := getUserObjectID(r) + if err != nil { + return bson.NilObjectID, bson.NilObjectID, err + } + spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"]) + if err != nil { + return bson.NilObjectID, bson.NilObjectID, err + } + return userID, spaceID, nil +} + +func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + var req dto.CreateTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + task, err := h.taskService.CreateTask(r.Context(), spaceID, userID, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(task) +} + +func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + categoryID := strings.TrimSpace(r.URL.Query().Get("categoryId")) + statusID := strings.TrimSpace(r.URL.Query().Get("statusId")) + parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId")) + + categoryFilter := &categoryID + statusFilter := &statusID + parentFilter := &parentTaskID + if categoryID == "" { + categoryFilter = nil + } + if statusID == "" { + statusFilter = nil + } + if parentTaskID == "" { + parentFilter = nil + } + + tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, categoryFilter, statusFilter, parentFilter) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tasks) +} + +func (h *TaskHandler) SearchTasks(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + query := r.URL.Query().Get("q") + tasks, err := h.taskService.SearchTasks(r.Context(), spaceID, userID, query) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tasks) +} + +func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"]) + if err != nil { + http.Error(w, "invalid task id", http.StatusBadRequest) + return + } + + task, err := h.taskService.GetTaskByID(r.Context(), spaceID, taskID, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(task) +} + +func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"]) + if err != nil { + http.Error(w, "invalid task id", http.StatusBadRequest) + return + } + + var req dto.UpdateTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + task, err := h.taskService.UpdateTask(r.Context(), spaceID, taskID, userID, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(task) +} + +func (h *TaskHandler) DeleteTask(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"]) + if err != nil { + http.Error(w, "invalid task id", http.StatusBadRequest) + return + } + + if err := h.taskService.DeleteTask(r.Context(), spaceID, taskID, userID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *TaskHandler) TransitionTaskStatus(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"]) + if err != nil { + http.Error(w, "invalid task id", http.StatusBadRequest) + return + } + + var req dto.TaskTransitionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + task, err := h.taskService.TransitionTaskStatus(r.Context(), spaceID, taskID, userID, req.Direction) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(task) +} + +func (h *TaskHandler) LinkTaskNote(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"]) + if err != nil { + http.Error(w, "invalid task id", http.StatusBadRequest) + return + } + + var req dto.LinkTaskNoteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + noteID, err := bson.ObjectIDFromHex(req.NoteID) + if err != nil { + http.Error(w, "invalid note id", http.StatusBadRequest) + return + } + + task, err := h.taskService.LinkNoteToTask(r.Context(), spaceID, taskID, noteID, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(task) +} + +func (h *TaskHandler) UnlinkTaskNote(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"]) + if err != nil { + http.Error(w, "invalid task id", http.StatusBadRequest) + return + } + noteID, err := bson.ObjectIDFromHex(mux.Vars(r)["noteId"]) + if err != nil { + http.Error(w, "invalid note id", http.StatusBadRequest) + return + } + + task, err := h.taskService.UnlinkNoteFromTask(r.Context(), spaceID, taskID, noteID, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(task) +} + +func (h *TaskHandler) ListTasksByNote(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + noteID, err := bson.ObjectIDFromHex(mux.Vars(r)["noteId"]) + if err != nil { + http.Error(w, "invalid note id", http.StatusBadRequest) + return + } + + tasks, err := h.taskService.ListTasksLinkedToNote(r.Context(), spaceID, noteID, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tasks) +} + +func (h *TaskHandler) ListStatuses(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(statuses) +} + +func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + var req dto.CreateTaskStatusRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + status, err := h.taskService.CreateStatus(r.Context(), spaceID, userID, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(status) +} + +func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"]) + if err != nil { + http.Error(w, "invalid status id", http.StatusBadRequest) + return + } + + var req dto.UpdateTaskStatusRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + status, err := h.taskService.UpdateStatus(r.Context(), spaceID, statusID, userID, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) +} + +func (h *TaskHandler) DeleteStatus(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"]) + if err != nil { + http.Error(w, "invalid status id", http.StatusBadRequest) + return + } + + if err := h.taskService.DeleteStatus(r.Context(), spaceID, statusID, userID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + var req dto.ReorderTaskStatusesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, userID, req.OrderedStatusIDs) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(statuses) +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 37ee01d..7e15026 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -128,7 +128,11 @@
-
+
+ + +
+
- +
+ + - + @@ -25,19 +35,52 @@
-
+
+
+
Link task for "{{ taskMentionQuery }}"
+ +
-
-
+
+
-
+
+ +
+
+
+ + Space Tasks + +
+ +
+ + Loading tasks... +
+
No tasks found.
+
+ +
+
+
@@ -98,6 +141,7 @@ import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue"; import DOMPurify from "dompurify"; import { useSettingsStore } from "../stores/settingsStore"; +import { useSpaceStore } from "../stores/spaceStore"; import { renderMarkdown } from "../utils/markdown.js"; import FileExplorer from "./FileExplorer.vue"; @@ -120,8 +164,9 @@ const props = defineProps({ }, }); -const emit = defineEmits(["save", "delete", "cancel"]); +const emit = defineEmits(["save", "delete", "cancel", "open-linked-task"]); const settingsStore = useSettingsStore(); +const spaceStore = useSpaceStore(); const publicSharingEnabled = ref(true); const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled); @@ -135,12 +180,110 @@ const notePassword = ref(""); const saveTimeout = ref(null); const saveState = ref("saved"); const saveStateTimeout = ref(null); +const taskMentionQuery = ref(""); +const taskMentionResults = ref([]); +const showTaskMention = ref(false); +const linkedTasks = ref([]); +const showTaskPicker = ref(false); +const taskPickerQuery = ref(""); +const taskPickerLoading = ref(false); + +const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value); +const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value); + +const editorColumnClass = computed(() => { + if (hasTwoAuxPanels.value) { + return "col-12 col-xl-4"; + } + return hasAuxPanels.value ? "col-12 col-md-5" : "col-12 col-md-6"; +}); + +const previewColumnClass = computed(() => { + if (hasTwoAuxPanels.value) { + return "col-12 col-xl-4 mt-3 mt-xl-0"; + } + return hasAuxPanels.value ? "col-12 col-md-4 mt-3 mt-md-0" : "col-12 col-md-6 mt-3 mt-md-0"; +}); + +const fileExplorerColumnClass = computed(() => { + return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0"; +}); + +const taskPickerColumnClass = computed(() => { + return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0"; +}); + +const taskStatusNameById = computed(() => { + const map = new Map(); + for (const status of spaceStore.taskStatuses || []) { + if (status?.id) { + map.set(status.id, status.name || ""); + } + } + return map; +}); + +const taskPickerItems = computed(() => { + const query = taskPickerQuery.value.trim().toLowerCase(); + const allTasks = [...(spaceStore.tasks || [])] + .map((task) => ({ + ...task, + picker_status_name: task.status_name || task.status?.name || taskStatusNameById.value.get(task.status_id) || "Unknown", + })) + .sort((a, b) => (a.title || "").localeCompare(b.title || "")); + if (!query) { + return allTasks; + } + return allTasks.filter((task) => (task.title || "").toLowerCase().includes(query)); +}); const renderedMarkdown = computed(() => { - const html = renderMarkdown(editingNote.value.content || ""); + const html = renderMarkdown(enrichTaskMentions(editingNote.value.content || "")); return DOMPurify.sanitize(html); }); +const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase(); + +const taskByTitle = computed(() => { + const map = new Map(); + for (const task of linkedTasks.value || []) { + const key = normalizeTaskTitle(task.title); + if (!key || map.has(key)) { + continue; + } + map.set(key, task); + } + return map; +}); + +const enrichTaskMentions = (content) => { + if (!content) { + return ""; + } + + return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => { + const title = (rawTitle || "").trim(); + if (!title) { + return full; + } + + const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title)); + if (!linkedTask?.id) { + return full; + } + + const statusName = (linkedTask.status_name || "Unknown").trim(); + const safeTitle = title.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); + const safeStatusName = statusName.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); + + const statusColor = (linkedTask.status_color || "").trim(); + const safeStatusColor = statusColor.replace(/"/g, "").replace(//g, ""); + const styleAttr = safeStatusColor ? ` style="--task-status-color:${safeStatusColor}"` : ""; + + return `${safeTitle}${safeStatusName}`; + }); +}; + const saveStatusLabel = computed(() => { switch (saveState.value) { case "dirty": @@ -155,12 +298,21 @@ const saveStatusLabel = computed(() => { watch( () => props.note, - (newNote) => { + async (newNote) => { editingNote.value = { ...newNote }; tagsInput.value = newNote.tags?.join(", ") || ""; passwordAction.value = "keep"; notePassword.value = ""; saveState.value = "saved"; + if (props.spaceId && newNote?.id) { + try { + linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, newNote.id); + } catch { + linkedTasks.value = []; + } + } else { + linkedTasks.value = []; + } }, ); @@ -211,12 +363,15 @@ const saveNote = () => { notePassword.value = ""; } markSavedSoon(); + // Auto-link any @task(Title) mentions present in the saved content + syncTaskMentionLinks(note.content || ""); }; const autoSave = () => { saveState.value = "dirty"; clearTimeout(saveTimeout.value); saveTimeout.value = setTimeout(saveNote, 3000); + detectTaskMention(); }; const confirmDelete = () => { @@ -249,6 +404,152 @@ const insertAtCursor = (snippet) => { }); }; +const detectTaskMention = async () => { + const content = editingNote.value.content || ""; + const match = content.match(/@task\s+([^\n]{1,40})$/i); + if (!match || !props.spaceId) { + showTaskMention.value = false; + taskMentionResults.value = []; + taskMentionQuery.value = ""; + return; + } + const query = match[1].trim(); + taskMentionQuery.value = query; + if (!query) { + showTaskMention.value = false; + taskMentionResults.value = []; + return; + } + try { + taskMentionResults.value = await spaceStore.searchTasks(props.spaceId, query); + showTaskMention.value = taskMentionResults.value.length > 0; + } catch { + taskMentionResults.value = []; + showTaskMention.value = false; + } +}; + +const replaceTaskMentionText = (title) => { + editingNote.value.content = (editingNote.value.content || "").replace(/@task\s+([^\n]{1,40})$/i, `@task(${title})`); +}; + +const selectMentionTask = async (task) => { + replaceTaskMentionText(task.title); + showTaskMention.value = false; + taskMentionResults.value = []; + if (!props.spaceId || !editingNote.value.id) { + return; + } + try { + await spaceStore.linkTaskToNote(props.spaceId, task.id, editingNote.value.id); + linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id); + } catch { + alert("Unable to link task to this note."); + } + autoSave(); +}; + +const insertTaskMention = (task) => { + if (!task?.title) { + return; + } + insertAtCursor(`@task(${task.title})`); +}; + +const refreshTaskPicker = async () => { + if (!props.spaceId) { + return; + } + taskPickerLoading.value = true; + try { + await Promise.all([spaceStore.fetchTasks(props.spaceId), spaceStore.fetchTaskStatuses(props.spaceId)]); + } finally { + taskPickerLoading.value = false; + } +}; + +const toggleTaskPicker = async () => { + showTaskPicker.value = !showTaskPicker.value; + if (showTaskPicker.value && !spaceStore.tasks.length) { + await refreshTaskPicker(); + } +}; + +/** + * Parse all @task(Title) mentions in content and ensure each is linked. + * Called after every real save so new mentions are linked automatically. + */ +const syncTaskMentionLinks = async (content) => { + if (!props.spaceId || !editingNote.value.id) { + return; + } + const mentionTitles = new Set(); + const rx = /@task\(([^)]+)\)/gi; + let m; + while ((m = rx.exec(content)) !== null) { + const title = (m[1] || "").trim(); + if (title) { + mentionTitles.add(title.toLowerCase()); + } + } + if (!mentionTitles.size) { + return; + } + let current; + try { + current = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id); + } catch { + return; + } + const linkedTitles = new Set((current || []).map((t) => (t.title || "").toLowerCase())); + const toLink = [...mentionTitles].filter((title) => !linkedTitles.has(title)); + if (!toLink.length) { + linkedTasks.value = current; + return; + } + await Promise.all( + toLink.map(async (title) => { + try { + const results = await spaceStore.searchTasks(props.spaceId, title); + const exact = results.find((t) => (t.title || "").toLowerCase() === title); + if (exact) { + await spaceStore.linkTaskToNote(props.spaceId, exact.id, editingNote.value.id); + } + } catch { + // best-effort — skip silently + } + }), + ); + try { + linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id); + } catch { + // ignore + } +}; + +const onPreviewClick = (event) => { + const anchor = event.target?.closest?.("a"); + if (!anchor) { + return; + } + + const href = anchor.getAttribute("href") || ""; + if (!href.startsWith("#task:")) { + return; + } + + event.preventDefault(); + const taskId = href.slice("#task:".length); + if (!taskId) { + return; + } + + const matchedTask = (linkedTasks.value || []).find((task) => task.id === taskId); + if (matchedTask) { + emit("open-linked-task", matchedTask); + } +}; + onBeforeUnmount(() => { clearTimeout(saveTimeout.value); clearTimeout(saveStateTimeout.value); @@ -257,6 +558,16 @@ onBeforeUnmount(() => { onMounted(async () => { await settingsStore.loadFeatureFlags(); publicSharingEnabled.value = settingsStore.publicSharingEnabled; + if (props.spaceId && editingNote.value?.id) { + try { + linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id); + } catch { + linkedTasks.value = []; + } + } + if (props.spaceId && !spaceStore.tasks.length) { + await refreshTaskPicker(); + } }); @@ -347,6 +658,133 @@ onMounted(async () => { font-size: 0.9rem; } +.task-mention-panel { + margin-top: 0.45rem; + border: 1px solid #dbe4f0; + border-radius: 10px; + background: #fbfdff; + padding: 0.5rem; + max-height: 220px; + overflow-y: auto; +} + +.task-mention-option { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + border: 0; + background: transparent; + padding: 0.35rem 0.45rem; + border-radius: 6px; + text-align: left; +} + +.task-mention-option:hover { + background: #eef3ff; +} + +.task-picker { + background: #fff; + min-height: 300px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.task-picker-header { + background: #f8f9fa; + min-height: 34px; +} + +.task-picker-search { + background: #fff; +} + +.task-picker-list { + overflow-y: auto; + max-height: 520px; + padding: 0.25rem; +} + +.task-picker-item { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + border: 0; + background: transparent; + border-radius: 8px; + padding: 0.35rem 0.45rem; + text-align: left; + gap: 0.4rem; + color: #333; +} + +.task-picker-item:hover { + background: #eef3ff; +} + +.task-picker-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-picker-empty { + padding: 0.7rem; +} + +.task-picker-item small { + font-size: 0.7rem; + color: #6b7280; +} + +.task-picker .btn-link { + text-decoration: none; +} + +.task-picker-item { + flex-wrap: wrap; +} + +.markdown-body :deep(.task-inline-link) { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.18rem 0.5rem; + border-radius: 999px; + border: 1px solid #c7d8ff; + background: #eef4ff; + color: #2c4ea3; + font-weight: 600; + text-decoration: none; +} + +.markdown-body :deep(.task-inline-title) { + line-height: 1; +} + +.markdown-body :deep(.task-inline-status) { + line-height: 1; + font-size: 0.72rem; + font-weight: 700; + border-radius: 999px; + padding: 0.16rem 0.42rem; + border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, #ffffff 40%); + background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, #ffffff 82%); + color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%); +} + +.markdown-body :deep(.task-inline-link:hover) { + background: #dfeaff; + border-color: #aac4ff; +} + +.markdown-body :deep(.task-inline-link i) { + font-size: 0.9rem; +} + /* Dark mode overrides */ :root[data-bs-theme="dark"] .editor-toolbar { border-bottom-color: #3a3f4b; @@ -373,4 +811,63 @@ onMounted(async () => { :root[data-bs-theme="dark"] .danger-zone-copy { color: #fca5a5; } + +:root[data-bs-theme="dark"] .task-mention-panel { + border-color: #3a4558; + background: #1f2733; +} + +:root[data-bs-theme="dark"] .task-mention-option:hover { + background: #2b3646; +} + +:root[data-bs-theme="dark"] .task-picker { + border-color: #3a3f4b !important; + background: #21252e; +} + +:root[data-bs-theme="dark"] .task-picker-header { + background: #21252e; + border-bottom-color: #3a3f4b !important; +} + +:root[data-bs-theme="dark"] .task-picker-search { + background: #21252e; + border-bottom-color: #3a3f4b !important; +} + +:root[data-bs-theme="dark"] .task-picker-search .form-control { + background: #1f2430; + border-color: #3a3f4b; + color: #e2e8f0; +} + +:root[data-bs-theme="dark"] .task-picker-item { + color: #e2e8f0; +} + +:root[data-bs-theme="dark"] .task-picker-item:hover { + background: #2d3748; +} + +:root[data-bs-theme="dark"] .task-picker-item small { + color: #a8b4c7; +} + +:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) { + border-color: #35508b; + background: #1b2a4a; + color: #9ec0ff; +} + +:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) { + background: #22345c; + border-color: #4566ad; +} + +:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) { + border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%); + background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%); + color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%); +} diff --git a/frontend/src/components/NoteViewer.vue b/frontend/src/components/NoteViewer.vue index fc3f8b8..bcdd906 100644 --- a/frontend/src/components/NoteViewer.vue +++ b/frontend/src/components/NoteViewer.vue @@ -24,7 +24,7 @@
-
+
@@ -46,13 +46,61 @@ const props = defineProps({ type: String, default: "", }, + linkedTasks: { + type: Array, + default: () => [], + }, }); +const emit = defineEmits(["open-linked-task"]); + const renderedMarkdown = computed(() => { - const html = renderMarkdown(props.note.content || ""); + const html = renderMarkdown(enrichTaskMentions(props.note.content || "")); return DOMPurify.sanitize(html); }); +const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase(); + +const taskByTitle = computed(() => { + const map = new Map(); + for (const task of props.linkedTasks || []) { + const key = normalizeTaskTitle(task.title); + if (!key || map.has(key)) { + continue; + } + map.set(key, task); + } + return map; +}); + +const enrichTaskMentions = (content) => { + if (!content) { + return ""; + } + + return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => { + const title = (rawTitle || "").trim(); + if (!title) { + return full; + } + + const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title)); + if (!linkedTask?.id) { + return full; + } + + const statusName = (linkedTask.status_name || "Unknown").trim(); + const safeTitle = title.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); + const safeStatusName = statusName.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); + + const statusColor = (linkedTask.status_color || "").trim(); + const safeStatusColor = statusColor.replace(/"/g, "").replace(//g, ""); + const styleAttr = safeStatusColor ? ` style="--task-status-color:${safeStatusColor}"` : ""; + + return `${safeTitle}${safeStatusName}`; + }); +}; + const categoryLabel = computed(() => { const categoryId = props.note.category_id; if (!categoryId) { @@ -79,6 +127,29 @@ const categoryLabel = computed(() => { }); const formatDateTime = (dateString) => new Date(dateString).toLocaleString(); + +const onMarkdownClick = (event) => { + const anchor = event.target?.closest?.("a"); + if (!anchor) { + return; + } + + const href = anchor.getAttribute("href") || ""; + if (!href.startsWith("#task:")) { + return; + } + + event.preventDefault(); + const taskId = href.slice("#task:".length); + if (!taskId) { + return; + } + + const matchedTask = (props.linkedTasks || []).find((task) => task.id === taskId); + if (matchedTask) { + emit("open-linked-task", matchedTask); + } +}; diff --git a/frontend/src/components/TaskBoard.vue b/frontend/src/components/TaskBoard.vue new file mode 100644 index 0000000..0906fef --- /dev/null +++ b/frontend/src/components/TaskBoard.vue @@ -0,0 +1,892 @@ + + + + + diff --git a/frontend/src/components/TaskDetailModal.vue b/frontend/src/components/TaskDetailModal.vue new file mode 100644 index 0000000..5d30aeb --- /dev/null +++ b/frontend/src/components/TaskDetailModal.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/frontend/src/stores/spaceStore.js b/frontend/src/stores/spaceStore.js index f1b8fb9..2bef2f8 100644 --- a/frontend/src/stores/spaceStore.js +++ b/frontend/src/stores/spaceStore.js @@ -13,9 +13,12 @@ export const useSpaceStore = defineStore("space", () => { const notesLoading = ref(false); const categories = ref([]); const categoryTree = ref([]); + const tasks = ref([]); + const taskStatuses = ref([]); + const noteLinkedTasks = ref([]); const refreshSpaceData = async (spaceId) => { - await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId)]); + await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]); }; const fetchSpaces = async () => { @@ -208,6 +211,130 @@ export const useSpaceStore = defineStore("space", () => { searchResults.value = []; }; + const fetchTaskStatuses = async (spaceId) => { + if (!spaceId) { + taskStatuses.value = []; + return []; + } + try { + const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-statuses`); + taskStatuses.value = response.data || []; + return taskStatuses.value; + } catch (error) { + console.error("Error fetching task statuses:", error); + taskStatuses.value = []; + return []; + } + }; + + const createTaskStatus = async (spaceId, payload) => { + const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload); + await fetchTaskStatuses(spaceId); + return response.data; + }; + + const updateTaskStatus = async (spaceId, statusId, payload) => { + const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`, payload); + await fetchTaskStatuses(spaceId); + return response.data; + }; + + const deleteTaskStatus = async (spaceId, statusId) => { + await apiClient.delete(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`); + await fetchTaskStatuses(spaceId); + }; + + const reorderTaskStatuses = async (spaceId, orderedStatusIds) => { + const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/reorder`, { + ordered_status_ids: orderedStatusIds, + }); + taskStatuses.value = response.data || []; + return taskStatuses.value; + }; + + const fetchTasks = async (spaceId, filters = {}) => { + if (!spaceId) { + tasks.value = []; + return []; + } + const params = {}; + if (filters.categoryId) { + params.categoryId = filters.categoryId; + } + if (filters.statusId) { + params.statusId = filters.statusId; + } + if (typeof filters.parentTaskId === "string") { + params.parentTaskId = filters.parentTaskId; + } + + try { + const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks`, { params }); + tasks.value = response.data || []; + return tasks.value; + } catch (error) { + console.error("Error fetching tasks:", error); + tasks.value = []; + return []; + } + }; + + const searchTasks = async (spaceId, query) => { + if (!spaceId || !query?.trim()) { + return []; + } + const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks/search`, { params: { q: query } }); + return response.data || []; + }; + + const getTask = async (spaceId, taskId) => { + const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks/${taskId}`); + return response.data; + }; + + const createTask = async (spaceId, payload) => { + const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks`, payload); + await fetchTasks(spaceId); + return response.data; + }; + + const updateTask = async (spaceId, taskId, payload) => { + const response = await apiClient.put(`/api/v1/spaces/${spaceId}/tasks/${taskId}`, payload); + await fetchTasks(spaceId); + return response.data; + }; + + const deleteTask = async (spaceId, taskId) => { + await apiClient.delete(`/api/v1/spaces/${spaceId}/tasks/${taskId}`); + await fetchTasks(spaceId); + }; + + const transitionTask = async (spaceId, taskId, direction) => { + const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks/${taskId}/transition`, { direction }); + await fetchTasks(spaceId); + return response.data; + }; + + const fetchTasksForNote = async (spaceId, noteId) => { + if (!spaceId || !noteId) { + noteLinkedTasks.value = []; + return []; + } + const response = await apiClient.get(`/api/v1/spaces/${spaceId}/notes/${noteId}/tasks`); + noteLinkedTasks.value = response.data || []; + return noteLinkedTasks.value; + }; + + const linkTaskToNote = async (spaceId, taskId, noteId) => { + const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks/${taskId}/notes`, { note_id: noteId }); + return response.data; + }; + + const unlinkTaskFromNote = async (spaceId, taskId, noteId) => { + const response = await apiClient.delete(`/api/v1/spaces/${spaceId}/tasks/${taskId}/notes/${noteId}`); + return response.data; + }; + return { spaces, currentSpace, @@ -217,6 +344,9 @@ export const useSpaceStore = defineStore("space", () => { notesLoading, categories, categoryTree, + tasks, + taskStatuses, + noteLinkedTasks, fetchSpaces, selectSpace, fetchNotes, @@ -232,5 +362,20 @@ export const useSpaceStore = defineStore("space", () => { deleteNote, searchNotes, clearSearchResults, + fetchTaskStatuses, + createTaskStatus, + updateTaskStatus, + deleteTaskStatus, + reorderTaskStatuses, + fetchTasks, + searchTasks, + getTask, + createTask, + updateTask, + deleteTask, + transitionTask, + fetchTasksForNote, + linkTaskToNote, + unlinkTaskFromNote, }; });