feat: task system
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m55s

This commit is contained in:
domrichardson
2026-03-27 16:33:11 +00:00
parent d793b5ccf2
commit 1b336299ee
15 changed files with 3876 additions and 17 deletions

View File

@@ -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

View 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
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}