// 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."` } } // TaskListQueryParams is the shared filter/sort/search/expand query block for // every task-list variant. It must stay EXPORTED: Huma promotes an anonymous // embed's params only when the embed field is itself exported, and an embed // field is exported iff its type name is (a lowercase type name silently drops // all of its params from binding and the spec). // // The three input structs below embed it but keep their path params inline: // Huma lists every path:"" field regardless of the route template, so a shared // project/view field would leak onto a narrower route as a phantom path param. // taskListViewInput is shared by both view-scoped endpoints. type TaskListQueryParams 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 taskListAllInput struct { TaskListQueryParams } type taskListProjectInput struct { ProjectID int64 `path:"project" doc:"The numeric id of the project."` TaskListQueryParams } 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."` TaskListQueryParams } // 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 }