From a6a073329f70dccc70cb39d052ad06a9e16936f8 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:06:50 +0200 Subject: [PATCH 1/4] docs(api/v2): tag task position fields for the v2 schema --- pkg/models/task_position.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go index 325033207..07c2839cc 100644 --- a/pkg/models/task_position.go +++ b/pkg/models/task_position.go @@ -33,9 +33,9 @@ const MinPositionSpacing = 0.01 type TaskPosition struct { // The ID of the task this position is for - TaskID int64 `xorm:"bigint not null index" json:"task_id" param:"task"` + TaskID int64 `xorm:"bigint not null index" json:"task_id" param:"task" readOnly:"true" doc:"The numeric id of the task this position belongs to. Taken from the URL; ignored in the request body."` // The project view this task is related to - ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id"` + ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id" doc:"The id of the project view this position applies to. Positions are stored per view, so the same task has an independent position in each of its project's views."` // The position of the task - any task project can be sorted as usual by this parameter. // When accessing tasks via kanban buckets, this is primarily used to sort them based on a range // We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number). @@ -44,7 +44,7 @@ type TaskPosition struct { // which also leaves a lot of room for rearranging and sorting later. // Positions are always saved per view. They will automatically be set if you request the tasks through a view // endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. - Position float64 `xorm:"double not null" json:"position"` + Position float64 `xorm:"double not null" json:"position" doc:"The task's sort position within the view, as a float so a task can be placed between any two others. To drop a task between two neighbours, set this to their midpoint. Values below the minimum spacing trigger a server-side recalculation of all positions in the view, so the stored value may differ from what you sent."` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` From 25a294d7bc21729ac189d38313428f1abd55a689 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:06:50 +0200 Subject: [PATCH 2/4] feat(api/v2): add task position updates on /api/v2 --- pkg/routes/api/v2/task_position.go | 63 +++++++++++++++++ pkg/webtests/huma_task_position_test.go | 94 +++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 pkg/routes/api/v2/task_position.go create mode 100644 pkg/webtests/huma_task_position_test.go diff --git a/pkg/routes/api/v2/task_position.go b/pkg/routes/api/v2/task_position.go new file mode 100644 index 000000000..13a7e3af8 --- /dev/null +++ b/pkg/routes/api/v2/task_position.go @@ -0,0 +1,63 @@ +// 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" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterTaskPositionRoutes wires the task-position update onto the Huma API. +// +// Setting a position is a plain CRUDable Update, so the handler reuses +// handler.DoUpdate (its CanUpdate delegates to the task's CanUpdate); the only +// custom part is taking TaskID from the path rather than the request body. +func RegisterTaskPositionRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-position-update", + Summary: "Set a task's position in a view", + Description: "Sets where a task sorts within one of its project's views. The position is per view, so this only affects the view named by project_view_id. Requires write access to the task. Positions below the minimum spacing make the server recalculate every position in the view, so the returned value may differ from the one sent.", + Method: http.MethodPut, + Path: "/tasks/{task}/position", + Tags: tags, + }, tasksPositionUpdate) +} + +func init() { AddRouteRegistrar(RegisterTaskPositionRoutes) } + +func tasksPositionUpdate(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The numeric id of the task whose position to set."` + Body models.TaskPosition +}) (*singleBody[models.TaskPosition], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + tp := &in.Body + tp.TaskID = in.TaskID // URL wins over body + if err := handler.DoUpdate(ctx, tp, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.TaskPosition]{Body: tp}, nil +} diff --git a/pkg/webtests/huma_task_position_test.go b/pkg/webtests/huma_task_position_test.go new file mode 100644 index 000000000..da10768e4 --- /dev/null +++ b/pkg/webtests/huma_task_position_test.go @@ -0,0 +1,94 @@ +// 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" + "fmt" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTaskPositionV2 covers PUT /tasks/{task}/position. It drives the Echo+Huma +// stack directly (humaRequest/humaTokenFor) because webHandlerTestV2's buildURL +// only models base[/{id}] paths, not action sub-paths. +func TestTaskPositionV2(t *testing.T) { + t.Run("updates the position of a writable task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Task 1 lives in project 1, which testuser1 owns; view 1 belongs to project 1. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":256}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var resp models.TaskPosition + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, int64(1), resp.TaskID, "task id is taken from the URL") + assert.Equal(t, int64(1), resp.ProjectViewID) + assert.InDelta(t, 256.0, resp.Position, 0) + }) + + t.Run("path task id wins over the body", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Body names task 2, URL names task 1; the URL must win. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"task_id":2,"project_view_id":1,"position":300}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var resp models.TaskPosition + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, int64(1), resp.TaskID) + }) + + t.Run("nonexistent task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/99999/position", `{"project_view_id":1,"position":1}`, token, "") + require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskDoesNotExist), "body must surface ErrCodeTaskDoesNotExist; body: %s", rec.Body.String()) + }) + + t.Run("no access to the task is forbidden", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // testuser15 cannot access task 1 (project 1, owned by testuser1). + token := humaTokenFor(t, &testuser15) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":1}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("read but no write on the task is forbidden", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // Task 32 lives in project 3, on which testuser1 has read-only access. + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/32/position", `{"project_view_id":1,"position":1}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} From 0e0ececa2dc755fbc06e14ed3f01ac0d8e1810e2 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:10:37 +0200 Subject: [PATCH 3/4] docs(api/v2): tag bulk label fields for the v2 schema --- pkg/models/label_task.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/models/label_task.go b/pkg/models/label_task.go index 312f5d027..024b0e039 100644 --- a/pkg/models/label_task.go +++ b/pkg/models/label_task.go @@ -415,7 +415,7 @@ func (t *Task) UpdateTaskLabels(s *xorm.Session, creator web.Auth, labels []*Lab // LabelTaskBulk is a helper struct to update a bunch of labels at once type LabelTaskBulk struct { // All labels you want to update at once. - Labels []*Label `json:"labels"` + Labels []*Label `json:"labels" doc:"The complete set of labels the task should have after the call. Any label currently on the task that is not in this list is removed; any label in the list that is not yet on the task is added. You must be able to see every label you attach."` TaskID int64 `json:"-" param:"projecttask"` web.CRUDable `json:"-"` From 328de89c0b69fd7ee9ba32b0b4fed9d677a94004 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:10:41 +0200 Subject: [PATCH 4/4] feat(api/v2): add bulk label replacement on /api/v2 --- pkg/routes/api/v2/label_task_bulk.go | 62 ++++++++++++ pkg/webtests/huma_label_task_bulk_test.go | 117 ++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 pkg/routes/api/v2/label_task_bulk.go create mode 100644 pkg/webtests/huma_label_task_bulk_test.go diff --git a/pkg/routes/api/v2/label_task_bulk.go b/pkg/routes/api/v2/label_task_bulk.go new file mode 100644 index 000000000..82f837540 --- /dev/null +++ b/pkg/routes/api/v2/label_task_bulk.go @@ -0,0 +1,62 @@ +// 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" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterLabelTaskBulkRoutes wires the bulk label-replacement action onto the +// Huma API. The model op is a CRUDable Create (handler.DoCreate, whose +// CanCreate enforces write access to the task), but the verb is PUT because the +// operation replaces the task's whole label set — the idempotent PUT semantics +// describe it more honestly than POST. +func RegisterLabelTaskBulkRoutes(api huma.API) { + tags := []string{"labels"} + + Register(api, huma.Operation{ + OperationID: "task-labels-bulk-replace", + Summary: "Replace all labels on a task", + Description: "Sets the task's labels to exactly the provided list: labels not in the list are removed, missing ones are added, unchanged ones are left alone. Requires write access to the task, and you must be able to see every label you attach. Returns the resulting label set.", + Method: http.MethodPut, + Path: "/tasks/{projecttask}/labels/bulk", + Tags: tags, + }, labelTasksBulkReplace) +} + +func init() { AddRouteRegistrar(RegisterLabelTaskBulkRoutes) } + +func labelTasksBulkReplace(ctx context.Context, in *struct { + TaskID int64 `path:"projecttask" doc:"The numeric id of the task whose labels to replace."` + Body models.LabelTaskBulk +}) (*singleBody[models.LabelTaskBulk], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + in.Body.TaskID = in.TaskID // parent from the path, not the body + if err := handler.DoCreate(ctx, &in.Body, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.LabelTaskBulk]{Body: &in.Body}, nil +} diff --git a/pkg/webtests/huma_label_task_bulk_test.go b/pkg/webtests/huma_label_task_bulk_test.go new file mode 100644 index 000000000..3ee42e45f --- /dev/null +++ b/pkg/webtests/huma_label_task_bulk_test.go @@ -0,0 +1,117 @@ +// 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" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestLabelTaskBulk_V2 ports the v1 bulk-replace matrix +// (pkg/webtests/label_task_test.go) onto PUT /api/v2/tasks/{projecttask}/labels/bulk. +// The body is the full target label set; the call adds missing labels and +// removes any not listed. +// +// Permission topology for testuser1 (see pkg/db/fixtures): +// - task 1 (project 1): owned by user1 → write. Has label #4 attached. +// - task 15 (project 6): shared via team 2 read-only → no write. +// - task 16 (project 7): shared via team 3 with write. +// - task 34 (project 20): private to user13 → no access. +// +// Labels: #1 own; #3 (user2, attached to no visible task) is invisible to user1. +func TestLabelTaskBulk_V2(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + put := func(taskID, body string) (*v2ProblemJSON, []int64, int) { + t.Helper() + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/"+taskID+"/labels/bulk", body, token, "") + if rec.Code >= 400 { + var p v2ProblemJSON + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &p), "error body: %s", rec.Body.String()) + return &p, nil, rec.Code + } + var resp struct { + Labels []struct { + ID int64 `json:"id"` + } `json:"labels"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp), "body: %s", rec.Body.String()) + ids := make([]int64, 0, len(resp.Labels)) + for _, l := range resp.Labels { + ids = append(ids, l.ID) + } + return nil, ids, rec.Code + } + + t.Run("Replace adds and removes", func(t *testing.T) { + // task 1 starts with label #4; replacing with [#1] must add #1 and drop #4. + p, ids, code := put("1", `{"labels":[{"id":1}]}`) + require.Nil(t, p) + assert.Equal(t, http.StatusOK, code) + assert.ElementsMatch(t, []int64{1}, ids, + "task 1's labels must be exactly {1} after replace") + }) + t.Run("Empty list clears all labels", func(t *testing.T) { + // task 16 (write-shared) gets a label, then an empty replace removes it. + _, ids, code := put("16", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusOK, code) + assert.ElementsMatch(t, []int64{1}, ids) + + p, ids, code := put("16", `{"labels":[]}`) + require.Nil(t, p) + assert.Equal(t, http.StatusOK, code) + assert.Empty(t, ids, "empty replace must remove every label") + }) + t.Run("Write share can replace", func(t *testing.T) { + _, ids, code := put("16", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusOK, code) + assert.ElementsMatch(t, []int64{1}, ids) + }) + t.Run("Read-only share is forbidden", func(t *testing.T) { + p, _, code := put("15", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusForbidden, code) + require.NotNil(t, p) + }) + t.Run("Forbidden task", func(t *testing.T) { + // task 34 is private to user13. + p, _, code := put("34", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusForbidden, code) + require.NotNil(t, p) + }) + t.Run("Nonexisting task", func(t *testing.T) { + p, _, code := put("9999", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusNotFound, code) + require.NotNil(t, p) + assert.Equal(t, models.ErrCodeTaskDoesNotExist, p.Code) + }) + t.Run("Label the user cannot see is rejected", func(t *testing.T) { + // label #3 (user2's, attached to no task user1 can see) is invisible to + // user1; attaching it to a writable task must be refused. + p, _, code := put("1", `{"labels":[{"id":3}]}`) + assert.Equal(t, http.StatusForbidden, code) + require.NotNil(t, p) + assert.Equal(t, models.ErrCodeUserHasNoAccessToLabel, p.Code) + }) +}