diff --git a/pkg/routes/api/v2/task_collection.go b/pkg/routes/api/v2/task_collection.go new file mode 100644 index 000000000..a0cdda758 --- /dev/null +++ b/pkg/routes/api/v2/task_collection.go @@ -0,0 +1,243 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +const taskListFilterDoc = "Filtering, sorting and search apply to every variant. See https://vikunja.io/docs/filters for the filter language." + +type taskListBody struct { + Body Paginated[*models.Task] +} + +// bucketsWithTasksBody is the buckets-with-tasks response. It is not paginated: +// the view's bucket configuration bounds how many tasks each bucket carries, so +// page/per_page don't apply and total is simply the number of buckets. +type bucketsWithTasksBody struct { + Body struct { + Items []*models.Bucket `json:"items"` + Total int64 `json:"total" doc:"The number of buckets returned."` + } +} + +// The three task-list input structs below each repeat ListParams + the six +// query fields INLINE. They can't share that set through an embed: in this Huma +// version a second/nested anonymous embed alongside ListParams is silently +// dropped from request binding and the OpenAPI spec (verified against the +// generated spec). A single shared input struct doesn't work either — Huma +// lists every path:"" field regardless of the route template, so a shared +// project/view field leaks onto a narrower route as a phantom path param. The +// structs differ only in their path params; taskListViewInput is shared by both +// view-scoped endpoints. + +type taskListAllInput struct { + ListParams + Filter string `query:"filter" doc:"Filter query to match tasks by. See https://vikunja.io/docs/filters."` + FilterTimezone string `query:"filter_timezone" doc:"Timezone used to resolve relative date filters like \"now\"."` + FilterIncludeNulls bool `query:"filter_include_nulls" doc:"If true, also include tasks whose filtered field is null."` + SortBy []string `query:"sort_by,explode" doc:"Fields to sort by (e.g. done, priority). Repeatable; pair positionally with order_by."` + OrderBy []string `query:"order_by,explode" doc:"Sort order per sort_by field, asc or desc. Repeatable; defaults to asc."` + Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra, more expensive data per task. Repeatable."` +} + +type taskListProjectInput struct { + ProjectID int64 `path:"project" doc:"The numeric id of the project."` + ListParams + Filter string `query:"filter" doc:"Filter query to match tasks by. See https://vikunja.io/docs/filters."` + FilterTimezone string `query:"filter_timezone" doc:"Timezone used to resolve relative date filters like \"now\"."` + FilterIncludeNulls bool `query:"filter_include_nulls" doc:"If true, also include tasks whose filtered field is null."` + SortBy []string `query:"sort_by,explode" doc:"Fields to sort by (e.g. done, priority). Repeatable; pair positionally with order_by."` + OrderBy []string `query:"order_by,explode" doc:"Sort order per sort_by field, asc or desc. Repeatable; defaults to asc."` + Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra, more expensive data per task. Repeatable."` +} + +type taskListViewInput struct { + ProjectID int64 `path:"project" doc:"The numeric id of the project."` + ViewID int64 `path:"view" doc:"The numeric id of the project view."` + ListParams + Filter string `query:"filter" doc:"Filter query to match tasks by. See https://vikunja.io/docs/filters."` + FilterTimezone string `query:"filter_timezone" doc:"Timezone used to resolve relative date filters like \"now\"."` + FilterIncludeNulls bool `query:"filter_include_nulls" doc:"If true, also include tasks whose filtered field is null."` + SortBy []string `query:"sort_by,explode" doc:"Fields to sort by (e.g. done, priority). Repeatable; pair positionally with order_by."` + OrderBy []string `query:"order_by,explode" doc:"Sort order per sort_by field, asc or desc. Repeatable; defaults to asc."` + Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra, more expensive data per task. Repeatable."` +} + +// taskListFilters is the bound query carried into the shared collection builder. +// The three input structs convert into it so the collection logic lives once. +type taskListFilters struct { + Q string + Filter string + FilterTimezone string + FilterIncludeNulls bool + SortBy []string + OrderBy []string + Expand []string +} + +func (in taskListAllInput) filters() taskListFilters { + return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand} +} + +func (in taskListProjectInput) filters() taskListFilters { + return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand} +} + +func (in taskListViewInput) filters() taskListFilters { + return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand} +} + +// collection turns the bound query into a TaskCollection. The search term +// arrives as `q` but reaches the model through DoReadAll's search argument, not +// the collection's Search field. forceFlat keeps a kanban view path returning +// flat tasks; the buckets endpoint leaves it false for the polymorphic shape. +func (f taskListFilters) collection(projectID, viewID int64, forceFlat bool) (*models.TaskCollection, error) { + expand, err := parseTaskExpand(f.Expand) + if err != nil { + return nil, translateDomainError(err) + } + tc := &models.TaskCollection{ + ProjectID: projectID, + ProjectViewID: viewID, + Filter: f.Filter, + FilterTimezone: f.FilterTimezone, + FilterIncludeNulls: f.FilterIncludeNulls, + SortBy: f.SortBy, + OrderBy: f.OrderBy, + Expand: expand, + } + if forceFlat { + tc.SetForceFlatTasks() + } + return tc, nil +} + +func RegisterTaskCollectionRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-list", + Summary: "List tasks across all projects", + Description: "Returns the tasks the authenticated user can see across every project they have access to, paginated and flat. " + taskListFilterDoc, + Method: http.MethodGet, + Path: "/tasks", + Tags: tags, + }, tasksListAll) + + Register(api, huma.Operation{ + OperationID: "project-tasks-list", + Summary: "List tasks in a project", + Description: "Returns the tasks in a project, paginated and flat. Requires read access to the project. " + taskListFilterDoc, + Method: http.MethodGet, + Path: "/projects/{project}/tasks", + Tags: tags, + }, projectTasksList) + + Register(api, huma.Operation{ + OperationID: "project-view-tasks-list", + Summary: "List tasks in a project view", + Description: "Returns the tasks in a project view, paginated and flat. The view's own filter, sort and search are applied on top of the query. Always returns flat tasks, even for a kanban view — use the buckets endpoint to get tasks grouped by bucket. " + taskListFilterDoc, + Method: http.MethodGet, + Path: "/projects/{project}/views/{view}/tasks", + Tags: tags, + }, projectViewTasksList) + + Register(api, huma.Operation{ + OperationID: "project-view-buckets-tasks-list", + Summary: "List a kanban view's buckets with their tasks", + Description: "Returns the buckets of a project's kanban view, each populated with the tasks in it. Requires read access to the project. Not paginated: the number and size of buckets follow the view's bucket configuration, so page/per_page do not apply. " + taskListFilterDoc, + Method: http.MethodGet, + Path: "/projects/{project}/views/{view}/buckets/tasks", + Tags: tags, + }, projectViewBucketsTasksList) +} + +func init() { AddRouteRegistrar(RegisterTaskCollectionRoutes) } + +func tasksListAll(ctx context.Context, in *taskListAllInput) (*taskListBody, error) { + return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, 0, 0) +} + +func projectTasksList(ctx context.Context, in *taskListProjectInput) (*taskListBody, error) { + return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, in.ProjectID, 0) +} + +func projectViewTasksList(ctx context.Context, in *taskListViewInput) (*taskListBody, error) { + return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, in.ProjectID, in.ViewID) +} + +// readFlatTasks runs DoReadAll for a flat-task endpoint and unwraps the result. +// The model authorizes (project/view CanRead) inside ReadAll, so there's no +// Can* call here. +func readFlatTasks(ctx context.Context, f taskListFilters, page, perPage int, projectID, viewID int64) (*taskListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + tc, err := f.collection(projectID, viewID, true) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, tc, a, f.Q, page, perPage) + if err != nil { + return nil, translateDomainError(err) + } + tasks, ok := result.([]*models.Task) + if !ok { + return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Task)", result) + } + return &taskListBody{Body: NewPaginated(tasks, total, page, perPage)}, nil +} + +func projectViewBucketsTasksList(ctx context.Context, in *taskListViewInput) (*bucketsWithTasksBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + f := in.filters() + tc, err := f.collection(in.ProjectID, in.ViewID, false) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, tc, a, f.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + buckets, ok := result.([]*models.Bucket) + if !ok { + // ReadAll only yields []*Bucket from the kanban branch; a flat []*Task + // here means the view has no bucket configuration, so there are no + // buckets to return. That's a client error, not a 500. + if _, isTasks := result.([]*models.Task); isTasks { + return nil, huma.Error400BadRequest("this view has no buckets; use the tasks endpoint for non-kanban views") + } + return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Bucket)", result) + } + out := &bucketsWithTasksBody{} + out.Body.Items = buckets + out.Body.Total = total + return out, nil +} diff --git a/pkg/webtests/huma_task_collection_test.go b/pkg/webtests/huma_task_collection_test.go new file mode 100644 index 000000000..110b04b61 --- /dev/null +++ b/pkg/webtests/huma_task_collection_test.go @@ -0,0 +1,248 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// decodePaginatedTaskItems pulls the items slice out of a Paginated[*Task] +// response so length assertions don't have to regex over nested task JSON. +func decodePaginatedTaskItems(t *testing.T, rec *httptest.ResponseRecorder) []json.RawMessage { + t.Helper() + var body struct { + Items []json.RawMessage `json:"items"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + return body.Items +} + +// TestHumaTaskCollection covers the v2 task-list endpoints. v2 splits v1's +// single polymorphic /tasks endpoint into flat-task endpoints (always []*Task, +// paginated) and a dedicated buckets-with-tasks endpoint (always []*Bucket). +// Mirrors v1's TestTaskCollection where the surface overlaps. +func TestHumaTaskCollection(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + tok := humaTokenFor(t, &testuser1) + + get := func(path string) *httptest.ResponseRecorder { + return humaRequest(t, h.e, http.MethodGet, path, "", tok, "") + } + + t.Run("project-scoped", func(t *testing.T) { + t.Run("returns the project's tasks", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `"items":[`) + assert.Contains(t, body, `task #1`) + assert.Contains(t, body, `task #12`) + assert.NotContains(t, body, `task #13`) // other project + assert.NotContains(t, body, `task #14`) + }) + t.Run("forbidden project", func(t *testing.T) { + // Project 2 is inaccessible to user1. + rec := get("/api/v2/projects/2/tasks") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("nonexistent project", func(t *testing.T) { + rec := get("/api/v2/projects/99999/tasks") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("pagination", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks?page=1&per_page=2") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Len(t, decodePaginatedTaskItems(t, rec), 2, "per_page caps the page to two tasks") + body := rec.Body.String() + assert.Contains(t, body, `"page":1`) + assert.Contains(t, body, `"per_page":2`) + }) + t.Run("filter", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks?filter=" + + "start_date%20%3E%20%272018-12-11T03%3A46%3A40%2B00%3A00%27%20%7C%7C%20" + + "end_date%20%3C%20%272018-12-13T11%3A20%3A01%2B00%3A00%27%20%7C%7C%20" + + "due_date%20%3E%20%272018-11-29T14%3A00%3A00%2B00%3A00%27") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.NotContains(t, body, `task #1`) + assert.Contains(t, body, `task #5 `) + assert.Contains(t, body, `task #6 `) + assert.NotContains(t, body, `task #10`) + }) + t.Run("invalid filter value", func(t *testing.T) { + // ErrInvalidTaskFilterValue carries an explicit 400; only govalidator + // failures map to 422 in v2. + rec := get("/api/v2/projects/1/tasks?filter=due_date%20%3E%20invalid") + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + }) + + t.Run("search via q", func(t *testing.T) { + // Only task #6 has the word "unique" in its description. + rec := get("/api/v2/projects/1/tasks?q=unique") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `task #6 `) + assert.NotContains(t, body, `task #1`) + assert.NotContains(t, body, `task #2 `) + }) + + t.Run("sort by repeated params", func(t *testing.T) { + // Two sort_by + two order_by prove ,explode binds every value. + rec := get("/api/v2/projects/1/tasks?sort_by=priority&sort_by=id&order_by=desc&order_by=asc") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + // task #3 has priority 100, the highest; desc puts it first. + assert.Regexp(t, `"items":\[\{"id":3,`, rec.Body.String()) + }) + + t.Run("invalid sort field", func(t *testing.T) { + // A 400 (not 200) proves sort_by binds: the model validated the field + // and rejected it. ErrInvalidTaskField carries an explicit 400. + rec := get("/api/v2/projects/1/tasks?sort_by=loremipsum") + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("cross-project", func(t *testing.T) { + // /tasks returns tasks from every project the user can see, including + // shared ones, but not tasks in projects they have no access to. + rec := get("/api/v2/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `task #1`) // own project + assert.Contains(t, body, `task #15`) // shared via team readonly + assert.Contains(t, body, `task #21`) // shared via parent project team + assert.NotContains(t, body, `task #13`) // no access + assert.NotContains(t, body, `task #14`) + }) + + t.Run("view-scoped", func(t *testing.T) { + t.Run("list view returns flat tasks", func(t *testing.T) { + rec := get("/api/v2/projects/1/views/1/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `task #1`) + assert.NotContains(t, body, `testbucket`) // not buckets + }) + t.Run("kanban view still returns flat tasks", func(t *testing.T) { + // View 4 is project 1's kanban view. v1 would return buckets here; + // v2's tasks endpoint forces flat tasks. + rec := get("/api/v2/projects/1/views/4/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `"items":[`) + assert.Contains(t, body, `task #1`) + assert.NotContains(t, body, `testbucket`) + }) + t.Run("forbidden view", func(t *testing.T) { + // Project 2 (and its view 8) is inaccessible to user1. + rec := get("/api/v2/projects/2/views/8/tasks") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + }) + + t.Run("saved filter project", func(t *testing.T) { + // Project -2 maps to saved filter #1, whose stored filter matches the + // date-range tasks. Recurses inside the model. + rec := get("/api/v2/projects/-2/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `task #5 `) + assert.Contains(t, body, `task #6 `) + assert.NotContains(t, body, `task #1`) + assert.NotContains(t, body, `task #10`) + }) +} + +// TestHumaTaskCollection_Expand proves expand binds every repeated value +// (,explode) and routes through parseTaskExpand. +func TestHumaTaskCollection_Expand(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + tok := humaTokenFor(t, &testuser1) + + get := func(path string) *httptest.ResponseRecorder { + return humaRequest(t, h.e, http.MethodGet, path, "", tok, "") + } + + t.Run("repeated expand applies every value", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks?expand=comment_count&expand=reactions") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `"comment_count":`) + assert.Contains(t, body, `"reactions":`) + }) + t.Run("invalid expand rejected", func(t *testing.T) { + rec := get("/api/v2/projects/1/tasks?expand=bogus") + // enum on the query param makes Huma reject before the handler. + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaTaskCollection_Buckets covers the dedicated buckets-with-tasks +// endpoint: a kanban view returns []*Bucket with each bucket's tasks populated, +// not paginated. +func TestHumaTaskCollection_Buckets(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + tok := humaTokenFor(t, &testuser1) + + get := func(path string) *httptest.ResponseRecorder { + return humaRequest(t, h.e, http.MethodGet, path, "", tok, "") + } + + t.Run("kanban view returns buckets with tasks", func(t *testing.T) { + rec := get("/api/v2/projects/1/views/4/buckets/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, `testbucket1`) + assert.Contains(t, body, `testbucket2`) + assert.Contains(t, body, `testbucket3`) + assert.NotContains(t, body, `testbucket4`) // belongs to project 2's view + // Tasks are nested under their bucket, not at the top level. + assert.Contains(t, body, `"tasks":[`) + assert.Contains(t, body, `task #1`) + // total counts buckets, not tasks. + assert.Contains(t, body, `"total":3`) + }) + + t.Run("forbidden project", func(t *testing.T) { + rec := get("/api/v2/projects/2/views/8/buckets/tasks") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("non-kanban view is a 400, not a 500", func(t *testing.T) { + // View 1 is project 1's list view; it has no bucket configuration, so + // the model returns flat tasks and the handler refuses cleanly. + rec := get("/api/v2/projects/1/views/1/buckets/tasks") + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("static tasks segment does not collide with the bucket-update route", func(t *testing.T) { + // PUT .../buckets/{bucket}/tasks exists; GET .../buckets/tasks must hit + // this handler, not parse "tasks" as a bucket id. + rec := get("/api/v2/projects/1/views/4/buckets/tasks") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `testbucket1`) + }) +}