Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions apiserver/internal/apis/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions apiserver/internal/repos/task/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
dkhalife marked this conversation as resolved.
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 {
Comment thread
dkhalife marked this conversation as resolved.
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

Expand Down
181 changes: 181 additions & 0 deletions apiserver/internal/repos/task/task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
30 changes: 30 additions & 0 deletions apiserver/internal/services/tasks/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions mcpserver/Services/ApiProxyService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ private async Task<string> SendAsync(HttpMethod method, string path, object? bod
public Task<string> GetAllTasks() =>
SendAsync(HttpMethod.Get, "api/v1/tasks/");

public Task<string> GetTasksDueBefore(string before) =>
SendAsync(HttpMethod.Get, $"api/v1/tasks/due?before={Uri.EscapeDataString(before)}");

public Task<string> GetTasksByLabel(int labelId) =>
SendAsync(HttpMethod.Get, $"api/v1/tasks/label/{labelId}");

public Task<string> GetTask(int id) =>
SendAsync(HttpMethod.Get, $"api/v1/tasks/{id}");

Expand Down
14 changes: 12 additions & 2 deletions mcpserver/Tools/TaskTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> ListTasks() =>
api.GetAllTasks();

Expand Down Expand Up @@ -64,7 +64,7 @@ public Task<string> 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<string> UpdateTask(
[Description("Task ID")] int id,
[Description("Task title")] string title,
Expand Down Expand Up @@ -99,4 +99,14 @@ public Task<string> UncompleteTask([Description("Task ID")] int id) =>
[McpServerTool, Description("Skip a task (advance to next due date without completing)")]
public Task<string> SkipTask([Description("Task ID")] int id) =>
api.SkipTask(id);

[McpServerTool, Description("List active tasks due before a specific timestamp")]
public Task<string> 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<string> ListTasksByLabel(
[Description("Label ID")] int labelId) =>
api.GetTasksByLabel(labelId);
}
Loading