diff --git a/pkg/models/task_duplicate.go b/pkg/models/task_duplicate.go
new file mode 100644
index 000000000..6be9bbec1
--- /dev/null
+++ b/pkg/models/task_duplicate.go
@@ -0,0 +1,172 @@
+// 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 models
+
+import (
+ "code.vikunja.io/api/pkg/files"
+ "code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/web"
+
+ "xorm.io/xorm"
+)
+
+// TaskDuplicate holds everything needed to duplicate a task
+type TaskDuplicate struct {
+ // The task id of the task to duplicate
+ TaskID int64 `json:"-" param:"projecttask"`
+
+ // The duplicated task
+ Task *Task `json:"duplicated_task,omitempty"`
+
+ web.Permissions `json:"-"`
+ web.CRUDable `json:"-"`
+}
+
+// CanCreate checks if a user has the permission to duplicate a task
+func (td *TaskDuplicate) CanCreate(s *xorm.Session, a web.Auth) (canCreate bool, err error) {
+ // Need read access on the original task
+ originalTask := &Task{ID: td.TaskID}
+ canRead, _, err := originalTask.CanRead(s, a)
+ if err != nil || !canRead {
+ return canRead, err
+ }
+
+ // Need write access on the project to create tasks in it
+ p := &Project{ID: originalTask.ProjectID}
+ return p.CanUpdate(s, a)
+}
+
+// Create duplicates a task
+// @Summary Duplicate a task
+// @Description Copies a task with all its properties (labels, assignees, attachments, reminders) into the same project. Creates a "copied from" relation between the new and original task.
+// @tags task
+// @Accept json
+// @Produce json
+// @Security JWTKeyAuth
+// @Param taskID path int true "The task ID to duplicate"
+// @Success 201 {object} models.TaskDuplicate "The duplicated task."
+// @Failure 403 {object} web.HTTPError "The user does not have access to the task."
+// @Failure 500 {object} models.Message "Internal error"
+// @Router /tasks/{taskID}/duplicate [put]
+func (td *TaskDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) {
+ // Get the original task with all details
+ originalTask := &Task{ID: td.TaskID}
+ err = originalTask.ReadOne(s, doer)
+ if err != nil {
+ return err
+ }
+
+ log.Debugf("Duplicating task %d", td.TaskID)
+
+ // Create the new task
+ newTask := &Task{
+ Title: originalTask.Title,
+ Description: originalTask.Description,
+ Done: false,
+ DueDate: originalTask.DueDate,
+ ProjectID: originalTask.ProjectID,
+ RepeatAfter: originalTask.RepeatAfter,
+ RepeatMode: originalTask.RepeatMode,
+ Priority: originalTask.Priority,
+ StartDate: originalTask.StartDate,
+ EndDate: originalTask.EndDate,
+ HexColor: originalTask.HexColor,
+ PercentDone: originalTask.PercentDone,
+ Assignees: originalTask.Assignees,
+ Reminders: originalTask.Reminders,
+ }
+
+ err = createTask(s, newTask, doer, true, true)
+ if err != nil {
+ return err
+ }
+
+ log.Debugf("Duplicated task %d into new task %d", td.TaskID, newTask.ID)
+
+ // Duplicate labels
+ labelTasks := []*LabelTask{}
+ err = s.Where("task_id = ?", td.TaskID).Find(&labelTasks)
+ if err != nil {
+ return err
+ }
+ for _, lt := range labelTasks {
+ lt.ID = 0
+ lt.TaskID = newTask.ID
+ if _, err := s.Insert(lt); err != nil {
+ return err
+ }
+ }
+
+ log.Debugf("Duplicated labels from task %d into %d", td.TaskID, newTask.ID)
+
+ // Duplicate attachments (copy underlying files)
+ attachments, err := getTaskAttachmentsByTaskIDs(s, []int64{td.TaskID})
+ if err != nil {
+ return err
+ }
+ for _, attachment := range attachments {
+ attachment.ID = 0
+ attachment.TaskID = newTask.ID
+ attachment.File = &files.File{ID: attachment.FileID}
+ if err := attachment.File.LoadFileMetaByID(); err != nil {
+ if files.IsErrFileDoesNotExist(err) {
+ log.Debugf("Not duplicating attachment (file %d) because it does not exist", attachment.FileID)
+ continue
+ }
+ return err
+ }
+ if err := attachment.File.LoadFileByID(); err != nil {
+ return err
+ }
+
+ err := attachment.NewAttachment(s, attachment.File.File, attachment.File.Name, attachment.File.Size, doer)
+ if err != nil {
+ return err
+ }
+ if attachment.File.File != nil {
+ _ = attachment.File.File.Close()
+ }
+ }
+
+ log.Debugf("Duplicated attachments from task %d into %d", td.TaskID, newTask.ID)
+
+ // Create "copied from/to" relation
+ rel := &TaskRelation{
+ TaskID: newTask.ID,
+ OtherTaskID: td.TaskID,
+ RelationKind: RelationKindCopiedFrom,
+ CreatedByID: doer.GetID(),
+ }
+ if _, err := s.Insert(rel); err != nil {
+ return err
+ }
+ reverseRel := &TaskRelation{
+ TaskID: td.TaskID,
+ OtherTaskID: newTask.ID,
+ RelationKind: RelationKindCopiedTo,
+ CreatedByID: doer.GetID(),
+ }
+ if _, err := s.Insert(reverseRel); err != nil {
+ return err
+ }
+
+ log.Debugf("Created copy relations between task %d and %d", td.TaskID, newTask.ID)
+
+ // Re-read the task to populate all fields for the response
+ td.Task = newTask
+ return td.Task.ReadOne(s, doer)
+}
diff --git a/pkg/models/task_duplicate_test.go b/pkg/models/task_duplicate_test.go
new file mode 100644
index 000000000..4435af873
--- /dev/null
+++ b/pkg/models/task_duplicate_test.go
@@ -0,0 +1,89 @@
+// 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 models
+
+import (
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/files"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTaskDuplicate(t *testing.T) {
+ t.Run("basic duplicate", func(t *testing.T) {
+ files.InitTestFileFixtures(t)
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ u := &user.User{ID: 1}
+
+ td := &TaskDuplicate{
+ TaskID: 1,
+ }
+ can, err := td.CanCreate(s, u)
+ require.NoError(t, err)
+ assert.True(t, can)
+ err = td.Create(s, u)
+ require.NoError(t, err)
+ assert.NotEqual(t, int64(0), td.Task.ID)
+ assert.NotEqual(t, int64(1), td.Task.ID)
+ assert.Equal(t, "task #1", td.Task.Title)
+
+ // Verify labels were copied
+ labelCount, err := s.Where("task_id = ?", td.Task.ID).Count(&LabelTask{})
+ require.NoError(t, err)
+ originalLabelCount, err := s.Where("task_id = ?", int64(1)).Count(&LabelTask{})
+ require.NoError(t, err)
+ assert.Equal(t, originalLabelCount, labelCount)
+
+ // Verify assignees were copied
+ assigneeCount, err := s.Where("task_id = ?", td.Task.ID).Count(&TaskAssginee{})
+ require.NoError(t, err)
+ originalAssigneeCount, err := s.Where("task_id = ?", int64(1)).Count(&TaskAssginee{})
+ require.NoError(t, err)
+ assert.Equal(t, originalAssigneeCount, assigneeCount)
+
+ // Verify a "copiedfrom" relation was created
+ relationCount, err := s.
+ Where("task_id = ? AND other_task_id = ? AND relation_kind = ?",
+ td.Task.ID, int64(1), RelationKindCopiedFrom).
+ Count(&TaskRelation{})
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), relationCount)
+ })
+
+ t.Run("no permission", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ // User 2 does not have write access to task 1's project (project 1)
+ u := &user.User{ID: 2}
+
+ td := &TaskDuplicate{
+ TaskID: 1,
+ }
+ can, err := td.CanCreate(s, u)
+ require.NoError(t, err)
+ assert.False(t, can)
+ })
+}