fix(tasks): ignore bucket_id filters when scoping subtask roots to parent

convertFiltersToDBFilterCondWithAlias hard-codes the task_buckets.bucket_id
column and the sole task_buckets join is keyed on the child
(task_buckets.task_id = tasks.id), so rebuilding a bucket_id filter against
the parent_tasks alias silently bound it to the child's bucket and could
misclassify roots when filtering by bucket with expand=subtasks. Strip
bucket_id conditions from the parent-match predicate so a bucket filter no
longer constrains the parent.
This commit is contained in:
kolaente 2026-06-19 22:54:37 +02:00
parent cbe8f1f5b5
commit d52f6d547f
1 changed files with 29 additions and 1 deletions

View File

@ -330,6 +330,34 @@ func cloneTaskFilters(filters []*taskFilter) []*taskFilter {
return cloned
}
// stripBucketIDFilters returns a copy of filters with every bucket_id condition
// removed (recursing into nested groups and dropping groups left empty). The
// parent-scoped root condition cannot evaluate a bucket_id filter against the
// parent: convertFiltersToDBFilterCondWithAlias hard-codes the task_buckets.bucket_id
// column, and the only task_buckets join is keyed on the child (task_buckets.task_id
// = tasks.id). Keeping it would bind the parent filter to the child's bucket and
// misclassify roots, so a bucket_id filter simply does not constrain the parent.
func stripBucketIDFilters(filters []*taskFilter) []*taskFilter {
stripped := make([]*taskFilter, 0, len(filters))
for _, f := range filters {
if nested, is := f.value.([]*taskFilter); is {
child := stripBucketIDFilters(nested)
if len(child) == 0 {
continue
}
c := *f
c.value = child
stripped = append(stripped, &c)
continue
}
if f.field == taskPropertyBucketID {
continue
}
stripped = append(stripped, f)
}
return stripped
}
// buildSubtaskRootCondition decides which tasks count as "roots" when expanding
// subtasks: a task is a root unless its parent is itself part of this result set.
//
@ -355,7 +383,7 @@ func buildSubtaskRootCondition(opts *taskSearchOptions) (builder.Cond, error) {
parentMatchesFilter := builder.Cond(builder.Expr("1 = 1"))
if len(opts.parsedFilters) > 0 {
parentFilters := cloneTaskFilters(opts.parsedFilters)
parentFilters := stripBucketIDFilters(cloneTaskFilters(opts.parsedFilters))
filterCond, err := convertFiltersToDBFilterCondWithAlias(parentFilters, opts.filterIncludeNulls, "parent_tasks")
if err != nil {
return nil, err