diff --git a/apiserver/internal/apis/task.go b/apiserver/internal/apis/task.go index 9301af7f..36ae2150 100644 --- a/apiserver/internal/apis/task.go +++ b/apiserver/internal/apis/task.go @@ -3,6 +3,7 @@ package apis import ( "net/http" "strconv" + "time" authMW "dkhalife.com/tasks/core/internal/middleware/auth" "dkhalife.com/tasks/core/internal/models" @@ -28,6 +29,45 @@ func (h *TasksAPIHandler) getTasks(c *gin.Context) { c.JSON(status, response) } +func (h *TasksAPIHandler) getTasksDueBefore(c *gin.Context) { + currentIdentity := auth.CurrentIdentity(c) + + raw := c.Query("before") + if raw == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "'before' query parameter is required", + }) + return + } + + before, err := time.Parse(time.RFC3339, raw) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "'before' must be in RFC 3339 / ISO 8601 format (e.g. 2025-01-15T00:00:00Z)", + }) + return + } + + status, response := h.tService.GetTasksDueBefore(c, currentIdentity.UserID, before.UTC()) + c.JSON(status, response) +} + +func (h *TasksAPIHandler) getTasksByLabel(c *gin.Context) { + currentIdentity := auth.CurrentIdentity(c) + + rawID := c.Param("labelId") + labelID, err := strconv.Atoi(rawID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid label ID", + }) + return + } + + status, response := h.tService.GetTasksByLabel(c, currentIdentity.UserID, labelID) + c.JSON(status, response) +} + func (h *TasksAPIHandler) getCompletedTasks(c *gin.Context) { currentIdentity := auth.CurrentIdentity(c) @@ -215,6 +255,8 @@ func TaskRoutes(router *gin.Engine, h *TasksAPIHandler, auth *authMW.AuthMiddlew tasksRoutes.Use(auth.MiddlewareFunc()) { tasksRoutes.GET("/", authMW.ScopeMiddleware(models.ApiTokenScopeTaskRead), h.getTasks) + tasksRoutes.GET("/due", authMW.ScopeMiddleware(models.ApiTokenScopeTaskRead), h.getTasksDueBefore) + tasksRoutes.GET("/label/:labelId", authMW.ScopeMiddleware(models.ApiTokenScopeTaskRead), h.getTasksByLabel) tasksRoutes.GET("/completed", authMW.ScopeMiddleware(models.ApiTokenScopeTaskRead), h.getCompletedTasks) tasksRoutes.PUT("/", authMW.ScopeMiddleware(models.ApiTokenScopeTaskWrite), h.editTask) tasksRoutes.POST("/", authMW.ScopeMiddleware(models.ApiTokenScopeTaskWrite), h.createTask) diff --git a/apiserver/internal/repos/task/task.go b/apiserver/internal/repos/task/task.go index a5534ae7..093dca4d 100644 --- a/apiserver/internal/repos/task/task.go +++ b/apiserver/internal/repos/task/task.go @@ -54,6 +54,35 @@ func (r *TaskRepository) GetTasks(c context.Context, userID int) ([]*models.Task return tasks, nil } +func (r *TaskRepository) GetTasksDueBefore(c context.Context, userID int, before time.Time) ([]*models.Task, error) { + var tasks []*models.Task + + if err := r.db.WithContext(c). + Where("created_by = ? AND is_active = 1 AND next_due_date < ?", userID, before). + Order("next_due_date ASC"). + Preload("Labels"). + Find(&tasks).Error; err != nil { + return nil, err + } + + return tasks, nil +} + +func (r *TaskRepository) GetTasksByLabel(c context.Context, userID int, labelID int) ([]*models.Task, error) { + var tasks []*models.Task + + if err := r.db.WithContext(c). + Where("created_by = ? AND is_active = 1", userID). + Joins("JOIN task_labels ON task_labels.task_id = tasks.id AND task_labels.label_id = ?", labelID). + Order("next_due_date ASC"). + Preload("Labels"). + Find(&tasks).Error; err != nil { + return nil, err + } + + return tasks, nil +} + func (r *TaskRepository) GetCompletedTasks(c context.Context, userID int, limit int, offset int) ([]*models.Task, error) { var tasks []*models.Task diff --git a/apiserver/internal/repos/task/task_test.go b/apiserver/internal/repos/task/task_test.go index 29820d82..87f0e12d 100644 --- a/apiserver/internal/repos/task/task_test.go +++ b/apiserver/internal/repos/task/task_test.go @@ -620,3 +620,184 @@ func (s *TaskTestSuite) TestGetCompletedTasks() { s.Require().NoError(err) s.Len(tasks, 0) } + +func (s *TaskTestSuite) TestGetTasksDueBefore() { + ctx := context.Background() + + now := time.Now().UTC() + past := now.Add(-48 * time.Hour) + soon := now.Add(24 * time.Hour) + later := now.Add(72 * time.Hour) + cutoff := now.Add(48 * time.Hour) + + tasks := []*models.Task{ + { + Title: "Past Task", + CreatedBy: s.testUser.ID, + NextDueDate: &past, + IsActive: true, + Frequency: models.Frequency{Type: models.RepeatOnce}, + }, + { + Title: "Soon Task", + CreatedBy: s.testUser.ID, + NextDueDate: &soon, + IsActive: true, + Frequency: models.Frequency{Type: models.RepeatOnce}, + }, + { + Title: "Later Task", + CreatedBy: s.testUser.ID, + NextDueDate: &later, + IsActive: true, + Frequency: models.Frequency{Type: models.RepeatOnce}, + }, + { + ID: 40, + Title: "Inactive Before Cutoff", + CreatedBy: s.testUser.ID, + NextDueDate: &soon, + IsActive: true, + Frequency: models.Frequency{Type: models.RepeatOnce}, + }, + } + + for _, task := range tasks { + err := s.DB.Create(task).Error + s.Require().NoError(err) + } + + err := s.DB.Model(&models.Task{}).Where("id = ?", 40).Update("is_active", false).Error + s.Require().NoError(err) + + anotherUser := &models.User{} + err = s.DB.Create(anotherUser).Error + s.Require().NoError(err) + + otherTask := &models.Task{ + Title: "Other User Task", + CreatedBy: anotherUser.ID, + NextDueDate: &soon, + IsActive: true, + } + err = s.DB.Create(otherTask).Error + s.Require().NoError(err) + + // Create a label and attach to "Soon Task" to verify preloading + label := &models.Label{Name: "Urgent", Color: "#FF0000", CreatedBy: s.testUser.ID} + err = s.DB.Create(label).Error + s.Require().NoError(err) + err = s.DB.Exec("INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)", tasks[1].ID, label.ID).Error + s.Require().NoError(err) + + result, err := s.repo.GetTasksDueBefore(ctx, s.testUser.ID, cutoff) + s.Require().NoError(err) + s.Require().Len(result, 2) + + // Ordered by next_due_date ASC + s.Equal("Past Task", result[0].Title) + s.Equal("Soon Task", result[1].Title) + + // Labels are preloaded + s.Require().Len(result[1].Labels, 1) + s.Equal("Urgent", result[1].Labels[0].Name) +} + +func (s *TaskTestSuite) TestGetTasksByLabel() { + ctx := context.Background() + + now := time.Now().UTC() + due1 := now.Add(48 * time.Hour) + due2 := now.Add(24 * time.Hour) + + label := &models.Label{Name: "Work", Color: "#00FF00", CreatedBy: s.testUser.ID} + err := s.DB.Create(label).Error + s.Require().NoError(err) + + otherLabel := &models.Label{Name: "Personal", Color: "#0000FF", CreatedBy: s.testUser.ID} + err = s.DB.Create(otherLabel).Error + s.Require().NoError(err) + + taskWithLabel1 := &models.Task{ + Title: "Work Task A", + CreatedBy: s.testUser.ID, + NextDueDate: &due1, + IsActive: true, + Frequency: models.Frequency{Type: models.RepeatOnce}, + } + err = s.DB.Create(taskWithLabel1).Error + s.Require().NoError(err) + + taskWithLabel2 := &models.Task{ + Title: "Work Task B", + CreatedBy: s.testUser.ID, + NextDueDate: &due2, + IsActive: true, + Frequency: models.Frequency{Type: models.RepeatOnce}, + } + err = s.DB.Create(taskWithLabel2).Error + s.Require().NoError(err) + + taskWithOtherLabel := &models.Task{ + Title: "Personal Task", + CreatedBy: s.testUser.ID, + NextDueDate: &due1, + IsActive: true, + Frequency: models.Frequency{Type: models.RepeatOnce}, + } + err = s.DB.Create(taskWithOtherLabel).Error + s.Require().NoError(err) + + inactiveTask := &models.Task{ + ID: 50, + Title: "Inactive Work Task", + CreatedBy: s.testUser.ID, + NextDueDate: &due1, + IsActive: true, + Frequency: models.Frequency{Type: models.RepeatOnce}, + } + err = s.DB.Create(inactiveTask).Error + s.Require().NoError(err) + err = s.DB.Model(&models.Task{}).Where("id = ?", 50).Update("is_active", false).Error + s.Require().NoError(err) + + // Assign labels + err = s.DB.Exec("INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)", taskWithLabel1.ID, label.ID).Error + s.Require().NoError(err) + err = s.DB.Exec("INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)", taskWithLabel2.ID, label.ID).Error + s.Require().NoError(err) + err = s.DB.Exec("INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)", taskWithOtherLabel.ID, otherLabel.ID).Error + s.Require().NoError(err) + err = s.DB.Exec("INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)", inactiveTask.ID, label.ID).Error + s.Require().NoError(err) + + // Also give taskWithLabel1 a second label to verify all labels preload + err = s.DB.Exec("INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)", taskWithLabel1.ID, otherLabel.ID).Error + s.Require().NoError(err) + + // Another user with same label name shouldn't appear + anotherUser := &models.User{} + err = s.DB.Create(anotherUser).Error + s.Require().NoError(err) + otherUserTask := &models.Task{ + Title: "Other User Work", + CreatedBy: anotherUser.ID, + NextDueDate: &due1, + IsActive: true, + } + err = s.DB.Create(otherUserTask).Error + s.Require().NoError(err) + err = s.DB.Exec("INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)", otherUserTask.ID, label.ID).Error + s.Require().NoError(err) + + result, err := s.repo.GetTasksByLabel(ctx, s.testUser.ID, label.ID) + s.Require().NoError(err) + s.Require().Len(result, 2) + + // Ordered by next_due_date ASC + s.Equal("Work Task B", result[0].Title) + s.Equal("Work Task A", result[1].Title) + + // All labels are preloaded (not just the filtered one) + s.Require().Len(result[1].Labels, 2) +} diff --git a/apiserver/internal/services/tasks/task.go b/apiserver/internal/services/tasks/task.go index dc20438f..01e3a301 100644 --- a/apiserver/internal/services/tasks/task.go +++ b/apiserver/internal/services/tasks/task.go @@ -52,6 +52,36 @@ func (s *TaskService) GetUserTasks(ctx context.Context, userID int) (int, interf } } +func (s *TaskService) GetTasksDueBefore(ctx context.Context, userID int, before time.Time) (int, interface{}) { + log := logging.FromContext(ctx) + tasks, err := s.t.GetTasksDueBefore(ctx, userID, before) + if err != nil { + log.Errorf("error getting tasks due before %s: %s", before.String(), err.Error()) + return http.StatusInternalServerError, gin.H{ + "error": "Error getting tasks", + } + } + + return http.StatusOK, gin.H{ + "tasks": tasks, + } +} + +func (s *TaskService) GetTasksByLabel(ctx context.Context, userID int, labelID int) (int, interface{}) { + log := logging.FromContext(ctx) + tasks, err := s.t.GetTasksByLabel(ctx, userID, labelID) + if err != nil { + log.Errorf("error getting tasks by label %d: %s", labelID, err.Error()) + return http.StatusInternalServerError, gin.H{ + "error": "Error getting tasks", + } + } + + return http.StatusOK, gin.H{ + "tasks": tasks, + } +} + func (s *TaskService) GetCompletedTasks(ctx context.Context, userID, limit, page int) (int, interface{}) { log := logging.FromContext(ctx) offset := (page - 1) * limit diff --git a/mcpserver/Services/ApiProxyService.cs b/mcpserver/Services/ApiProxyService.cs index d7d34dfd..2a0d1dcd 100644 --- a/mcpserver/Services/ApiProxyService.cs +++ b/mcpserver/Services/ApiProxyService.cs @@ -57,6 +57,12 @@ private async Task SendAsync(HttpMethod method, string path, object? bod public Task GetAllTasks() => SendAsync(HttpMethod.Get, "api/v1/tasks/"); + public Task GetTasksDueBefore(string before) => + SendAsync(HttpMethod.Get, $"api/v1/tasks/due?before={Uri.EscapeDataString(before)}"); + + public Task GetTasksByLabel(int labelId) => + SendAsync(HttpMethod.Get, $"api/v1/tasks/label/{labelId}"); + public Task GetTask(int id) => SendAsync(HttpMethod.Get, $"api/v1/tasks/{id}"); diff --git a/mcpserver/Tools/TaskTools.cs b/mcpserver/Tools/TaskTools.cs index 698f48c7..a7a35cf7 100644 --- a/mcpserver/Tools/TaskTools.cs +++ b/mcpserver/Tools/TaskTools.cs @@ -8,7 +8,7 @@ namespace TaskWizard.McpServer.Tools; [McpServerToolType] public class TaskTools(ApiProxyService api) { - [McpServerTool, Description("List all tasks")] + [McpServerTool, Description("List all active (not completed) tasks")] public Task ListTasks() => api.GetAllTasks(); @@ -64,7 +64,7 @@ public Task CreateCustomTask( Labels = labels?.ToList() ?? [] }); - [McpServerTool, Description("Update an existing task")] + [McpServerTool, Description("Update an existing task. WARNING: Any optional property not provided will be cleared/reset on the task (e.g. omitting nextDueDate removes the due date). Always supply all current values you want to keep.")] public Task UpdateTask( [Description("Task ID")] int id, [Description("Task title")] string title, @@ -99,4 +99,14 @@ public Task UncompleteTask([Description("Task ID")] int id) => [McpServerTool, Description("Skip a task (advance to next due date without completing)")] public Task SkipTask([Description("Task ID")] int id) => api.SkipTask(id); + + [McpServerTool, Description("List active tasks due before a specific timestamp")] + public Task ListTasksDueBefore( + [Description("UTC timestamp in RFC 3339 / ISO 8601 format (e.g. 2025-01-15T00:00:00Z)")] string before) => + api.GetTasksDueBefore(before); + + [McpServerTool, Description("List active tasks tagged with a given label")] + public Task ListTasksByLabel( + [Description("Label ID")] int labelId) => + api.GetTasksByLabel(labelId); }