diff --git a/pkg/routes/api/v2/task_relations.go b/pkg/routes/api/v2/task_relations.go
new file mode 100644
index 000000000..eb9fa305f
--- /dev/null
+++ b/pkg/routes/api/v2/task_relations.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 apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// RegisterTaskRelationRoutes wires task-relation create/delete onto the Huma API.
+//
+// Both operations reuse handler.DoCreate/DoDelete; CanCreate enforces write on
+// the base task + read on the other task and rejects invalid kinds, CanDelete
+// enforces write on the base task. The only custom part is mapping the path
+// segments onto the model.
+func RegisterTaskRelationRoutes(api huma.API) {
+ tags := []string{"tasks"}
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-relations-create",
+ Summary: "Create a task relation",
+ Description: "Relates two tasks. The authenticated user needs write access to the base task (in the path) and at least read access to the other task; the two tasks need not share a project. The inverse relation is created automatically (e.g. a subtask relation also stores the parenttask relation on the other task). Subtask/parenttask chains that would form a cycle are rejected.",
+ Method: http.MethodPost,
+ Path: "/tasks/{task}/relations",
+ Tags: tags,
+ }, tasksRelationsCreate)
+
+ Register(api, huma.Operation{
+ OperationID: "tasks-relations-delete",
+ Summary: "Delete a task relation",
+ Description: "Removes the relation identified by the base task, relation kind and other task. The automatically created inverse relation is removed as well. The authenticated user needs write access to the base task.",
+ Method: http.MethodDelete,
+ Path: "/tasks/{task}/relations/{relationKind}/{otherTask}",
+ Tags: tags,
+ }, tasksRelationsDelete)
+}
+
+func init() { AddRouteRegistrar(RegisterTaskRelationRoutes) }
+
+func tasksRelationsCreate(ctx context.Context, in *struct {
+ TaskID int64 `path:"task" doc:"The numeric id of the base task to relate from."`
+ Body models.TaskRelation
+}) (*singleBody[models.TaskRelation], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rel := &in.Body
+ rel.TaskID = in.TaskID // URL wins over body
+ if err := handler.DoCreate(ctx, rel, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.TaskRelation]{Body: rel}, nil
+}
+
+// The relationKind enum mirrors models.TaskRelation.RelationKind's tag (see the sync note there).
+func tasksRelationsDelete(ctx context.Context, in *struct {
+ TaskID int64 `path:"task" doc:"The numeric id of the base task."`
+ RelationKind models.RelationKind `path:"relationKind" enum:"subtask,parenttask,related,duplicateof,duplicates,blocking,blocked,precedes,follows,copiedfrom,copiedto" doc:"The kind of the relation to remove."`
+ OtherTaskID int64 `path:"otherTask" doc:"The numeric id of the other task in the relation."`
+}) (*emptyBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rel := &models.TaskRelation{
+ TaskID: in.TaskID,
+ RelationKind: in.RelationKind,
+ OtherTaskID: in.OtherTaskID,
+ }
+ if err := handler.DoDelete(ctx, rel, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
diff --git a/pkg/webtests/huma_task_relation_test.go b/pkg/webtests/huma_task_relation_test.go
new file mode 100644
index 000000000..65166402c
--- /dev/null
+++ b/pkg/webtests/huma_task_relation_test.go
@@ -0,0 +1,197 @@
+// 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 (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestTaskRelationV2 covers POST /tasks/{task}/relations and
+// DELETE /tasks/{task}/relations/{relationKind}/{otherTask}. It drives the
+// Echo+Huma stack directly (humaRequest/humaTokenFor) because the action
+// sub-paths aren't modelled by webHandlerTestV2's buildURL. Coverage mirrors
+// the v1 model matrix in pkg/models/task_relation_test.go.
+func TestTaskRelationV2(t *testing.T) {
+ t.Run("Create", func(t *testing.T) {
+ t.Run("creates forward and inverse rows", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations",
+ `{"other_task_id":2,"relation_kind":"subtask"}`, token, "")
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"relation_kind":"subtask"`)
+ assert.Contains(t, rec.Body.String(), `"task_id":1`)
+ assert.Contains(t, rec.Body.String(), `"other_task_id":2`)
+
+ // Create must store both directions: the forward subtask and the
+ // automatically derived inverse parenttask.
+ db.AssertExists(t, "task_relations", map[string]interface{}{
+ "task_id": 1,
+ "other_task_id": 2,
+ "relation_kind": models.RelationKindSubtask,
+ "created_by_id": 1,
+ }, false)
+ db.AssertExists(t, "task_relations", map[string]interface{}{
+ "task_id": 2,
+ "other_task_id": 1,
+ "relation_kind": models.RelationKindParenttask,
+ "created_by_id": 1,
+ }, false)
+ })
+
+ t.Run("path task id wins over body", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // task_id in the body is ignored; the row is created for the path task.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations",
+ `{"task_id":999,"other_task_id":2,"relation_kind":"related"}`, token, "")
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+ db.AssertExists(t, "task_relations", map[string]interface{}{
+ "task_id": 1,
+ "other_task_id": 2,
+ "relation_kind": models.RelationKindRelated,
+ }, false)
+ })
+
+ t.Run("cycle is rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // task 29 is already a subtask of task 1 (fixture); making task 1 a
+ // subtask of task 29 would close the loop.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/29/relations",
+ `{"other_task_id":1,"relation_kind":"subtask"}`, token, "")
+ require.Equal(t, http.StatusConflict, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskRelationCycle))
+ })
+
+ t.Run("same task is rejected", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations",
+ `{"other_task_id":1,"relation_kind":"related"}`, token, "")
+ require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeRelationTasksCannotBeTheSame))
+ })
+
+ t.Run("invalid relation kind in body is rejected by the enum", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // relation_kind carries an enum constraint, so Huma rejects an unknown
+ // kind with 422 before the handler runs (consistent with the delete path).
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations",
+ `{"other_task_id":2,"relation_kind":"bogus"}`, token, "")
+ require.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("nonexistent base task", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/999999/relations",
+ `{"other_task_id":1,"relation_kind":"subtask"}`, 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))
+ })
+
+ t.Run("forbidden - no write on base task", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // task 15 is read-only for user1, so CanCreate (needs write on base) denies.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/15/relations",
+ `{"other_task_id":1,"relation_kind":"subtask"}`, token, "")
+ require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ t.Run("removes forward and inverse rows", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Fixture relation 1: task 1 -subtask-> task 29, with the inverse
+ // parenttask row (task 29 -> task 1).
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/subtask/29", "", token, "")
+ require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String())
+ assert.Empty(t, rec.Body.String())
+
+ db.AssertMissing(t, "task_relations", map[string]interface{}{
+ "task_id": 1,
+ "other_task_id": 29,
+ "relation_kind": models.RelationKindSubtask,
+ })
+ db.AssertMissing(t, "task_relations", map[string]interface{}{
+ "task_id": 29,
+ "other_task_id": 1,
+ "relation_kind": models.RelationKindParenttask,
+ })
+ })
+
+ t.Run("invalid relation kind in path is rejected by the enum", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // The path param carries an enum constraint, so Huma rejects an unknown
+ // kind with 422 before the handler runs.
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/bogus/29", "", token, "")
+ require.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
+ })
+
+ t.Run("nonexistent relation", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/subtask/2", "", token, "")
+ require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
+ assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeRelationDoesNotExist))
+ })
+
+ t.Run("forbidden - no write on base task", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Fixture relation 7: task 41 -subtask-> task 43, owned by user15 in
+ // project 36, which user1 cannot access — CanDelete denies.
+ rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/41/relations/subtask/43", "", token, "")
+ require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
+ })
+ })
+}