diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c67f04d..2298dea 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -135,6 +135,7 @@ func main() { db.MembershipRepo, db.NoteRepo, db.CategoryRepo, + db.TaskListRepo, db.UserRepo, permissionService, ) @@ -151,6 +152,7 @@ func main() { categoryService := services.NewCategoryService( db.CategoryRepo, + db.TaskListRepo, db.MembershipRepo, db.NoteRepo, permissionService, @@ -158,6 +160,7 @@ func main() { taskService := services.NewTaskService( db.TaskRepo, + db.TaskListRepo, db.TaskStatusRepo, db.NoteRepo, db.CategoryRepo, @@ -269,6 +272,11 @@ func main() { api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH") // Task endpoints + api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.ListTaskLists).Methods("GET") + api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.CreateTaskList).Methods("POST") + api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.UpdateTaskList).Methods("PUT") + api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.DeleteTaskList).Methods("DELETE") + 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") diff --git a/backend/internal/application/dto/dto.go b/backend/internal/application/dto/dto.go index a188f14..46afb45 100644 --- a/backend/internal/application/dto/dto.go +++ b/backend/internal/application/dto/dto.go @@ -430,6 +430,7 @@ type CategoryTreeDTO struct { *CategoryDTO Subcategories []*CategoryTreeDTO `json:"subcategories"` Notes []*NoteListItemDTO `json:"notes"` + TaskLists []*TaskListDTO `json:"task_lists"` } // NewCategoryDTO creates a DTO from a category entity @@ -458,7 +459,7 @@ func NewCategoryDTO(category *entities.Category) *CategoryDTO { 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"` + TaskListID string `json:"task_list_id" validate:"required"` StatusID string `json:"status_id" validate:"required"` ParentTaskID *string `json:"parent_task_id,omitempty"` NoteLinks []string `json:"note_links"` @@ -468,7 +469,7 @@ type CreateTaskRequest struct { type UpdateTaskRequest struct { Title *string `json:"title,omitempty"` Description *string `json:"description,omitempty"` - CategoryID *string `json:"category_id,omitempty"` + TaskListID *string `json:"task_list_id,omitempty"` StatusID *string `json:"status_id,omitempty"` ParentTaskID *string `json:"parent_task_id,omitempty"` NoteLinks []string `json:"note_links,omitempty"` @@ -490,7 +491,7 @@ type TaskDTO struct { SpaceID string `json:"space_id"` Title string `json:"title"` Description string `json:"description"` - CategoryID *string `json:"category_id,omitempty"` + TaskListID string `json:"task_list_id"` StatusID string `json:"status_id"` ParentTaskID *string `json:"parent_task_id,omitempty"` Depth int `json:"depth"` @@ -538,14 +539,35 @@ type TaskStatusDTO struct { UpdatedAt string `json:"updated_at"` } +// CreateTaskListRequest represents task list creation input. +type CreateTaskListRequest struct { + Name string `json:"name" validate:"required,min=1,max=120"` + Description string `json:"description" validate:"max=500"` + CategoryID *string `json:"category_id,omitempty"` +} + +// UpdateTaskListRequest represents task list update input. +type UpdateTaskListRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + CategoryID *string `json:"category_id,omitempty"` +} + +// TaskListDTO represents a task list in API responses. +type TaskListDTO struct { + ID string `json:"id"` + SpaceID string `json:"space_id"` + CategoryID *string `json:"category_id,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + CreatedBy string `json:"created_by"` + UpdatedBy string `json:"updated_by"` + 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() @@ -562,7 +584,7 @@ func NewTaskDTO(task *entities.Task) *TaskDTO { SpaceID: task.SpaceID.Hex(), Title: task.Title, Description: task.Description, - CategoryID: categoryID, + TaskListID: task.TaskListID.Hex(), StatusID: task.StatusID.Hex(), ParentTaskID: parentTaskID, Depth: task.Depth, @@ -574,6 +596,27 @@ func NewTaskDTO(task *entities.Task) *TaskDTO { } } +// NewTaskListDTO creates a DTO from a task list entity. +func NewTaskListDTO(taskList *entities.TaskList) *TaskListDTO { + var categoryID *string + if taskList.CategoryID != nil { + id := taskList.CategoryID.Hex() + categoryID = &id + } + + return &TaskListDTO{ + ID: taskList.ID.Hex(), + SpaceID: taskList.SpaceID.Hex(), + CategoryID: categoryID, + Name: taskList.Name, + Description: taskList.Description, + CreatedBy: taskList.CreatedBy.Hex(), + UpdatedBy: taskList.UpdatedBy.Hex(), + CreatedAt: taskList.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: taskList.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } +} + // NewTaskStatusDTO creates a DTO from a task status entity. func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO { return &TaskStatusDTO{ diff --git a/backend/internal/application/services/category_service.go b/backend/internal/application/services/category_service.go index a31a5cc..5551253 100644 --- a/backend/internal/application/services/category_service.go +++ b/backend/internal/application/services/category_service.go @@ -15,6 +15,7 @@ import ( // CategoryService handles category operations type CategoryService struct { categoryRepo repositories.CategoryRepository + taskListRepo repositories.TaskListRepository membershipRepo repositories.MembershipRepository noteRepo repositories.NoteRepository permissionService *PermissionService @@ -23,12 +24,14 @@ type CategoryService struct { // NewCategoryService creates a new category service func NewCategoryService( categoryRepo repositories.CategoryRepository, + taskListRepo repositories.TaskListRepository, membershipRepo repositories.MembershipRepository, noteRepo repositories.NoteRepository, permissionService *PermissionService, ) *CategoryService { return &CategoryService{ categoryRepo: categoryRepo, + taskListRepo: taskListRepo, membershipRepo: membershipRepo, noteRepo: noteRepo, permissionService: permissionService, @@ -134,6 +137,14 @@ func (s *CategoryService) buildCategoryTree(ctx context.Context, category *entit } } + // Get task lists in this category + taskLists, err := s.taskListRepo.ListTaskListsByCategory(ctx, spaceID, category.ID) + if err == nil { + for _, taskList := range taskLists { + tree.TaskLists = append(tree.TaskLists, dto.NewTaskListDTO(taskList)) + } + } + return tree, nil } diff --git a/backend/internal/application/services/space_service.go b/backend/internal/application/services/space_service.go index 07f281a..fb9c257 100644 --- a/backend/internal/application/services/space_service.go +++ b/backend/internal/application/services/space_service.go @@ -17,6 +17,7 @@ type SpaceService struct { membershipRepo repositories.MembershipRepository noteRepo repositories.NoteRepository categoryRepo repositories.CategoryRepository + taskListRepo repositories.TaskListRepository userRepo repositories.UserRepository permissionService *PermissionService } @@ -27,6 +28,7 @@ func NewSpaceService( membershipRepo repositories.MembershipRepository, noteRepo repositories.NoteRepository, categoryRepo repositories.CategoryRepository, + taskListRepo repositories.TaskListRepository, userRepo repositories.UserRepository, permissionService *PermissionService, ) *SpaceService { @@ -35,6 +37,7 @@ func NewSpaceService( membershipRepo: membershipRepo, noteRepo: noteRepo, categoryRepo: categoryRepo, + taskListRepo: taskListRepo, userRepo: userRepo, permissionService: permissionService, } @@ -180,6 +183,9 @@ func (s *SpaceService) DeleteSpace(ctx context.Context, spaceID, userID bson.Obj if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil { return err } + if err := s.taskListRepo.DeleteTaskListsBySpaceID(ctx, spaceID); err != nil { + return err + } if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil { return err } diff --git a/backend/internal/application/services/task_service.go b/backend/internal/application/services/task_service.go index 2809efb..0daf053 100644 --- a/backend/internal/application/services/task_service.go +++ b/backend/internal/application/services/task_service.go @@ -17,6 +17,7 @@ import ( // TaskService handles task and task status operations. type TaskService struct { taskRepo repositories.TaskRepository + taskListRepo repositories.TaskListRepository taskStatusRepo repositories.TaskStatusRepository noteRepo repositories.NoteRepository categoryRepo repositories.CategoryRepository @@ -27,6 +28,7 @@ type TaskService struct { // NewTaskService creates a task service. func NewTaskService( taskRepo repositories.TaskRepository, + taskListRepo repositories.TaskListRepository, taskStatusRepo repositories.TaskStatusRepository, noteRepo repositories.NoteRepository, categoryRepo repositories.CategoryRepository, @@ -35,6 +37,7 @@ func NewTaskService( ) *TaskService { return &TaskService{ taskRepo: taskRepo, + taskListRepo: taskListRepo, taskStatusRepo: taskStatusRepo, noteRepo: noteRepo, categoryRepo: categoryRepo, @@ -121,13 +124,10 @@ func toObjectIDs(hexIDs []string) ([]bson.ObjectID, error) { 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") +func (s *TaskService) validateTaskList(ctx context.Context, spaceID, taskListID bson.ObjectID) error { + taskList, err := s.taskListRepo.GetTaskListByID(ctx, taskListID) + if err != nil || taskList.SpaceID != spaceID { + return errors.New("invalid task list") } return nil } @@ -209,14 +209,6 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec 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") @@ -226,6 +218,14 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec return nil, err } + taskListID, err := bson.ObjectIDFromHex(strings.TrimSpace(req.TaskListID)) + if err != nil { + return nil, errors.New("invalid task list") + } + if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil { + return nil, err + } + noteLinks, err := toObjectIDs(req.NoteLinks) if err != nil { return nil, err @@ -258,11 +258,19 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec statusID = parsedStatusID } + if parentTaskID != nil { + parent, parentErr := s.taskRepo.GetTaskByID(ctx, *parentTaskID) + if parentErr != nil || parent.SpaceID != spaceID { + return nil, errors.New("invalid parent task") + } + taskListID = parent.TaskListID + } + task := &entities.Task{ SpaceID: spaceID, Title: strings.TrimSpace(req.Title), Description: strings.TrimSpace(req.Description), - CategoryID: categoryID, + TaskListID: taskListID, StatusID: statusID, ParentTaskID: parentTaskID, Depth: depth, @@ -318,7 +326,7 @@ func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID b func (s *TaskService) ListTasks( ctx context.Context, spaceID, userID bson.ObjectID, - categoryID, statusID, parentTaskID *string, + taskListID, statusID, parentTaskID *string, ) ([]*dto.TaskDTO, error) { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { return nil, err @@ -328,12 +336,12 @@ func (s *TaskService) ListTasks( } filters := map[string]any{} - if categoryID != nil && strings.TrimSpace(*categoryID) != "" { - id, err := bson.ObjectIDFromHex(*categoryID) + if taskListID != nil && strings.TrimSpace(*taskListID) != "" { + id, err := bson.ObjectIDFromHex(*taskListID) if err != nil { - return nil, errors.New("invalid category filter") + return nil, errors.New("invalid task list filter") } - filters["category_id"] = id + filters["task_list_id"] = id } if statusID != nil && strings.TrimSpace(*statusID) != "" { id, err := bson.ObjectIDFromHex(*statusID) @@ -453,19 +461,21 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs 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 req.TaskListID != nil { + if strings.TrimSpace(*req.TaskListID) == "" { + return nil, errors.New("task list is required") } - if err := s.validateCategory(ctx, spaceID, task.CategoryID); err != nil { + taskListID, parseErr := bson.ObjectIDFromHex(*req.TaskListID) + if parseErr != nil { + return nil, errors.New("invalid task list") + } + if task.ParentTaskID != nil { + return nil, errors.New("subtasks inherit task list from parent") + } + if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil { return nil, err } + task.TaskListID = taskListID } if req.ParentTaskID != nil { @@ -486,6 +496,11 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs } task.ParentTaskID = &parentID task.Depth = depth + parentTask, parentErr := s.taskRepo.GetTaskByID(ctx, parentID) + if parentErr != nil || parentTask.SpaceID != spaceID { + return nil, errors.New("invalid parent task") + } + task.TaskListID = parentTask.TaskListID } } @@ -670,6 +685,144 @@ func (s *TaskService) UnlinkNoteFromTask(ctx context.Context, spaceID, taskID, n return dto.NewTaskDTO(task), nil } +func (s *TaskService) ListTaskLists(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskListDTO, error) { + if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { + return nil, err + } + lists, err := s.taskListRepo.ListTaskLists(ctx, spaceID) + if err != nil { + return nil, err + } + result := make([]*dto.TaskListDTO, 0, len(lists)) + for _, list := range lists { + result = append(result, dto.NewTaskListDTO(list)) + } + return result, nil +} + +func (s *TaskService) CreateTaskList(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskListRequest) (*dto.TaskListDTO, error) { + if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { + return nil, err + } + hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.create") + if err != nil { + return nil, err + } + if !hasPermission { + return nil, errors.New("insufficient permissions") + } + + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errors.New("task list name is required") + } + + var categoryID *bson.ObjectID + if req.CategoryID != nil && strings.TrimSpace(*req.CategoryID) != "" { + id, parseErr := bson.ObjectIDFromHex(*req.CategoryID) + if parseErr != nil { + return nil, errors.New("invalid category") + } + category, categoryErr := s.categoryRepo.GetCategoryByID(ctx, id) + if categoryErr != nil || category.SpaceID != spaceID { + return nil, errors.New("invalid category") + } + categoryID = &id + } + + list := &entities.TaskList{ + SpaceID: spaceID, + CategoryID: categoryID, + Name: name, + Description: strings.TrimSpace(req.Description), + CreatedBy: userID, + UpdatedBy: userID, + } + + if err := s.taskListRepo.CreateTaskList(ctx, list); err != nil { + return nil, err + } + return dto.NewTaskListDTO(list), nil +} + +func (s *TaskService) UpdateTaskList(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, req *dto.UpdateTaskListRequest) (*dto.TaskListDTO, error) { + if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { + return nil, err + } + hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit") + if err != nil { + return nil, err + } + if !hasPermission { + return nil, errors.New("insufficient permissions") + } + + list, err := s.taskListRepo.GetTaskListByID(ctx, taskListID) + if err != nil || list.SpaceID != spaceID { + return nil, errors.New("task list not found") + } + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return nil, errors.New("task list name is required") + } + list.Name = name + } + if req.Description != nil { + list.Description = strings.TrimSpace(*req.Description) + } + if req.CategoryID != nil { + if strings.TrimSpace(*req.CategoryID) == "" { + list.CategoryID = nil + } else { + categoryID, parseErr := bson.ObjectIDFromHex(*req.CategoryID) + if parseErr != nil { + return nil, errors.New("invalid category") + } + category, categoryErr := s.categoryRepo.GetCategoryByID(ctx, categoryID) + if categoryErr != nil || category.SpaceID != spaceID { + return nil, errors.New("invalid category") + } + list.CategoryID = &categoryID + } + } + + list.UpdatedBy = userID + if err := s.taskListRepo.UpdateTaskList(ctx, list); err != nil { + return nil, err + } + return dto.NewTaskListDTO(list), nil +} + +func (s *TaskService) DeleteTaskList(ctx context.Context, spaceID, taskListID, userID bson.ObjectID) error { + if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { + return err + } + hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.delete") + if err != nil { + return err + } + if !hasPermission { + return errors.New("insufficient permissions") + } + + list, err := s.taskListRepo.GetTaskListByID(ctx, taskListID) + if err != nil || list.SpaceID != spaceID { + return errors.New("task list not found") + } + + tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"task_list_id": taskListID}) + if err != nil { + return err + } + if len(tasks) > 0 { + return errors.New("cannot delete task list with tasks") + } + + return s.taskListRepo.DeleteTaskList(ctx, taskListID) +} + func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { return nil, err diff --git a/backend/internal/domain/entities/task.go b/backend/internal/domain/entities/task.go index 40fc291..12d68b3 100644 --- a/backend/internal/domain/entities/task.go +++ b/backend/internal/domain/entities/task.go @@ -14,7 +14,7 @@ type Task struct { SpaceID bson.ObjectID `bson:"space_id"` Title string `bson:"title"` Description string `bson:"description"` - CategoryID *bson.ObjectID `bson:"category_id,omitempty"` + TaskListID bson.ObjectID `bson:"task_list_id"` StatusID bson.ObjectID `bson:"status_id"` ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"` Depth int `bson:"depth"` @@ -35,3 +35,16 @@ type TaskStatus struct { CreatedAt time.Time `bson:"created_at"` UpdatedAt time.Time `bson:"updated_at"` } + +// TaskList groups tasks under a named list that can be attached to a category. +type TaskList struct { + ID bson.ObjectID `bson:"_id,omitempty"` + SpaceID bson.ObjectID `bson:"space_id"` + CategoryID *bson.ObjectID `bson:"category_id,omitempty"` + Name string `bson:"name"` + Description string `bson:"description,omitempty"` + CreatedBy bson.ObjectID `bson:"created_by"` + UpdatedBy bson.ObjectID `bson:"updated_by"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` +} diff --git a/backend/internal/domain/repositories/interfaces.go b/backend/internal/domain/repositories/interfaces.go index 6be8bb9..4432152 100644 --- a/backend/internal/domain/repositories/interfaces.go +++ b/backend/internal/domain/repositories/interfaces.go @@ -229,6 +229,17 @@ type TaskRepository interface { CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error) } +// TaskListRepository defines task list operations. +type TaskListRepository interface { + CreateTaskList(ctx context.Context, list *entities.TaskList) error + GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error) + ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error) + ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error) + UpdateTaskList(ctx context.Context, list *entities.TaskList) error + DeleteTaskList(ctx context.Context, id bson.ObjectID) error + DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error +} + // TaskStatusRepository defines task status operations type TaskStatusRepository interface { CreateStatus(ctx context.Context, status *entities.TaskStatus) error diff --git a/backend/internal/infrastructure/database/database.go b/backend/internal/infrastructure/database/database.go index ae850b0..67cc9b4 100644 --- a/backend/internal/infrastructure/database/database.go +++ b/backend/internal/infrastructure/database/database.go @@ -16,6 +16,7 @@ type Database struct { MembershipRepo *MembershipRepository NoteRepo *NoteRepository CategoryRepo *CategoryRepository + TaskListRepo *TaskListRepository TaskRepo *TaskRepository TaskStatusRepo *TaskStatusRepository RevisionRepo *NoteRevisionRepository @@ -49,6 +50,7 @@ func NewDatabase(ctx context.Context, mongoURL string) (*Database, error) { MembershipRepo: NewMembershipRepository(db), NoteRepo: NewNoteRepository(db), CategoryRepo: NewCategoryRepository(db), + TaskListRepo: NewTaskListRepository(db), TaskRepo: NewTaskRepository(db), TaskStatusRepo: NewTaskStatusRepository(db), RevisionRepo: NewNoteRevisionRepository(db), @@ -87,6 +89,9 @@ func (d *Database) EnsureIndexes(ctx context.Context) error { if err := d.TaskRepo.EnsureIndexes(ctx); err != nil { return err } + if err := d.TaskListRepo.EnsureIndexes(ctx); err != nil { + return err + } if err := d.TaskStatusRepo.EnsureIndexes(ctx); err != nil { return err } diff --git a/backend/internal/infrastructure/database/task_repository.go b/backend/internal/infrastructure/database/task_repository.go index 742f586..688ec0e 100644 --- a/backend/internal/infrastructure/database/task_repository.go +++ b/backend/internal/infrastructure/database/task_repository.go @@ -108,13 +108,95 @@ 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: "task_list_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 } +// TaskListRepository implements task list data access. +type TaskListRepository struct { + collection *mongo.Collection +} + +// NewTaskListRepository creates a new task list repository. +func NewTaskListRepository(db *mongo.Database) *TaskListRepository { + return &TaskListRepository{collection: db.Collection("task_lists")} +} + +func (r *TaskListRepository) CreateTaskList(ctx context.Context, list *entities.TaskList) error { + list.ID = bson.NewObjectID() + list.CreatedAt = time.Now() + list.UpdatedAt = time.Now() + _, err := r.collection.InsertOne(ctx, list) + return err +} + +func (r *TaskListRepository) GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error) { + var list entities.TaskList + err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&list) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("task list not found") + } + return nil, err + } + return &list, nil +} + +func (r *TaskListRepository) ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error) { + cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}})) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var lists []*entities.TaskList + if err := cursor.All(ctx, &lists); err != nil { + return nil, err + } + return lists, nil +} + +func (r *TaskListRepository) ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error) { + cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID, "category_id": categoryID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}})) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var lists []*entities.TaskList + if err := cursor.All(ctx, &lists); err != nil { + return nil, err + } + return lists, nil +} + +func (r *TaskListRepository) UpdateTaskList(ctx context.Context, list *entities.TaskList) error { + list.UpdatedAt = time.Now() + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": list.ID}, list) + return err +} + +func (r *TaskListRepository) DeleteTaskList(ctx context.Context, id bson.ObjectID) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +func (r *TaskListRepository) DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error { + _, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID}) + return err +} + +func (r *TaskListRepository) 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: "category_id", Value: 1}}}, + }) + return err +} + // TaskStatusRepository implements task status data access. type TaskStatusRepository struct { collection *mongo.Collection diff --git a/backend/internal/interfaces/handlers/task_handler.go b/backend/internal/interfaces/handlers/task_handler.go index b31aa81..a1d28ca 100644 --- a/backend/internal/interfaces/handlers/task_handler.go +++ b/backend/internal/interfaces/handlers/task_handler.go @@ -64,15 +64,15 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) { return } - categoryID := strings.TrimSpace(r.URL.Query().Get("categoryId")) + taskListID := strings.TrimSpace(r.URL.Query().Get("taskListId")) statusID := strings.TrimSpace(r.URL.Query().Get("statusId")) parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId")) - categoryFilter := &categoryID + taskListFilter := &taskListID statusFilter := &statusID parentFilter := &parentTaskID - if categoryID == "" { - categoryFilter = nil + if taskListID == "" { + taskListFilter = nil } if statusID == "" { statusFilter = nil @@ -81,7 +81,7 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) { parentFilter = nil } - tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, categoryFilter, statusFilter, parentFilter) + tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, taskListFilter, statusFilter, parentFilter) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -91,6 +91,94 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(tasks) } +func (h *TaskHandler) ListTaskLists(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + lists, err := h.taskService.ListTaskLists(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(lists) +} + +func (h *TaskHandler) CreateTaskList(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.CreateTaskListRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + list, err := h.taskService.CreateTaskList(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(list) +} + +func (h *TaskHandler) UpdateTaskList(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"]) + if err != nil { + http.Error(w, "invalid task list id", http.StatusBadRequest) + return + } + + var req dto.UpdateTaskListRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + list, err := h.taskService.UpdateTaskList(r.Context(), spaceID, taskListID, userID, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(list) +} + +func (h *TaskHandler) DeleteTaskList(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"]) + if err != nil { + http.Error(w, "invalid task list id", http.StatusBadRequest) + return + } + + if err := h.taskService.DeleteTaskList(r.Context(), spaceID, taskListID, userID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *TaskHandler) SearchTasks(w http.ResponseWriter, r *http.Request) { userID, spaceID, err := parseIDsFromRequest(r) if err != nil { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 75ccefc..7a75c3d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -44,7 +44,7 @@
{{ getDescription(note) }}
- Updated: {{ formatDate(note.updated_at) }} -Use a title, content keyword, or tag to find matching notes in the selected space.
+Use a title, content keyword, or tag to find matching notes and task lists in the selected space.
Try different keywords or a shorter phrase.