diff --git a/pkg/models/kanban_task_bucket.go b/pkg/models/kanban_task_bucket.go index 108b8a371..58a8bbad3 100644 --- a/pkg/models/kanban_task_bucket.go +++ b/pkg/models/kanban_task_bucket.go @@ -148,14 +148,20 @@ func updateTaskBucket(s *xorm.Session, a web.Auth, b *TaskBucket) (err error) { // A repeating task doesn't stay in the done bucket; route // it back to the view's default bucket so the user sees // the next iteration waiting in the "To-Do" column. - b.BucketID, err = getDefaultBucketID(s, view) - if err != nil { - return err + // When no default bucket is configured, leave the task in + // its current bucket — no update needed. + if view.DefaultBucketID != 0 { + b.BucketID, err = getDefaultBucketID(s, view) + if err != nil { + return err + } + } else { + b.BucketID = oldTaskBucket.BucketID } - // If the task is already in the default bucket, skip the - // upsert — MySQL's UPDATE returns 0 affected rows when - // the value is unchanged, which would make upsert fall - // through to INSERT and hit the unique constraint. + // If the bucket is unchanged, skip the upsert — MySQL's + // UPDATE returns 0 affected rows when the value is unchanged, + // which would make upsert fall through to INSERT and hit the + // unique constraint. if b.BucketID == oldTaskBucket.BucketID { updateBucket = false } diff --git a/pkg/models/kanban_task_bucket_test.go b/pkg/models/kanban_task_bucket_test.go index 6d1eb2f24..032da7315 100644 --- a/pkg/models/kanban_task_bucket_test.go +++ b/pkg/models/kanban_task_bucket_test.go @@ -226,6 +226,61 @@ func TestTaskBucket_Update(t *testing.T) { }) }) + t.Run("moving a repeating task from a non-default bucket when no default bucket is configured preserves the original bucket", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + u := &user.User{ID: 1} + + // View 4 has default_bucket_id: 1 and done_bucket_id: 3. + // Remove the default bucket to test fallback behavior. + _, err := s.ID(4).Cols("default_bucket_id").Update(&ProjectView{DefaultBucketID: 0}) + require.NoError(t, err) + + // Task 28 is a repeating task. Pre-position it in bucket 2 + // using a raw update so we can bypass the bucket-2 limit check. + _, err = s.Where("task_id = ? AND project_view_id = ?", 28, 4). + Cols("bucket_id"). + Update(&TaskBucket{BucketID: 2}) + require.NoError(t, err) + + tb := &TaskBucket{ + TaskID: 28, + BucketID: 3, // Bucket 3 is the done bucket on view 4 + ProjectViewID: 4, + ProjectID: 1, + } + err = tb.Update(s, u) + require.NoError(t, err) + err = s.Commit() + require.NoError(t, err) + + // Repeating task should have been re-opened by updateDone... + assert.False(t, tb.Task.Done) + + // ...and routed back to the ORIGINAL bucket (2), not the default (1) + // and not left in the done bucket (3). + assert.Equal(t, int64(2), tb.BucketID) + + db.AssertExists(t, "tasks", map[string]interface{}{ + "id": 28, + "done": false, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 28, + "bucket_id": 2, + }, false) + db.AssertMissing(t, "task_buckets", map[string]interface{}{ + "task_id": 28, + "bucket_id": 1, + }) + db.AssertMissing(t, "task_buckets", map[string]interface{}{ + "task_id": 28, + "bucket_id": 3, + }) + }) + t.Run("keep done timestamp when moving task between projects", func(t *testing.T) { db.LoadAndAssertFixtures(t) u := &user.User{ID: 1} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index ee5eda824..a20295f0f 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -1535,31 +1535,35 @@ func (t *Task) moveTaskToDoneBuckets(s *xorm.Session, a web.Auth, views []*Proje // and is used when a repeating task is marked done: repeating tasks // don't stay in the done bucket, so they should be routed back to // the default ("To-Do") bucket so the next iteration is visible there. +// When no explicit default bucket is configured, the task stays in its +// current bucket — no update needed. func (t *Task) moveTaskToDefaultBuckets(s *xorm.Session, a web.Auth, views []*ProjectView) error { for _, view := range views { - defaultBucketID, err := getDefaultBucketID(s, view) - if err != nil { - return err - } + if view.DefaultBucketID != 0 { + defaultBucketID, err := getDefaultBucketID(s, view) + if err != nil { + return err + } - tb := &TaskBucket{ - BucketID: defaultBucketID, - TaskID: t.ID, - ProjectViewID: view.ID, - ProjectID: t.ProjectID, - } - err = updateTaskBucket(s, a, tb) - if err != nil { - return err + tb := &TaskBucket{ + BucketID: defaultBucketID, + TaskID: t.ID, + ProjectViewID: view.ID, + ProjectID: t.ProjectID, + } + if err = updateTaskBucket(s, a, tb); err != nil { + return err + } } + // When no default bucket is configured, the task stays in its current + // bucket — no bucket update needed. tp := TaskPosition{ TaskID: t.ID, ProjectViewID: view.ID, Position: calculateDefaultPosition(t.Index, t.Position), } - err = updateTaskPosition(s, a, &tp) - if err != nil { + if err := updateTaskPosition(s, a, &tp); err != nil { return err } }