feat: task system
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m55s
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m55s
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -452,6 +452,141 @@ func NewCategoryDTO(category *entities.Category) *CategoryDTO {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== TASK DTOs ==========
|
||||
|
||||
// CreateTaskRequest represents task creation input.
|
||||
type CreateTaskRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=255"`
|
||||
Description string `json:"description" validate:"max=2000"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
StatusID string `json:"status_id" validate:"required"`
|
||||
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||
NoteLinks []string `json:"note_links"`
|
||||
}
|
||||
|
||||
// UpdateTaskRequest represents task update input.
|
||||
type UpdateTaskRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
StatusID *string `json:"status_id,omitempty"`
|
||||
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||
NoteLinks []string `json:"note_links,omitempty"`
|
||||
}
|
||||
|
||||
// TaskTransitionRequest allows moving task status by one step.
|
||||
type TaskTransitionRequest struct {
|
||||
Direction string `json:"direction" validate:"required,oneof=forward backward"`
|
||||
}
|
||||
|
||||
// LinkTaskNoteRequest links/unlinks a note from a task.
|
||||
type LinkTaskNoteRequest struct {
|
||||
NoteID string `json:"note_id" validate:"required"`
|
||||
}
|
||||
|
||||
// TaskDTO represents a task in API responses.
|
||||
type TaskDTO struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"space_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
StatusID string `json:"status_id"`
|
||||
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||
Depth int `json:"depth"`
|
||||
NoteLinks []string `json:"note_links"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskWithStatusDTO includes status details and child tasks for detail views.
|
||||
type TaskWithStatusDTO struct {
|
||||
*TaskDTO
|
||||
StatusName string `json:"status_name"`
|
||||
StatusColor string `json:"status_color,omitempty"`
|
||||
StatusOrder int `json:"status_order"`
|
||||
Subtasks []*TaskDTO `json:"subtasks"`
|
||||
}
|
||||
|
||||
// CreateTaskStatusRequest represents task status creation input.
|
||||
type CreateTaskStatusRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Color string `json:"color,omitempty" validate:"max=20"`
|
||||
}
|
||||
|
||||
// UpdateTaskStatusRequest represents task status updates.
|
||||
type UpdateTaskStatusRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Color string `json:"color,omitempty" validate:"max=20"`
|
||||
}
|
||||
|
||||
// ReorderTaskStatusesRequest represents a full ordered status ID list.
|
||||
type ReorderTaskStatusesRequest struct {
|
||||
OrderedStatusIDs []string `json:"ordered_status_ids"`
|
||||
}
|
||||
|
||||
// TaskStatusDTO represents a task status in API responses.
|
||||
type TaskStatusDTO struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"space_id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Order int `json:"order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NewTaskDTO creates a DTO from a task entity.
|
||||
func NewTaskDTO(task *entities.Task) *TaskDTO {
|
||||
var categoryID *string
|
||||
if task.CategoryID != nil {
|
||||
id := task.CategoryID.Hex()
|
||||
categoryID = &id
|
||||
}
|
||||
|
||||
var parentTaskID *string
|
||||
if task.ParentTaskID != nil {
|
||||
id := task.ParentTaskID.Hex()
|
||||
parentTaskID = &id
|
||||
}
|
||||
|
||||
noteLinks := make([]string, 0, len(task.NoteLinks))
|
||||
for _, noteID := range task.NoteLinks {
|
||||
noteLinks = append(noteLinks, noteID.Hex())
|
||||
}
|
||||
|
||||
return &TaskDTO{
|
||||
ID: task.ID.Hex(),
|
||||
SpaceID: task.SpaceID.Hex(),
|
||||
Title: task.Title,
|
||||
Description: task.Description,
|
||||
CategoryID: categoryID,
|
||||
StatusID: task.StatusID.Hex(),
|
||||
ParentTaskID: parentTaskID,
|
||||
Depth: task.Depth,
|
||||
NoteLinks: noteLinks,
|
||||
CreatedBy: task.CreatedBy.Hex(),
|
||||
UpdatedBy: task.UpdatedBy.Hex(),
|
||||
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: task.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// NewTaskStatusDTO creates a DTO from a task status entity.
|
||||
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
|
||||
return &TaskStatusDTO{
|
||||
ID: status.ID.Hex(),
|
||||
SpaceID: status.SpaceID.Hex(),
|
||||
Name: status.Name,
|
||||
Color: status.Color,
|
||||
Order: status.Order,
|
||||
CreatedAt: status.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: status.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// ========== ERROR DTOs ==========
|
||||
|
||||
// ErrorResponse represents an error response
|
||||
|
||||
880
backend/internal/application/services/task_service.go
Normal file
880
backend/internal/application/services/task_service.go
Normal file
@@ -0,0 +1,880 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// TaskService handles task and task status operations.
|
||||
type TaskService struct {
|
||||
taskRepo repositories.TaskRepository
|
||||
taskStatusRepo repositories.TaskStatusRepository
|
||||
noteRepo repositories.NoteRepository
|
||||
categoryRepo repositories.CategoryRepository
|
||||
membershipRepo repositories.MembershipRepository
|
||||
permissionService *PermissionService
|
||||
}
|
||||
|
||||
// NewTaskService creates a task service.
|
||||
func NewTaskService(
|
||||
taskRepo repositories.TaskRepository,
|
||||
taskStatusRepo repositories.TaskStatusRepository,
|
||||
noteRepo repositories.NoteRepository,
|
||||
categoryRepo repositories.CategoryRepository,
|
||||
membershipRepo repositories.MembershipRepository,
|
||||
permissionService *PermissionService,
|
||||
) *TaskService {
|
||||
return &TaskService{
|
||||
taskRepo: taskRepo,
|
||||
taskStatusRepo: taskStatusRepo,
|
||||
noteRepo: noteRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
membershipRepo: membershipRepo,
|
||||
permissionService: permissionService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.ObjectID) error {
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(statuses) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
defaults := []struct {
|
||||
name string
|
||||
color string
|
||||
}{
|
||||
{name: "Pending", color: "#7c8596"},
|
||||
{name: "In Progress", color: "#3b82f6"},
|
||||
{name: "Done", color: "#22c55e"},
|
||||
}
|
||||
|
||||
for idx, status := range defaults {
|
||||
if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{
|
||||
SpaceID: spaceID,
|
||||
Name: status.name,
|
||||
Color: status.color,
|
||||
Order: idx,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TaskService) hasTaskPermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
|
||||
if s.permissionService == nil {
|
||||
return false, errors.New("permission service unavailable")
|
||||
}
|
||||
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
|
||||
}
|
||||
|
||||
func (s *TaskService) requireSpaceAccess(ctx context.Context, userID, spaceID bson.ObjectID) error {
|
||||
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toObjectIDPtr(hexID *string) (*bson.ObjectID, error) {
|
||||
if hexID == nil || strings.TrimSpace(*hexID) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id, err := bson.ObjectIDFromHex(*hexID)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid object id")
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
func toObjectIDs(hexIDs []string) ([]bson.ObjectID, error) {
|
||||
result := make([]bson.ObjectID, 0, len(hexIDs))
|
||||
seen := map[bson.ObjectID]struct{}{}
|
||||
for _, hexID := range hexIDs {
|
||||
hexID = strings.TrimSpace(hexID)
|
||||
if hexID == "" {
|
||||
continue
|
||||
}
|
||||
id, err := bson.ObjectIDFromHex(hexID)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid linked note id")
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
result = append(result, id)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) validateCategory(ctx context.Context, spaceID bson.ObjectID, categoryID *bson.ObjectID) error {
|
||||
if categoryID == nil {
|
||||
return nil
|
||||
}
|
||||
category, err := s.categoryRepo.GetCategoryByID(ctx, *categoryID)
|
||||
if err != nil || category.SpaceID != spaceID {
|
||||
return errors.New("invalid category")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TaskService) validateNoteLinks(ctx context.Context, spaceID bson.ObjectID, noteLinks []bson.ObjectID) error {
|
||||
for _, noteID := range noteLinks {
|
||||
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||
if err != nil || note.SpaceID != spaceID {
|
||||
return errors.New("invalid linked note")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TaskService) validateStatus(ctx context.Context, spaceID, statusID bson.ObjectID) (*entities.TaskStatus, error) {
|
||||
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
|
||||
if err != nil || status.SpaceID != spaceID {
|
||||
return nil, errors.New("invalid task status")
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) resolveDepthAndParent(ctx context.Context, spaceID bson.ObjectID, parentTaskID *bson.ObjectID) (int, error) {
|
||||
if parentTaskID == nil {
|
||||
return 0, nil
|
||||
}
|
||||
parent, err := s.taskRepo.GetTaskByID(ctx, *parentTaskID)
|
||||
if err != nil || parent.SpaceID != spaceID {
|
||||
return 0, errors.New("invalid parent task")
|
||||
}
|
||||
depth := parent.Depth + 1
|
||||
if depth > entities.MaxTaskDepth {
|
||||
return 0, fmt.Errorf("max task depth is %d", entities.MaxTaskDepth+1)
|
||||
}
|
||||
return depth, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) isAdjacentStatusMove(ctx context.Context, spaceID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) {
|
||||
if currentStatusID == requestedStatusID {
|
||||
return true, nil
|
||||
}
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
currentIdx := -1
|
||||
requestedIdx := -1
|
||||
for idx, status := range statuses {
|
||||
if status.ID == currentStatusID {
|
||||
currentIdx = idx
|
||||
}
|
||||
if status.ID == requestedStatusID {
|
||||
requestedIdx = idx
|
||||
}
|
||||
}
|
||||
if currentIdx == -1 || requestedIdx == -1 {
|
||||
return false, errors.New("status not found in sequence")
|
||||
}
|
||||
delta := requestedIdx - currentIdx
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
return delta == 1, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskRequest) (*dto.TaskDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.create")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
categoryID, err := toObjectIDPtr(req.CategoryID)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid category")
|
||||
}
|
||||
if err := s.validateCategory(ctx, spaceID, categoryID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentTaskID, err := toObjectIDPtr(req.ParentTaskID)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid parent task")
|
||||
}
|
||||
depth, err := s.resolveDepthAndParent(ctx, spaceID, parentTaskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
noteLinks, err := toObjectIDs(req.NoteLinks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateNoteLinks(ctx, spaceID, noteLinks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(statuses) == 0 {
|
||||
return nil, errors.New("no task statuses configured")
|
||||
}
|
||||
sort.Slice(statuses, func(i, j int) bool {
|
||||
return statuses[i].Order < statuses[j].Order
|
||||
})
|
||||
|
||||
statusID := statuses[0].ID
|
||||
requestedStatusID := strings.TrimSpace(req.StatusID)
|
||||
if requestedStatusID != "" && parentTaskID == nil {
|
||||
parsedStatusID, parseErr := bson.ObjectIDFromHex(requestedStatusID)
|
||||
if parseErr != nil {
|
||||
return nil, errors.New("invalid task status")
|
||||
}
|
||||
if _, validateErr := s.validateStatus(ctx, spaceID, parsedStatusID); validateErr != nil {
|
||||
return nil, validateErr
|
||||
}
|
||||
statusID = parsedStatusID
|
||||
}
|
||||
|
||||
task := &entities.Task{
|
||||
SpaceID: spaceID,
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
CategoryID: categoryID,
|
||||
StatusID: statusID,
|
||||
ParentTaskID: parentTaskID,
|
||||
Depth: depth,
|
||||
NoteLinks: noteLinks,
|
||||
CreatedBy: userID,
|
||||
UpdatedBy: userID,
|
||||
}
|
||||
|
||||
if task.Title == "" {
|
||||
return nil, errors.New("title is required")
|
||||
}
|
||||
|
||||
if err := s.taskRepo.CreateTask(ctx, task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dto.NewTaskDTO(task), nil
|
||||
}
|
||||
|
||||
func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID bson.ObjectID) (*dto.TaskWithStatusDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
|
||||
if err != nil || task.SpaceID != spaceID {
|
||||
return nil, errors.New("task not found")
|
||||
}
|
||||
|
||||
status, err := s.validateStatus(ctx, spaceID, task.StatusID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
subtasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"parent_task_id": task.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
subtaskDTOs := make([]*dto.TaskDTO, 0, len(subtasks))
|
||||
for _, subtask := range subtasks {
|
||||
subtaskDTOs = append(subtaskDTOs, dto.NewTaskDTO(subtask))
|
||||
}
|
||||
|
||||
return &dto.TaskWithStatusDTO{
|
||||
TaskDTO: dto.NewTaskDTO(task),
|
||||
StatusName: status.Name,
|
||||
StatusColor: status.Color,
|
||||
StatusOrder: status.Order,
|
||||
Subtasks: subtaskDTOs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) ListTasks(
|
||||
ctx context.Context,
|
||||
spaceID, userID bson.ObjectID,
|
||||
categoryID, statusID, parentTaskID *string,
|
||||
) ([]*dto.TaskDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filters := map[string]any{}
|
||||
if categoryID != nil && strings.TrimSpace(*categoryID) != "" {
|
||||
id, err := bson.ObjectIDFromHex(*categoryID)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid category filter")
|
||||
}
|
||||
filters["category_id"] = id
|
||||
}
|
||||
if statusID != nil && strings.TrimSpace(*statusID) != "" {
|
||||
id, err := bson.ObjectIDFromHex(*statusID)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid status filter")
|
||||
}
|
||||
filters["status_id"] = id
|
||||
}
|
||||
if parentTaskID != nil {
|
||||
parentFilter := strings.TrimSpace(*parentTaskID)
|
||||
switch parentFilter {
|
||||
case "":
|
||||
case "root":
|
||||
filters["parent_task_id"] = nil
|
||||
default:
|
||||
id, err := bson.ObjectIDFromHex(parentFilter)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid parent task filter")
|
||||
}
|
||||
filters["parent_task_id"] = id
|
||||
}
|
||||
}
|
||||
|
||||
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*dto.TaskDTO, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
result = append(result, dto.NewTaskDTO(task))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) SearchTasks(ctx context.Context, spaceID, userID bson.ObjectID, query string) ([]*dto.TaskDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return []*dto.TaskDTO{}, nil
|
||||
}
|
||||
tasks, err := s.taskRepo.SearchTasks(ctx, spaceID, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*dto.TaskDTO, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
result = append(result, dto.NewTaskDTO(task))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) ListTasksLinkedToNote(ctx context.Context, spaceID, noteID, userID bson.ObjectID) ([]*dto.TaskWithStatusDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil {
|
||||
return nil, errors.New("note not found")
|
||||
}
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statusByID := map[bson.ObjectID]*entities.TaskStatus{}
|
||||
for _, status := range statuses {
|
||||
statusByID[status.ID] = status
|
||||
}
|
||||
|
||||
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"note_links": noteID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*dto.TaskWithStatusDTO, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
status := statusByID[task.StatusID]
|
||||
if status == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, &dto.TaskWithStatusDTO{
|
||||
TaskDTO: dto.NewTaskDTO(task),
|
||||
StatusName: status.Name,
|
||||
StatusColor: status.Color,
|
||||
StatusOrder: status.Order,
|
||||
Subtasks: []*dto.TaskDTO{},
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bson.ObjectID, req *dto.UpdateTaskRequest) (*dto.TaskDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
|
||||
if err != nil || task.SpaceID != spaceID {
|
||||
return nil, errors.New("task not found")
|
||||
}
|
||||
|
||||
if req.Title != nil {
|
||||
task.Title = strings.TrimSpace(*req.Title)
|
||||
if task.Title == "" {
|
||||
return nil, errors.New("title is required")
|
||||
}
|
||||
}
|
||||
if req.Description != nil {
|
||||
task.Description = strings.TrimSpace(*req.Description)
|
||||
}
|
||||
|
||||
if req.CategoryID != nil {
|
||||
if strings.TrimSpace(*req.CategoryID) == "" {
|
||||
task.CategoryID = nil
|
||||
} else {
|
||||
categoryID, parseErr := bson.ObjectIDFromHex(*req.CategoryID)
|
||||
if parseErr != nil {
|
||||
return nil, errors.New("invalid category")
|
||||
}
|
||||
task.CategoryID = &categoryID
|
||||
}
|
||||
if err := s.validateCategory(ctx, spaceID, task.CategoryID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if req.ParentTaskID != nil {
|
||||
if strings.TrimSpace(*req.ParentTaskID) == "" {
|
||||
task.ParentTaskID = nil
|
||||
task.Depth = 0
|
||||
} else {
|
||||
parentID, parseErr := bson.ObjectIDFromHex(*req.ParentTaskID)
|
||||
if parseErr != nil {
|
||||
return nil, errors.New("invalid parent task")
|
||||
}
|
||||
if parentID == task.ID {
|
||||
return nil, errors.New("task cannot be its own parent")
|
||||
}
|
||||
depth, depthErr := s.resolveDepthAndParent(ctx, spaceID, &parentID)
|
||||
if depthErr != nil {
|
||||
return nil, depthErr
|
||||
}
|
||||
task.ParentTaskID = &parentID
|
||||
task.Depth = depth
|
||||
}
|
||||
}
|
||||
|
||||
if req.StatusID != nil {
|
||||
statusID, parseErr := bson.ObjectIDFromHex(*req.StatusID)
|
||||
if parseErr != nil {
|
||||
return nil, errors.New("invalid status")
|
||||
}
|
||||
if _, err := s.validateStatus(ctx, spaceID, statusID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
adjacent, err := s.isAdjacentStatusMove(ctx, spaceID, task.StatusID, statusID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !adjacent {
|
||||
return nil, errors.New("status transition must follow adjacent order sequence")
|
||||
}
|
||||
task.StatusID = statusID
|
||||
}
|
||||
|
||||
if req.NoteLinks != nil {
|
||||
noteLinks, convertErr := toObjectIDs(req.NoteLinks)
|
||||
if convertErr != nil {
|
||||
return nil, convertErr
|
||||
}
|
||||
if err := s.validateNoteLinks(ctx, spaceID, noteLinks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.NoteLinks = noteLinks
|
||||
}
|
||||
|
||||
task.UpdatedBy = userID
|
||||
task.UpdatedAt = time.Now()
|
||||
if err := s.taskRepo.UpdateTask(ctx, task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dto.NewTaskDTO(task), nil
|
||||
}
|
||||
|
||||
func (s *TaskService) DeleteTask(ctx context.Context, spaceID, taskID, userID bson.ObjectID) error {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.delete")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasPermission {
|
||||
return errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
|
||||
if err != nil || task.SpaceID != spaceID {
|
||||
return errors.New("task not found")
|
||||
}
|
||||
|
||||
if childCount, err := s.taskRepo.CountChildren(ctx, task.ID); err == nil && childCount > 0 {
|
||||
return errors.New("cannot delete task with subtasks")
|
||||
}
|
||||
|
||||
return s.taskRepo.DeleteTask(ctx, taskID)
|
||||
}
|
||||
|
||||
func (s *TaskService) TransitionTaskStatus(ctx context.Context, spaceID, taskID, userID bson.ObjectID, direction string) (*dto.TaskDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
|
||||
if err != nil || task.SpaceID != spaceID {
|
||||
return nil, errors.New("task not found")
|
||||
}
|
||||
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current := -1
|
||||
for idx, status := range statuses {
|
||||
if status.ID == task.StatusID {
|
||||
current = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
if current == -1 {
|
||||
return nil, errors.New("task has invalid status")
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(direction)) {
|
||||
case "forward":
|
||||
if current+1 >= len(statuses) {
|
||||
return nil, errors.New("task is already at final status")
|
||||
}
|
||||
task.StatusID = statuses[current+1].ID
|
||||
case "backward":
|
||||
if current-1 < 0 {
|
||||
return nil, errors.New("task is already at initial status")
|
||||
}
|
||||
task.StatusID = statuses[current-1].ID
|
||||
default:
|
||||
return nil, errors.New("invalid transition direction")
|
||||
}
|
||||
|
||||
task.UpdatedBy = userID
|
||||
if err := s.taskRepo.UpdateTask(ctx, task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dto.NewTaskDTO(task), nil
|
||||
}
|
||||
|
||||
func (s *TaskService) LinkNoteToTask(ctx context.Context, spaceID, taskID, noteID, userID bson.ObjectID) (*dto.TaskDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
|
||||
if err != nil || task.SpaceID != spaceID {
|
||||
return nil, errors.New("task not found")
|
||||
}
|
||||
|
||||
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||
if err != nil || note.SpaceID != spaceID {
|
||||
return nil, errors.New("note not found")
|
||||
}
|
||||
|
||||
for _, linkedNoteID := range task.NoteLinks {
|
||||
if linkedNoteID == noteID {
|
||||
return dto.NewTaskDTO(task), nil
|
||||
}
|
||||
}
|
||||
task.NoteLinks = append(task.NoteLinks, noteID)
|
||||
task.UpdatedBy = userID
|
||||
if err := s.taskRepo.UpdateTask(ctx, task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dto.NewTaskDTO(task), nil
|
||||
}
|
||||
|
||||
func (s *TaskService) UnlinkNoteFromTask(ctx context.Context, spaceID, taskID, noteID, userID bson.ObjectID) (*dto.TaskDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
task, err := s.taskRepo.GetTaskByID(ctx, taskID)
|
||||
if err != nil || task.SpaceID != spaceID {
|
||||
return nil, errors.New("task not found")
|
||||
}
|
||||
|
||||
filtered := make([]bson.ObjectID, 0, len(task.NoteLinks))
|
||||
for _, linkedNoteID := range task.NoteLinks {
|
||||
if linkedNoteID != noteID {
|
||||
filtered = append(filtered, linkedNoteID)
|
||||
}
|
||||
}
|
||||
task.NoteLinks = filtered
|
||||
task.UpdatedBy = userID
|
||||
if err := s.taskRepo.UpdateTask(ctx, task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dto.NewTaskDTO(task), nil
|
||||
}
|
||||
|
||||
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*dto.TaskStatusDTO, 0, len(statuses))
|
||||
for _, status := range statuses {
|
||||
result = append(result, dto.NewTaskStatusDTO(status))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.status.manage")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := &entities.TaskStatus{
|
||||
SpaceID: spaceID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Color: strings.TrimSpace(req.Color),
|
||||
Order: len(statuses),
|
||||
}
|
||||
if status.Name == "" {
|
||||
return nil, errors.New("status name is required")
|
||||
}
|
||||
if err := s.taskStatusRepo.CreateStatus(ctx, status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dto.NewTaskStatusDTO(status), nil
|
||||
}
|
||||
|
||||
func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.status.manage")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
|
||||
if err != nil || status.SpaceID != spaceID {
|
||||
return nil, errors.New("task status not found")
|
||||
}
|
||||
|
||||
status.Name = strings.TrimSpace(req.Name)
|
||||
status.Color = strings.TrimSpace(req.Color)
|
||||
if status.Name == "" {
|
||||
return nil, errors.New("status name is required")
|
||||
}
|
||||
if err := s.taskStatusRepo.UpdateStatus(ctx, status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dto.NewTaskStatusDTO(status), nil
|
||||
}
|
||||
|
||||
func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID) error {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.status.manage")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasPermission {
|
||||
return errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(statuses) <= 1 {
|
||||
return errors.New("at least one status is required")
|
||||
}
|
||||
|
||||
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"status_id": statusID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tasks) > 0 {
|
||||
return errors.New("cannot delete status used by tasks")
|
||||
}
|
||||
|
||||
if err := s.taskStatusRepo.DeleteStatus(ctx, statusID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.normalizeStatusOrder(ctx, spaceID)
|
||||
}
|
||||
|
||||
func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) {
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.status.manage")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPermission {
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(statuses) != len(orderedStatusIDs) {
|
||||
return nil, errors.New("ordered status list must include every status exactly once")
|
||||
}
|
||||
|
||||
statusByID := make(map[bson.ObjectID]*entities.TaskStatus, len(statuses))
|
||||
for _, status := range statuses {
|
||||
statusByID[status.ID] = status
|
||||
}
|
||||
|
||||
seen := map[bson.ObjectID]struct{}{}
|
||||
orderedStatuses := make([]*entities.TaskStatus, 0, len(orderedStatusIDs))
|
||||
for _, statusIDHex := range orderedStatusIDs {
|
||||
statusID, parseErr := bson.ObjectIDFromHex(statusIDHex)
|
||||
if parseErr != nil {
|
||||
return nil, errors.New("invalid status id in ordered_status_ids")
|
||||
}
|
||||
status := statusByID[statusID]
|
||||
if status == nil {
|
||||
return nil, errors.New("status id does not belong to this space")
|
||||
}
|
||||
if _, exists := seen[statusID]; exists {
|
||||
return nil, errors.New("duplicate status id in ordered_status_ids")
|
||||
}
|
||||
seen[statusID] = struct{}{}
|
||||
orderedStatuses = append(orderedStatuses, status)
|
||||
}
|
||||
|
||||
minOrder := statuses[0].Order
|
||||
for _, status := range statuses {
|
||||
if status.Order < minOrder {
|
||||
minOrder = status.Order
|
||||
}
|
||||
}
|
||||
|
||||
tempBase := minOrder - len(statuses) - 1
|
||||
for idx, status := range orderedStatuses {
|
||||
status.Order = tempBase + idx
|
||||
if err := s.taskStatusRepo.UpdateStatus(ctx, status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for idx, status := range orderedStatuses {
|
||||
status.Order = idx
|
||||
if err := s.taskStatusRepo.UpdateStatus(ctx, status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*dto.TaskStatusDTO, 0, len(updatedStatuses))
|
||||
for _, status := range updatedStatuses {
|
||||
result = append(result, dto.NewTaskStatusDTO(status))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) normalizeStatusOrder(ctx context.Context, spaceID bson.ObjectID) error {
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.SliceStable(statuses, func(i, j int) bool {
|
||||
return statuses[i].Order < statuses[j].Order
|
||||
})
|
||||
for idx, status := range statuses {
|
||||
if status.Order == idx {
|
||||
continue
|
||||
}
|
||||
status.Order = idx
|
||||
if err := s.taskStatusRepo.UpdateStatus(ctx, status); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
37
backend/internal/domain/entities/task.go
Normal file
37
backend/internal/domain/entities/task.go
Normal 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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
185
backend/internal/infrastructure/database/task_repository.go
Normal file
185
backend/internal/infrastructure/database/task_repository.go
Normal 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
|
||||
}
|
||||
398
backend/internal/interfaces/handlers/task_handler.go
Normal file
398
backend/internal/interfaces/handlers/task_handler.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user