From 3acb1ad1c7eb9c3d856817ff06c58d0e6bd94e20 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Fri, 17 Jul 2026 16:53:49 +0000 Subject: [PATCH 1/2] feat: add support for custom field filters in task listing - Implemented parsing and handling of custom field filters in the task handler. - Added tests for various custom field filter scenarios including select, number range, and date range filters. - Enhanced error handling for invalid JSON and unknown field keys in custom field filters. - Introduced regression tests to ensure stability against invalid stored values and field key reuse scenarios. - Updated API documentation to reflect new query parameters for custom field filters and importance ranges. --- apps/mcp/src/types/index.ts | 10 + .../interactions/interaction-layout.tsx | 76 ++ .../task-detail/properties-panel.tsx | 24 +- .../interactions/view-settings-panel.tsx | 485 +++++++++- apps/web/src/i18n/locales/en/projects.json | 22 +- apps/web/src/i18n/locales/es/projects.json | 22 +- apps/web/src/i18n/locales/fr/projects.json | 22 +- apps/web/src/i18n/locales/ja/projects.json | 22 +- apps/web/src/i18n/locales/ko/projects.json | 22 +- apps/web/src/i18n/locales/pt-BR/projects.json | 22 +- apps/web/src/i18n/locales/ru/projects.json | 22 +- apps/web/src/i18n/locales/vi/projects.json | 22 +- apps/web/src/i18n/locales/zh-CN/projects.json | 22 +- apps/web/src/lib/interaction-api.test.ts | 94 ++ apps/web/src/lib/interaction-api.ts | 93 ++ services/api/internal/domain/sprint/entity.go | 27 +- .../api/internal/domain/task/repository.go | 46 + .../repository/postgres/task_repository.go | 218 ++++- .../postgres/task_repository_test.go | 296 ++++++ .../http/handler/attachment_handler_test.go | 10 - .../transport/http/handler/task_handler.go | 161 ++++ .../http/handler/task_handler_test.go | 216 ++++- services/api/test/e2e/task_filter_test.go | 879 ++++++++++++++++++ services/api/test/e2e/task_pagination_test.go | 104 +++ 24 files changed, 2858 insertions(+), 79 deletions(-) create mode 100644 services/api/test/e2e/task_filter_test.go diff --git a/apps/mcp/src/types/index.ts b/apps/mcp/src/types/index.ts index 6e916508..bf2f1376 100644 --- a/apps/mcp/src/types/index.ts +++ b/apps/mcp/src/types/index.ts @@ -425,11 +425,21 @@ export interface FilterConfig { items?: Record; } +export interface CustomFieldFilterConfig { + selector?: FilterConfig; + min?: number; + max?: number; + after?: string; + before?: string; + contains?: string; +} + export interface ViewFilters { task_types?: FilterConfig; statuses?: FilterConfig; assignees?: FilterConfig; sprints?: FilterConfig; + custom_fields?: Record; } export interface ViewConfig { diff --git a/apps/web/src/components/projects/interactions/interaction-layout.tsx b/apps/web/src/components/projects/interactions/interaction-layout.tsx index ec773321..4badbd89 100644 --- a/apps/web/src/components/projects/interactions/interaction-layout.tsx +++ b/apps/web/src/components/projects/interactions/interaction-layout.tsx @@ -37,6 +37,7 @@ import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { allTasksQueryOptions, bulkMoveViewTaskPositions, + type CustomFieldFilterQuery, createSprint, createTask, createViewByContext, @@ -76,6 +77,7 @@ import { cn } from "@/lib/utils"; import { BoardView } from "./board-view"; import { ListView } from "./list-view"; import { NewViewPopover } from "./new-view-popover"; +import { getImportanceBucketBounds } from "./priority"; import { RenameViewDialog } from "./rename-view-dialog"; import { RoadmapView } from "./roadmap-view"; import { TaskDetailModal } from "./task-detail-modal"; @@ -561,6 +563,52 @@ export function InteractionLayout({ ); } + let custom_field_filters: + | Record + | undefined; + const cfFilterConfig = activeViewConfig?.filters?.custom_fields; + if (cfFilterConfig) { + const resolved: Record = {}; + for (const cf of customFields) { + const f = cfFilterConfig[cf.field_key]; + if (!f) continue; + if (cf.field_type === "select" || cf.field_type === "multi_select") { + if (!f.selector) continue; + const values = resolveFilterConfig(f.selector, cf.options); + if (values.length > 0) resolved[cf.field_key] = { values }; + } else if (cf.field_type === "boolean") { + if (!f.selector) continue; + const values = resolveFilterConfig(f.selector, ["true", "false"]); + if (values.length > 0) resolved[cf.field_key] = { values }; + } else if (cf.field_type === "number") { + if (f.min == null && f.max == null) continue; + resolved[cf.field_key] = { min: f.min, max: f.max }; + } else if (cf.field_type === "date") { + if (!f.after && !f.before) continue; + resolved[cf.field_key] = { after: f.after, before: f.before }; + } else { + // text / url + if (!f.contains) continue; + resolved[cf.field_key] = { contains: f.contains }; + } + } + if (Object.keys(resolved).length > 0) custom_field_filters = resolved; + } + + let importance_ranges: { min: number; max: number }[] | undefined; + const importanceBuckets = activeViewConfig?.filters?.importance; + if (importanceBuckets && importanceBuckets.length > 0) { + importance_ranges = importanceBuckets.map((bucket) => + getImportanceBucketBounds(bucket), + ); + } + + const tags = + activeViewConfig?.filters?.tags && + activeViewConfig.filters.tags.length > 0 + ? activeViewConfig.filters.tags + : undefined; + return { sprint_ids: activeViewConfig?.filters !== undefined @@ -582,9 +630,19 @@ export function InteractionLayout({ assignee_ids, assignee_null, task_type_ids, + custom_field_filters, + start_date_after: activeViewConfig?.filters?.start_date?.after, + start_date_before: activeViewConfig?.filters?.start_date?.before, + due_date_after: activeViewConfig?.filters?.due_date?.after, + due_date_before: activeViewConfig?.filters?.due_date?.before, + story_points_min: activeViewConfig?.filters?.story_points?.min, + story_points_max: activeViewConfig?.filters?.story_points?.max, + importance_ranges, + tags, }; }, [ activeViewConfig?.filters, + customFields, defaultPageTaskTypeIds, members, sprints, @@ -645,6 +703,15 @@ export function InteractionLayout({ sortBy: activeViewConfig?.sort_by, viewId: effectiveViewId, search: debouncedSearchQuery || undefined, + customFieldFilters: apiFilters.custom_field_filters, + startDateAfter: apiFilters.start_date_after, + startDateBefore: apiFilters.start_date_before, + dueDateAfter: apiFilters.due_date_after, + dueDateBefore: apiFilters.due_date_before, + storyPointsMin: apiFilters.story_points_min, + storyPointsMax: apiFilters.story_points_max, + importanceRanges: apiFilters.importance_ranges, + tags: apiFilters.tags, }), [ context, @@ -724,6 +791,15 @@ export function InteractionLayout({ sortBy: activeViewConfig?.sort_by, viewId: effectiveViewId, search: debouncedSearchQuery || undefined, + customFieldFilters: apiFilters.custom_field_filters, + startDateAfter: apiFilters.start_date_after, + startDateBefore: apiFilters.start_date_before, + dueDateAfter: apiFilters.due_date_after, + dueDateBefore: apiFilters.due_date_before, + storyPointsMin: apiFilters.story_points_min, + storyPointsMax: apiFilters.story_points_max, + importanceRanges: apiFilters.importance_ranges, + tags: apiFilters.tags, }), [ context, diff --git a/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx b/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx index b6888389..3850980a 100644 --- a/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx @@ -2,11 +2,9 @@ import { ArrowRight, BookOpen, Check, - Clock, ExternalLink, KanbanSquare, Layers, - Link2, Plus, X, } from "lucide-react"; @@ -31,7 +29,7 @@ import { } from "../priority"; import { TaskTypeSelector } from "../task-type-selector"; import { AddFieldDialog } from "./add-field-dialog"; -import { FieldRow, FieldValue } from "./primitives"; +import { FieldRow } from "./primitives"; import type { SelectOption, UserOption } from "./property-field"; import { PropertyField } from "./property-field"; import { NumberEditor } from "./property-field/number-editor"; @@ -163,16 +161,6 @@ export function PropertiesPanel({ canEdit={canEdit} /> - - - - {(taskType || (canEdit && taskTypes.length > 0)) && ( )} - - - - void; +}) { + const { t } = useTranslation("projects"); + const isAll = selectedBuckets.length === 0; + + const toggle = (bucket: number) => { + if (selectedBuckets.includes(bucket)) { + onChange(selectedBuckets.filter((b) => b !== bucket)); + } else { + onChange([...selectedBuckets, bucket]); + } + }; + + return ( +
+ onChange([])} + /> + {PRIORITY_LEVELS.map((level) => ( + toggle(level.value)} + dot={ + + } + /> + ))} +
+ ); +} + +// ── Tags filter ──────────────────────────────────────────────────────────────── + +function TagsFilterSection({ + tags, + onChange, +}: { + tags: string[]; + onChange: (tags: string[]) => void; +}) { + const { t } = useTranslation("projects"); + const [input, setInput] = useState(""); + + function handleAdd(tag: string) { + const trimmed = tag.trim(); + if (!trimmed || tags.includes(trimmed)) return; + onChange([...tags, trimmed]); + setInput(""); + } + + function handleRemove(tag: string) { + onChange(tags.filter((x) => x !== tag)); + } + + return ( +
+ ({ key: tag, label: tag }))} + onRemoveChip={handleRemove} + canEdit + addLabel={t("layout.viewSettings.tagsFilter.addTag")} + > +
{ + e.preventDefault(); + handleAdd(input); + }} + > + setInput(e.target.value)} + placeholder={t("layout.viewSettings.tagsFilter.addTagPlaceholder")} + className="w-full rounded-lg border border-border/30 bg-muted/25 px-3 py-2 text-sm placeholder:text-muted-foreground/60 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all duration-150" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAdd(input); + } + }} + /> +
+
+
+ ); +} + +// ── Custom field filters ────────────────────────────────────────────────────── + +function customFieldFilterCount( + f: CustomFieldFilterConfig | undefined, +): number { + if (!f) return 0; + if (f.selector) return filterConfigToIds(f.selector).length; + if (f.min != null || f.max != null) return 1; + if (f.after || f.before) return 1; + if (f.contains) return 1; + return 0; +} + +function CustomFieldSelectorFilter({ + options, + selectedIds, + onChange, + noOptionsLabel, +}: { + options: { key: string; label: string }[]; + selectedIds: string[]; + onChange: (ids: string[]) => void; + noOptionsLabel: string; +}) { + const { t } = useTranslation("projects"); + const isAll = selectedIds.length === 0; + + if (options.length === 0) { + return ( +

+ {noOptionsLabel} +

+ ); + } + + const toggle = (key: string) => { + if (selectedIds.includes(key)) + onChange(selectedIds.filter((x) => x !== key)); + else onChange([...selectedIds, key]); + }; + + return ( +
+ onChange([])} + /> + {options.map((o) => ( + toggle(o.key)} + /> + ))} +
+ ); +} + +function RangeFilterInput({ + kind, + min, + max, + onChange, +}: { + kind: "number" | "date"; + min?: string; + max?: string; + onChange: (min: string | undefined, max: string | undefined) => void; +}) { + const { t } = useTranslation("projects"); + return ( +
+ onChange(e.target.value || undefined, max)} + placeholder={t("layout.viewSettings.customFieldFilter.min")} + className="w-full min-w-0 rounded-md border border-border/30 bg-background px-2 py-1 text-xs outline-none transition-all hover:border-border/50 focus:border-primary/50 focus:ring-1 focus:ring-primary/20" + /> + + onChange(min, e.target.value || undefined)} + placeholder={t("layout.viewSettings.customFieldFilter.max")} + className="w-full min-w-0 rounded-md border border-border/30 bg-background px-2 py-1 text-xs outline-none transition-all hover:border-border/50 focus:border-primary/50 focus:ring-1 focus:ring-primary/20" + /> +
+ ); +} + +function CustomFieldContainsFilter({ + value, + onChange, +}: { + value?: string; + onChange: (v: string | undefined) => void; +}) { + const { t } = useTranslation("projects"); + const [localValue, setLocalValue] = useState(value ?? ""); + const debouncedOnChange = useDebouncedCallback(onChange, 300); + + useEffect(() => { + setLocalValue(value ?? ""); + }, [value]); + + return ( +
+ { + setLocalValue(e.target.value); + debouncedOnChange(e.target.value || undefined); + }} + placeholder={t( + "layout.viewSettings.customFieldFilter.containsPlaceholder", + )} + className="w-full min-w-0 rounded-md border border-border/30 bg-background px-2 py-1 text-xs outline-none transition-all hover:border-border/50 focus:border-primary/50 focus:ring-1 focus:ring-primary/20" + /> +
+ ); +} + +function CustomFieldFilterSection({ + cf, + value, + onChange, +}: { + cf: CustomFieldDefinition; + value: CustomFieldFilterConfig | undefined; + onChange: (next: CustomFieldFilterConfig | undefined) => void; +}) { + const { t } = useTranslation("projects"); + + if (cf.field_type === "select" || cf.field_type === "multi_select") { + const selectedIds = filterConfigToIds(value?.selector); + return ( + ({ key: opt, label: opt }))} + selectedIds={selectedIds} + noOptionsLabel={t("layout.viewSettings.customFieldFilter.noOptions")} + onChange={(ids) => { + const selector = idsToFilterConfig(ids); + onChange(selector ? { selector } : undefined); + }} + /> + ); + } + + if (cf.field_type === "boolean") { + const selectedIds = filterConfigToIds(value?.selector); + return ( + { + const selector = idsToFilterConfig(ids); + onChange(selector ? { selector } : undefined); + }} + /> + ); + } + + if (cf.field_type === "number") { + return ( + { + const next: CustomFieldFilterConfig = { + min: min !== undefined ? Number(min) : undefined, + max: max !== undefined ? Number(max) : undefined, + }; + onChange(next.min == null && next.max == null ? undefined : next); + }} + /> + ); + } + + if (cf.field_type === "date") { + return ( + { + const next: CustomFieldFilterConfig = { after, before }; + onChange(!next.after && !next.before ? undefined : next); + }} + /> + ); + } + + // text / url + return ( + onChange(contains ? { contains } : undefined)} + /> + ); +} + // ── Collapsible filter group ────────────────────────────────────────────────── function CollapsibleFilter({ @@ -837,9 +1160,39 @@ export function ViewSettingsPanel({ if (!next.statuses) delete next.statuses; if (!next.assignees) delete next.assignees; if (!next.task_types) delete next.task_types; + if (!next.custom_fields || Object.keys(next.custom_fields).length === 0) { + delete next.custom_fields; + } + if (!next.start_date?.after && !next.start_date?.before) { + delete next.start_date; + } + if (!next.due_date?.after && !next.due_date?.before) { + delete next.due_date; + } + if (next.story_points?.min == null && next.story_points?.max == null) { + delete next.story_points; + } + if (!next.importance || next.importance.length === 0) { + delete next.importance; + } + if (!next.tags || next.tags.length === 0) { + delete next.tags; + } update({ filters: Object.keys(next).length > 0 ? next : {} }); }; + const updateCustomFieldFilter = ( + fieldKey: string, + next: CustomFieldFilterConfig | undefined, + ) => { + const current = { ...(draft.filters?.custom_fields ?? {}) }; + if (next) current[fieldKey] = next; + else delete current[fieldKey]; + updateFilters({ + custom_fields: Object.keys(current).length > 0 ? current : undefined, + }); + }; + const handleSave = async () => { if (!view) return; await onSave(view.id, draft); @@ -882,13 +1235,34 @@ export function ViewSettingsPanel({ const filterAssigneeIds = filterConfigToIds(draft.filters?.assignees); const { allNormal: filterTaskTypeAllNormal, selectedIds: filterTaskTypeIds } = taskTypeConfigToUI(draft.filters?.task_types); + const customFieldFilters = draft.filters?.custom_fields ?? {}; + const customFieldFilterBadgeTotal = customFields.reduce( + (sum, cf) => sum + customFieldFilterCount(customFieldFilters[cf.field_key]), + 0, + ); + const filterStartDate = draft.filters?.start_date; + const filterDueDate = draft.filters?.due_date; + const filterStoryPoints = draft.filters?.story_points; + const filterImportance = draft.filters?.importance ?? []; + const filterTags = draft.filters?.tags ?? []; + const startDateBadge = + filterStartDate?.after || filterStartDate?.before ? 1 : 0; + const dueDateBadge = filterDueDate?.after || filterDueDate?.before ? 1 : 0; + const storyPointsBadge = + filterStoryPoints?.min != null || filterStoryPoints?.max != null ? 1 : 0; const filterBadge = filterSprintIds.length + filterStatusIds.length + filterAssigneeIds.length + filterTaskTypeIds.length + - (filterTaskTypeAllNormal ? 1 : 0); + (filterTaskTypeAllNormal ? 1 : 0) + + customFieldFilterBadgeTotal + + startDateBadge + + dueDateBadge + + storyPointsBadge + + filterImportance.length + + filterTags.length; const hasSavedFilters = filterBadge > 0; return ( @@ -1115,6 +1489,115 @@ export function ViewSettingsPanel({ } /> + + 0} + > + + updateFilters({ start_date: { after, before } }) + } + /> + + + 0} + > + + updateFilters({ due_date: { after, before } }) + } + /> + + + 0} + > + + updateFilters({ + importance: buckets.length > 0 ? buckets : undefined, + }) + } + /> + + + 0} + > + + updateFilters({ + story_points: { + min: min !== undefined ? Number(min) : undefined, + max: max !== undefined ? Number(max) : undefined, + }, + }) + } + /> + + + 0} + > + + updateFilters({ + tags: tags.length > 0 ? tags : undefined, + }) + } + /> + + + {customFields.map((cf) => { + const value = customFieldFilters[cf.field_key]; + const count = customFieldFilterCount(value); + return ( + 0} + > + + updateCustomFieldFilter(cf.field_key, next) + } + /> + + ); + })} diff --git a/apps/web/src/i18n/locales/en/projects.json b/apps/web/src/i18n/locales/en/projects.json index 104a63d1..d8c3e7ee 100644 --- a/apps/web/src/i18n/locales/en/projects.json +++ b/apps/web/src/i18n/locales/en/projects.json @@ -740,10 +740,7 @@ "title": "Properties", "status": "Status", "dates": "Dates", - "trackTime": "Track Time", - "addTime": "Add time", "type": "Type", - "relationships": "Relationships", "assignees": "Assignees", "importance": "Importance", "none": "None", @@ -1017,6 +1014,11 @@ "statusesLabel": "Statuses", "assigneesLabel": "Assignees", "taskTypesLabel": "Task types", + "startDateLabel": "Start Date", + "dueDateLabel": "Due Date", + "importanceLabel": "Importance", + "storyPointsLabel": "Story Points", + "tagsLabel": "Tags", "reset": "Reset", "saving": "Saving…", "save": "Save", @@ -1040,6 +1042,20 @@ "allNormalTypesDynamic": "All normal types (dynamic)", "systemTypes": "System types", "noTaskTypes": "No task types" + }, + "customFieldFilter": { + "allValues": "All values", + "noOptions": "No options", + "min": "Min", + "max": "Max", + "containsPlaceholder": "Contains…" + }, + "importanceFilter": { + "allLevels": "All levels" + }, + "tagsFilter": { + "addTag": "Add tag", + "addTagPlaceholder": "Add tag..." } }, "roadmap": { diff --git a/apps/web/src/i18n/locales/es/projects.json b/apps/web/src/i18n/locales/es/projects.json index 2082bbf9..0b18ad6b 100644 --- a/apps/web/src/i18n/locales/es/projects.json +++ b/apps/web/src/i18n/locales/es/projects.json @@ -740,10 +740,7 @@ "title": "Propiedades", "status": "Estado", "dates": "Fechas", - "trackTime": "Registrar tiempo", - "addTime": "Añadir tiempo", "type": "Tipo", - "relationships": "Relaciones", "assignees": "Asignados", "importance": "Importancia", "none": "Ninguno", @@ -1017,6 +1014,11 @@ "statusesLabel": "Estados", "assigneesLabel": "Asignados", "taskTypesLabel": "Tipos de tarea", + "startDateLabel": "Fecha de inicio", + "dueDateLabel": "Fecha de vencimiento", + "importanceLabel": "Importancia", + "storyPointsLabel": "Puntos de historia", + "tagsLabel": "Etiquetas", "reset": "Restablecer", "saving": "Guardando…", "save": "Guardar", @@ -1040,6 +1042,20 @@ "allNormalTypesDynamic": "Todos los tipos normales (dinámico)", "systemTypes": "Tipos del sistema", "noTaskTypes": "No hay tipos de tarea" + }, + "customFieldFilter": { + "allValues": "Todos los valores", + "noOptions": "Sin opciones", + "min": "Mín", + "max": "Máx", + "containsPlaceholder": "Contiene…" + }, + "importanceFilter": { + "allLevels": "Todos los niveles" + }, + "tagsFilter": { + "addTag": "Agregar etiqueta", + "addTagPlaceholder": "Agregar etiqueta..." } }, "roadmap": { diff --git a/apps/web/src/i18n/locales/fr/projects.json b/apps/web/src/i18n/locales/fr/projects.json index 0fac78fc..567e6ee7 100644 --- a/apps/web/src/i18n/locales/fr/projects.json +++ b/apps/web/src/i18n/locales/fr/projects.json @@ -740,10 +740,7 @@ "title": "Propriétés", "status": "Statut", "dates": "Dates", - "trackTime": "Suivre le temps", - "addTime": "Ajouter du temps", "type": "Type", - "relationships": "Relations", "assignees": "Assignés", "importance": "Importance", "none": "Aucune", @@ -1017,6 +1014,11 @@ "statusesLabel": "Statuts", "assigneesLabel": "Assignés", "taskTypesLabel": "Types de tâche", + "startDateLabel": "Date de début", + "dueDateLabel": "Date d'échéance", + "importanceLabel": "Importance", + "storyPointsLabel": "Story points", + "tagsLabel": "Tags", "reset": "Réinitialiser", "saving": "Enregistrement…", "save": "Enregistrer", @@ -1040,6 +1042,20 @@ "allNormalTypesDynamic": "Tous les types normaux (dynamique)", "systemTypes": "Types système", "noTaskTypes": "Aucun type de tâche" + }, + "customFieldFilter": { + "allValues": "Toutes les valeurs", + "noOptions": "Aucune option", + "min": "Min", + "max": "Max", + "containsPlaceholder": "Contient…" + }, + "importanceFilter": { + "allLevels": "Tous les niveaux" + }, + "tagsFilter": { + "addTag": "Ajouter une étiquette", + "addTagPlaceholder": "Ajouter une étiquette..." } }, "roadmap": { diff --git a/apps/web/src/i18n/locales/ja/projects.json b/apps/web/src/i18n/locales/ja/projects.json index ca6d5c17..b995eca6 100644 --- a/apps/web/src/i18n/locales/ja/projects.json +++ b/apps/web/src/i18n/locales/ja/projects.json @@ -740,10 +740,7 @@ "title": "プロパティ", "status": "ステータス", "dates": "日付", - "trackTime": "作業時間を記録", - "addTime": "時間を追加", "type": "タイプ", - "relationships": "関連付け", "assignees": "担当者", "importance": "重要度", "none": "なし", @@ -1017,6 +1014,11 @@ "statusesLabel": "ステータス", "assigneesLabel": "担当者", "taskTypesLabel": "タスクタイプ", + "startDateLabel": "開始日", + "dueDateLabel": "期日", + "importanceLabel": "重要度", + "storyPointsLabel": "ストーリーポイント", + "tagsLabel": "タグ", "reset": "リセット", "saving": "保存中…", "save": "保存", @@ -1040,6 +1042,20 @@ "allNormalTypesDynamic": "すべての通常タイプ(動的)", "systemTypes": "システムタイプ", "noTaskTypes": "タスクタイプはありません" + }, + "customFieldFilter": { + "allValues": "すべての値", + "noOptions": "オプションはありません", + "min": "最小", + "max": "最大", + "containsPlaceholder": "を含む…" + }, + "importanceFilter": { + "allLevels": "すべてのレベル" + }, + "tagsFilter": { + "addTag": "タグを追加", + "addTagPlaceholder": "タグを追加..." } }, "roadmap": { diff --git a/apps/web/src/i18n/locales/ko/projects.json b/apps/web/src/i18n/locales/ko/projects.json index 5cb8b1d0..2c3fb1fe 100644 --- a/apps/web/src/i18n/locales/ko/projects.json +++ b/apps/web/src/i18n/locales/ko/projects.json @@ -740,10 +740,7 @@ "title": "속성", "status": "상태", "dates": "날짜", - "trackTime": "시간 기록", - "addTime": "시간 추가", "type": "유형", - "relationships": "관계", "assignees": "담당자", "importance": "중요도", "none": "없음", @@ -1017,6 +1014,11 @@ "statusesLabel": "상태", "assigneesLabel": "담당자", "taskTypesLabel": "작업 유형", + "startDateLabel": "시작일", + "dueDateLabel": "마감일", + "importanceLabel": "중요도", + "storyPointsLabel": "스토리 포인트", + "tagsLabel": "태그", "reset": "초기화", "saving": "저장 중…", "save": "저장", @@ -1040,6 +1042,20 @@ "allNormalTypesDynamic": "모든 일반 유형(동적)", "systemTypes": "시스템 유형", "noTaskTypes": "작업 유형 없음" + }, + "customFieldFilter": { + "allValues": "모든 값", + "noOptions": "옵션 없음", + "min": "최소", + "max": "최대", + "containsPlaceholder": "포함…" + }, + "importanceFilter": { + "allLevels": "모든 수준" + }, + "tagsFilter": { + "addTag": "태그 추가", + "addTagPlaceholder": "태그 추가..." } }, "roadmap": { diff --git a/apps/web/src/i18n/locales/pt-BR/projects.json b/apps/web/src/i18n/locales/pt-BR/projects.json index 2c9f245d..ffcb86ca 100644 --- a/apps/web/src/i18n/locales/pt-BR/projects.json +++ b/apps/web/src/i18n/locales/pt-BR/projects.json @@ -740,10 +740,7 @@ "title": "Propriedades", "status": "Status", "dates": "Datas", - "trackTime": "Registrar tempo", - "addTime": "Adicionar tempo", "type": "Tipo", - "relationships": "Relacionamentos", "assignees": "Responsáveis", "importance": "Importância", "none": "Nenhum", @@ -1017,6 +1014,11 @@ "statusesLabel": "Status", "assigneesLabel": "Responsáveis", "taskTypesLabel": "Tipos de tarefa", + "startDateLabel": "Data de início", + "dueDateLabel": "Data de entrega", + "importanceLabel": "Importância", + "storyPointsLabel": "Story Points", + "tagsLabel": "Tags", "reset": "Redefinir", "saving": "Salvando…", "save": "Salvar", @@ -1040,6 +1042,20 @@ "allNormalTypesDynamic": "Todos os tipos normais (dinâmico)", "systemTypes": "Tipos de sistema", "noTaskTypes": "Nenhum tipo de tarefa" + }, + "customFieldFilter": { + "allValues": "Todos os valores", + "noOptions": "Sem opções", + "min": "Mín", + "max": "Máx", + "containsPlaceholder": "Contém…" + }, + "importanceFilter": { + "allLevels": "Todos os níveis" + }, + "tagsFilter": { + "addTag": "Adicionar etiqueta", + "addTagPlaceholder": "Adicionar etiqueta..." } }, "roadmap": { diff --git a/apps/web/src/i18n/locales/ru/projects.json b/apps/web/src/i18n/locales/ru/projects.json index 01e483eb..78718ad2 100644 --- a/apps/web/src/i18n/locales/ru/projects.json +++ b/apps/web/src/i18n/locales/ru/projects.json @@ -756,10 +756,7 @@ "title": "Свойства", "status": "Статус", "dates": "Даты", - "trackTime": "Учёт времени", - "addTime": "Добавить время", "type": "Тип", - "relationships": "Связи", "assignees": "Исполнители", "importance": "Важность", "none": "Нет", @@ -1035,6 +1032,11 @@ "statusesLabel": "Статусы", "assigneesLabel": "Исполнители", "taskTypesLabel": "Типы задач", + "startDateLabel": "Дата начала", + "dueDateLabel": "Срок", + "importanceLabel": "Важность", + "storyPointsLabel": "Story Points", + "tagsLabel": "Теги", "reset": "Сбросить", "saving": "Сохранение…", "save": "Сохранить", @@ -1058,6 +1060,20 @@ "allNormalTypesDynamic": "Все обычные типы (динамически)", "systemTypes": "Системные типы", "noTaskTypes": "Нет типов задач" + }, + "customFieldFilter": { + "allValues": "Все значения", + "noOptions": "Нет вариантов", + "min": "Мин", + "max": "Макс", + "containsPlaceholder": "Содержит…" + }, + "importanceFilter": { + "allLevels": "Все уровни" + }, + "tagsFilter": { + "addTag": "Добавить тег", + "addTagPlaceholder": "Добавить тег..." } }, "roadmap": { diff --git a/apps/web/src/i18n/locales/vi/projects.json b/apps/web/src/i18n/locales/vi/projects.json index bfefff30..401f3a34 100644 --- a/apps/web/src/i18n/locales/vi/projects.json +++ b/apps/web/src/i18n/locales/vi/projects.json @@ -740,10 +740,7 @@ "title": "Thuộc tính", "status": "Trạng thái", "dates": "Ngày tháng", - "trackTime": "Theo dõi thời gian", - "addTime": "Thêm thời gian", "type": "Loại", - "relationships": "Liên kết", "assignees": "Người được giao", "importance": "Mức độ quan trọng", "none": "Không có", @@ -1017,6 +1014,11 @@ "statusesLabel": "Trạng thái", "assigneesLabel": "Người được giao", "taskTypesLabel": "Loại nhiệm vụ", + "startDateLabel": "Ngày bắt đầu", + "dueDateLabel": "Ngày đến hạn", + "importanceLabel": "Mức độ quan trọng", + "storyPointsLabel": "Story Points", + "tagsLabel": "Thẻ", "reset": "Đặt lại", "saving": "Đang lưu…", "save": "Lưu", @@ -1040,6 +1042,20 @@ "allNormalTypesDynamic": "Tất cả loại thông thường (động)", "systemTypes": "Loại hệ thống", "noTaskTypes": "Không có loại nhiệm vụ" + }, + "customFieldFilter": { + "allValues": "Tất cả giá trị", + "noOptions": "Không có tùy chọn", + "min": "Tối thiểu", + "max": "Tối đa", + "containsPlaceholder": "Chứa…" + }, + "importanceFilter": { + "allLevels": "Tất cả mức độ" + }, + "tagsFilter": { + "addTag": "Thêm thẻ", + "addTagPlaceholder": "Thêm thẻ..." } }, "roadmap": { diff --git a/apps/web/src/i18n/locales/zh-CN/projects.json b/apps/web/src/i18n/locales/zh-CN/projects.json index 11f6b3f2..9af68010 100644 --- a/apps/web/src/i18n/locales/zh-CN/projects.json +++ b/apps/web/src/i18n/locales/zh-CN/projects.json @@ -740,10 +740,7 @@ "title": "属性", "status": "状态", "dates": "日期", - "trackTime": "记录时间", - "addTime": "添加时间", "type": "类型", - "relationships": "关联关系", "assignees": "负责人", "importance": "重要程度", "none": "无", @@ -1017,6 +1014,11 @@ "statusesLabel": "状态", "assigneesLabel": "负责人", "taskTypesLabel": "任务类型", + "startDateLabel": "开始日期", + "dueDateLabel": "截止日期", + "importanceLabel": "重要程度", + "storyPointsLabel": "故事点", + "tagsLabel": "标签", "reset": "重置", "saving": "正在保存…", "save": "保存", @@ -1040,6 +1042,20 @@ "allNormalTypesDynamic": "所有普通类型(动态)", "systemTypes": "系统类型", "noTaskTypes": "暂无任务类型" + }, + "customFieldFilter": { + "allValues": "所有值", + "noOptions": "无选项", + "min": "最小值", + "max": "最大值", + "containsPlaceholder": "包含…" + }, + "importanceFilter": { + "allLevels": "所有级别" + }, + "tagsFilter": { + "addTag": "添加标签", + "addTagPlaceholder": "添加标签..." } }, "roadmap": { diff --git a/apps/web/src/lib/interaction-api.test.ts b/apps/web/src/lib/interaction-api.test.ts index e56a852a..79ab2b06 100644 --- a/apps/web/src/lib/interaction-api.test.ts +++ b/apps/web/src/lib/interaction-api.test.ts @@ -296,6 +296,100 @@ describe("interaction-api", () => { ]; expect(config.params?.search).toBeUndefined(); }); + + it("serializes customFieldFilters as a single JSON query param", async () => { + mockGet.mockResolvedValue( + ok({ items: [], total: 0, page: 1, page_size: 200 }), + ); + + await listAllTasks(PROJECT_ID, { + customFieldFilters: { + priority: { values: ["High", "Urgent"] }, + effort: { min: 2, max: 8 }, + }, + }); + + const [, config] = mockGet.mock.calls[0] as [ + string, + { params: Record }, + ]; + expect(config.params?.custom_field_filters).toBe( + JSON.stringify({ + priority: { values: ["High", "Urgent"] }, + effort: { min: 2, max: 8 }, + }), + ); + }); + + it("omits custom_field_filters when customFieldFilters is empty or unset", async () => { + mockGet.mockResolvedValue( + ok({ items: [], total: 0, page: 1, page_size: 200 }), + ); + + await listAllTasks(PROJECT_ID, { customFieldFilters: {} }); + + const [, config] = mockGet.mock.calls[0] as [ + string, + { params: Record }, + ]; + expect(config.params?.custom_field_filters).toBeUndefined(); + }); + + it("serializes the built-in date/story-points/importance/tags filters", async () => { + mockGet.mockResolvedValue( + ok({ items: [], total: 0, page: 1, page_size: 200 }), + ); + + await listAllTasks(PROJECT_ID, { + startDateAfter: "2024-01-01", + startDateBefore: "2024-06-01", + dueDateAfter: "2024-02-01", + dueDateBefore: "2024-07-01", + storyPointsMin: 2, + storyPointsMax: 8, + importanceRanges: [ + { min: 1, max: 19 }, + { min: 100, max: 2147483647 }, + ], + tags: ["urgent", "needs review"], + }); + + const [, config] = mockGet.mock.calls[0] as [ + string, + { params: Record }, + ]; + expect(config.params?.start_date_after).toBe("2024-01-01"); + expect(config.params?.start_date_before).toBe("2024-06-01"); + expect(config.params?.due_date_after).toBe("2024-02-01"); + expect(config.params?.due_date_before).toBe("2024-07-01"); + expect(config.params?.story_points_min).toBe(2); + expect(config.params?.story_points_max).toBe(8); + expect(config.params?.importance_ranges).toBe( + JSON.stringify([ + { min: 1, max: 19 }, + { min: 100, max: 2147483647 }, + ]), + ); + expect(config.params?.tags).toBe("urgent,needs review"); + }); + + it("omits the built-in filter params when unset or empty", async () => { + mockGet.mockResolvedValue( + ok({ items: [], total: 0, page: 1, page_size: 200 }), + ); + + await listAllTasks(PROJECT_ID, { importanceRanges: [], tags: [] }); + + const [, config] = mockGet.mock.calls[0] as [ + string, + { params: Record }, + ]; + expect(config.params?.start_date_after).toBeUndefined(); + expect(config.params?.due_date_before).toBeUndefined(); + expect(config.params?.story_points_min).toBeUndefined(); + expect(config.params?.importance_ranges).toBeUndefined(); + expect(config.params?.tags).toBeUndefined(); + }); }); // ── layoutToViewType ───────────────────────────────────────────────────── diff --git a/apps/web/src/lib/interaction-api.ts b/apps/web/src/lib/interaction-api.ts index a67ab239..8c5b124c 100644 --- a/apps/web/src/lib/interaction-api.ts +++ b/apps/web/src/lib/interaction-api.ts @@ -93,6 +93,38 @@ export interface FilterConfig { items?: Record; } +/** + * A per-custom-field filter selector stored inside a view's filters, keyed by + * the target custom field's `field_key`. Which members are meaningful depends + * on the field's type (looked up from its `CustomFieldDefinition` at resolve + * time, not stored here): + * - `select` / `multi_select` / `boolean` → `selector` (matches ANY of the + * resolved option values, same recursive semantics as `FilterConfig`) + * - `number` → `min` / `max` (inclusive range; either may be unset) + * - `date` → `after` / `before` (inclusive range, "YYYY-MM-DD"; either may be unset) + * - `text` / `url` → `contains` (case-insensitive substring match) + */ +export interface CustomFieldFilterConfig { + selector?: FilterConfig; + min?: number; + max?: number; + after?: string; + before?: string; + contains?: string; +} + +/** An inclusive date range filter, "YYYY-MM-DD"; either bound may be unset. */ +export interface DateRangeFilter { + after?: string; + before?: string; +} + +/** An inclusive numeric range filter; either bound may be unset. */ +export interface NumberRangeFilter { + min?: number; + max?: number; +} + /** * Per-view filter configuration stored in the database. * Each dimension uses an optional FilterConfig selector; absent means no filter @@ -103,6 +135,34 @@ export interface ViewFilters { statuses?: FilterConfig; assignees?: FilterConfig; sprints?: FilterConfig; + custom_fields?: Record; + start_date?: DateRangeFilter; + due_date?: DateRangeFilter; + story_points?: NumberRangeFilter; + /** + * Selected priority bucket indices (0=None, 1=Low, 2=Medium, 3=High, + * 4=Critical — see priority.ts). Resolved into concrete importance ranges + * client-side (via getImportanceBucketBounds) before querying, so + * non-contiguous selections like "Low or Critical" work correctly. + */ + importance?: number[]; + /** Matches tasks that have ANY of these tag values. */ + tags?: string[]; +} + +/** + * The resolved (query-ready) shape of a single custom field filter, sent to + * the API as part of `custom_field_filters`. Unlike `CustomFieldFilterConfig`, + * `values` is already an explicit list — any `selector` FilterConfig has been + * resolved client-side against the field's option values. + */ +export interface CustomFieldFilterQuery { + values?: string[]; + min?: number; + max?: number; + after?: string; + before?: string; + contains?: string; } export interface ViewConfig { @@ -427,6 +487,21 @@ export interface ListTasksOptions { viewId?: string; /** Free-text search matched server-side against title and "#". */ search?: string; + /** Resolved per-custom-field filter criteria, keyed by field key. Sent as a single JSON query param since fields are dynamic per project. */ + customFieldFilters?: Record; + /** Inclusive start_date range, "YYYY-MM-DD". */ + startDateAfter?: string; + startDateBefore?: string; + /** Inclusive due_date range, "YYYY-MM-DD". */ + dueDateAfter?: string; + dueDateBefore?: string; + /** Inclusive story_points range. */ + storyPointsMin?: number; + storyPointsMax?: number; + /** Inclusive importance ranges (OR'd together), resolved from selected priority buckets. */ + importanceRanges?: { min: number; max: number }[]; + /** Matches tasks with ANY of these tag values. */ + tags?: string[]; } function buildTaskQueryParams(opts: ListTasksOptions = {}) { @@ -465,6 +540,24 @@ function buildTaskQueryParams(opts: ListTasksOptions = {}) { params.view_id = opts.viewId; } if (opts.search?.trim()) params.search = opts.search.trim(); + if ( + opts.customFieldFilters && + Object.keys(opts.customFieldFilters).length > 0 + ) { + params.custom_field_filters = JSON.stringify(opts.customFieldFilters); + } + if (opts.startDateAfter) params.start_date_after = opts.startDateAfter; + if (opts.startDateBefore) params.start_date_before = opts.startDateBefore; + if (opts.dueDateAfter) params.due_date_after = opts.dueDateAfter; + if (opts.dueDateBefore) params.due_date_before = opts.dueDateBefore; + if (opts.storyPointsMin != null) + params.story_points_min = opts.storyPointsMin; + if (opts.storyPointsMax != null) + params.story_points_max = opts.storyPointsMax; + if (opts.importanceRanges && opts.importanceRanges.length > 0) { + params.importance_ranges = JSON.stringify(opts.importanceRanges); + } + if (opts.tags && opts.tags.length > 0) params.tags = opts.tags.join(","); return params; } diff --git a/services/api/internal/domain/sprint/entity.go b/services/api/internal/domain/sprint/entity.go index 7b636aa4..eea62528 100644 --- a/services/api/internal/domain/sprint/entity.go +++ b/services/api/internal/domain/sprint/entity.go @@ -150,14 +150,33 @@ type FilterConfig struct { Items map[string]FilterEntry `json:"items,omitempty"` } +// CustomFieldFilter is a per-custom-field filter selector stored inside a +// view's ViewFilters, keyed by the target custom field's FieldKey. Which +// members are meaningful depends on the field's type (resolved against the +// project's CustomFieldDefinition at read time, not stored here): +// - select / multi_select / boolean → Selector (matches ANY of the +// resolved option values, same recursive semantics as FilterConfig) +// - number → Min / Max (inclusive range; either may be nil) +// - date → After / Before (inclusive range, "YYYY-MM-DD"; either may be nil) +// - text / url → Contains (case-insensitive substring match) +type CustomFieldFilter struct { + Selector *FilterConfig `json:"selector,omitempty"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + After *string `json:"after,omitempty"` + Before *string `json:"before,omitempty"` + Contains *string `json:"contains,omitempty"` +} + // ViewFilters holds the saved per-view filter configuration. // Each dimension is an optional FilterConfig selector. A nil dimension means // no filter is applied for that dimension (i.e. include everything). type ViewFilters struct { - TaskTypes *FilterConfig `json:"task_types,omitempty"` - Statuses *FilterConfig `json:"statuses,omitempty"` - Assignees *FilterConfig `json:"assignees,omitempty"` - Sprints *FilterConfig `json:"sprints,omitempty"` + TaskTypes *FilterConfig `json:"task_types,omitempty"` + Statuses *FilterConfig `json:"statuses,omitempty"` + Assignees *FilterConfig `json:"assignees,omitempty"` + Sprints *FilterConfig `json:"sprints,omitempty"` + CustomFields map[string]*CustomFieldFilter `json:"custom_fields,omitempty"` } // ViewConfig holds the display settings for a sprint view. diff --git a/services/api/internal/domain/task/repository.go b/services/api/internal/domain/task/repository.go index d20ab0c6..24dd4ce1 100644 --- a/services/api/internal/domain/task/repository.go +++ b/services/api/internal/domain/task/repository.go @@ -112,6 +112,52 @@ type TaskFilter struct { // Search, when non-nil and non-blank, restricts results to tasks whose title // or "#" id contains the text (case-insensitive). Search *string + // CustomFieldFilters filters tasks by custom field values, keyed by field key. + CustomFieldFilters map[string]CustomFieldFilterQuery + // StartDateAfter/StartDateBefore filter tasks by start_date (inclusive, + // "YYYY-MM-DD"). Either may be nil. + StartDateAfter *string + StartDateBefore *string + // DueDateAfter/DueDateBefore filter tasks by due_date (inclusive, + // "YYYY-MM-DD"). Either may be nil. + DueDateAfter *string + DueDateBefore *string + // StoryPointsMin/StoryPointsMax filter tasks by story_points (inclusive). + // Either may be nil. Tasks with no story_points never match when either is set. + StoryPointsMin *int + StoryPointsMax *int + // ImportanceRanges filters tasks whose importance falls within ANY of these + // inclusive ranges (OR'd together, so non-contiguous selections like + // "Low or Critical" are representable). Empty means no filter. + ImportanceRanges []IntRange + // Tags filters tasks that have ANY of these tag values (exact match). + Tags []string +} + +// IntRange is an inclusive [Min, Max] integer range. +type IntRange struct { + Min int + Max int +} + +// CustomFieldFilterQuery carries resolved filter criteria for one custom +// field, keyed by field key in TaskFilter.CustomFieldFilters. FieldType is +// resolved server-side from CustomFieldDefinition (never trusted from the +// client, since it determines how the value is cast in SQL) and determines +// which of the other members are meaningful: +// - "select" / "boolean" → Values (exact-match, ANY-of) +// - "multi_select" → Values (array-containment, ANY-of) +// - "number" → Min / Max (inclusive range) +// - "date" → After / Before (inclusive range, "YYYY-MM-DD") +// - "text" / "url" → Contains (case-insensitive substring match) +type CustomFieldFilterQuery struct { + FieldType string + Values []string + Min *float64 + Max *float64 + After *string + Before *string + Contains *string } // CustomFieldDefinitionRepository defines persistence operations for custom diff --git a/services/api/internal/repository/postgres/task_repository.go b/services/api/internal/repository/postgres/task_repository.go index 9d629b39..dd61606f 100644 --- a/services/api/internal/repository/postgres/task_repository.go +++ b/services/api/internal/repository/postgres/task_repository.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "sort" "strings" "time" @@ -497,6 +498,173 @@ func applyTaskFilter(b *queryBuilder, filter taskdom.TaskFilter) { "(title ILIKE %s OR ('#' || task_number::text) ILIKE %s)", p1, p2)) } } + + if filter.StartDateAfter != nil { + p := b.placeholder() + b.whereClauses = append(b.whereClauses, "start_date >= "+p) + b.args = append(b.args, *filter.StartDateAfter) + } + if filter.StartDateBefore != nil { + p := b.placeholder() + b.whereClauses = append(b.whereClauses, "start_date <= "+p) + b.args = append(b.args, *filter.StartDateBefore) + } + if filter.DueDateAfter != nil { + p := b.placeholder() + b.whereClauses = append(b.whereClauses, "due_date >= "+p) + b.args = append(b.args, *filter.DueDateAfter) + } + if filter.DueDateBefore != nil { + p := b.placeholder() + b.whereClauses = append(b.whereClauses, "due_date <= "+p) + b.args = append(b.args, *filter.DueDateBefore) + } + if filter.StoryPointsMin != nil { + p := b.placeholder() + b.whereClauses = append(b.whereClauses, "story_points >= "+p) + b.args = append(b.args, *filter.StoryPointsMin) + } + if filter.StoryPointsMax != nil { + p := b.placeholder() + b.whereClauses = append(b.whereClauses, "story_points <= "+p) + b.args = append(b.args, *filter.StoryPointsMax) + } + if len(filter.ImportanceRanges) > 0 { + parts := make([]string, len(filter.ImportanceRanges)) + for i, rg := range filter.ImportanceRanges { + minP := b.placeholder() + b.args = append(b.args, rg.Min) + maxP := b.placeholder() + b.args = append(b.args, rg.Max) + parts[i] = "(importance BETWEEN " + minP + " AND " + maxP + ")" + } + b.whereClauses = append(b.whereClauses, "("+strings.Join(parts, " OR ")+")") + } + if len(filter.Tags) > 0 { + parts := make([]string, len(filter.Tags)) + for i, tag := range filter.Tags { + p := b.placeholder() + b.args = append(b.args, tag) + parts[i] = "tags @> to_jsonb(" + p + "::text)" + } + b.whereClauses = append(b.whereClauses, "("+strings.Join(parts, " OR ")+")") + } + + applyCustomFieldFilters(b, filter.CustomFieldFilters) +} + +// applyCustomFieldFilters adds WHERE predicates for TaskFilter.CustomFieldFilters, +// reading and casting the custom_fields JSONB column per field type. Field +// keys are bound as parameters (never string-concatenated) and iterated in +// sorted order so the generated SQL text is deterministic across calls with +// the same filter set, which helps the driver's prepared-statement cache. +func applyCustomFieldFilters(b *queryBuilder, filters map[string]taskdom.CustomFieldFilterQuery) { + if len(filters) == 0 { + return + } + keys := make([]string, 0, len(filters)) + for k := range filters { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, key := range keys { + q := filters[key] + switch q.FieldType { + case "select", "boolean": + if len(q.Values) == 0 { + continue + } + keyP := b.placeholder() + b.args = append(b.args, key) + placeholders := make([]string, len(q.Values)) + for i, v := range q.Values { + p := b.placeholder() + b.args = append(b.args, v) + placeholders[i] = p + } + b.whereClauses = append(b.whereClauses, + "(custom_fields->>"+keyP+") IN ("+strings.Join(placeholders, ",")+")") + case "multi_select": + if len(q.Values) == 0 { + continue + } + keyP := b.placeholder() + b.args = append(b.args, key) + parts := make([]string, len(q.Values)) + for i, v := range q.Values { + p := b.placeholder() + b.args = append(b.args, v) + parts[i] = "custom_fields->" + keyP + " @> to_jsonb(" + p + "::text)" + } + b.whereClauses = append(b.whereClauses, "("+strings.Join(parts, " OR ")+")") + case "number": + if q.Min == nil && q.Max == nil { + continue + } + keyP := b.placeholder() + b.args = append(b.args, key) + expr := customFieldNumericExpr(keyP) + if q.Min != nil { + p := b.placeholder() + b.args = append(b.args, *q.Min) + b.whereClauses = append(b.whereClauses, expr+" >= "+p) + } + if q.Max != nil { + p := b.placeholder() + b.args = append(b.args, *q.Max) + b.whereClauses = append(b.whereClauses, expr+" <= "+p) + } + case "date": + if q.After == nil && q.Before == nil { + continue + } + keyP := b.placeholder() + b.args = append(b.args, key) + expr := customFieldDateExpr(keyP) + if q.After != nil { + p := b.placeholder() + b.args = append(b.args, *q.After) + b.whereClauses = append(b.whereClauses, expr+" >= "+p) + } + if q.Before != nil { + p := b.placeholder() + b.args = append(b.args, *q.Before) + b.whereClauses = append(b.whereClauses, expr+" <= "+p) + } + case "text", "url": + if q.Contains == nil || strings.TrimSpace(*q.Contains) == "" { + continue + } + keyP := b.placeholder() + b.args = append(b.args, key) + pattern := "%" + escapeLikePattern(*q.Contains) + "%" + p := b.placeholder() + b.args = append(b.args, pattern) + b.whereClauses = append(b.whereClauses, + "(custom_fields->>"+keyP+") ILIKE "+p) + } + } +} + +// customFieldNumericExpr returns a SQL expression that reads the +// custom_fields JSONB text value under keyP (a bound $N placeholder holding +// the field key) and safely casts it to numeric, yielding NULL instead of +// erroring the whole query when the stored value isn't a valid number. +// custom_fields has no write-time validation against the field's declared +// type (any JSON value is accepted on task create/update), and a field +// definition can be deleted and recreated with the same field_key but a +// different type — either way, a task can end up with a stray/invalid value +// under a "numeric" field's key. A bare ::numeric cast on such a row aborts +// the entire query with a Postgres error instead of just excluding that row, +// so the cast is guarded by a regex check first. +func customFieldNumericExpr(keyP string) string { + return "(CASE WHEN custom_fields->>" + keyP + " ~ '^-?[0-9]+(\\.[0-9]+)?$' THEN (custom_fields->>" + keyP + ")::numeric END)" +} + +// customFieldDateExpr is the date-typed counterpart of customFieldNumericExpr. +func customFieldDateExpr(keyP string) string { + return "(CASE WHEN custom_fields->>" + keyP + " ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}' THEN (custom_fields->>" + keyP + ")::date END)" } // escapeLikePattern escapes the LIKE/ILIKE wildcard characters (% and _) and @@ -549,11 +717,11 @@ func buildCFOrderBy(sort taskdom.TaskSort, b *queryBuilder) string { case "number": p := b.placeholder() b.args = append(b.args, sort.By) - return fmt.Sprintf("(custom_fields->>%s)::numeric ASC NULLS LAST, created_at ASC, id ASC", p) + return customFieldNumericExpr(p) + " ASC NULLS LAST, created_at ASC, id ASC" case "date": p := b.placeholder() b.args = append(b.args, sort.By) - return fmt.Sprintf("(custom_fields->>%s)::date ASC NULLS LAST, created_at ASC, id ASC", p) + return customFieldDateExpr(p) + " ASC NULLS LAST, created_at ASC, id ASC" case "select": if len(sort.CFOpts) == 0 { return "created_at ASC, id ASC" @@ -719,8 +887,8 @@ func applyCFCursorWhere(b *queryBuilder, cur *taskdom.TaskCursor, sort taskdom.T keyP3 := b.placeholder() b.args = append(b.args, sort.By) b.whereClauses = append(b.whereClauses, fmt.Sprintf( - "((custom_fields->>%s)::numeric > %s OR ((custom_fields->>%s)::numeric = %s AND (created_at, id) > (%s, %s)) OR custom_fields->>%s IS NULL)", - keyP, vP, keyP2, vP2, caP, idP, keyP3)) + "(%s > %s OR (%s = %s AND (created_at, id) > (%s, %s)) OR custom_fields->>%s IS NULL)", + customFieldNumericExpr(keyP), vP, customFieldNumericExpr(keyP2), vP2, caP, idP, keyP3)) } else { keyP := b.placeholder() b.args = append(b.args, sort.By) @@ -748,8 +916,8 @@ func applyCFCursorWhere(b *queryBuilder, cur *taskdom.TaskCursor, sort taskdom.T keyP3 := b.placeholder() b.args = append(b.args, sort.By) b.whereClauses = append(b.whereClauses, fmt.Sprintf( - "((custom_fields->>%s)::date > %s::date OR ((custom_fields->>%s)::date = %s::date AND (created_at, id) > (%s, %s)) OR custom_fields->>%s IS NULL)", - keyP, dP, keyP2, dP2, caP, idP, keyP3)) + "(%s > %s::date OR (%s = %s::date AND (created_at, id) > (%s, %s)) OR custom_fields->>%s IS NULL)", + customFieldDateExpr(keyP), dP, customFieldDateExpr(keyP2), dP2, caP, idP, keyP3)) } else { keyP := b.placeholder() b.args = append(b.args, sort.By) @@ -918,7 +1086,7 @@ func (r *TaskRepository) SumTaskField(ctx context.Context, projectID uuid.UUID, } else { keyP := b.placeholder() b.args = append(b.args, fieldKey) - err = r.db.GetContext(ctx, &sum, `SELECT COALESCE(SUM((custom_fields->>`+keyP+`)::numeric), 0) FROM tasks WHERE `+whereSQL, b.args...) + err = r.db.GetContext(ctx, &sum, `SELECT COALESCE(SUM(`+customFieldNumericExpr(keyP)+`), 0) FROM tasks WHERE `+whereSQL, b.args...) } if err != nil { return 0, fmt.Errorf("task repo: sum field %q: %w", fieldKey, err) @@ -1376,13 +1544,37 @@ func (r *TaskRepository) UpdateCustomFieldDefinition(ctx context.Context, f *tas return nil } -// DeleteCustomFieldDefinition removes a custom field definition by ID. +// DeleteCustomFieldDefinition removes a custom field definition by ID and +// purges its value from every task in the project's custom_fields JSONB +// column. Without this, a deleted field's stray/stale values remain attached +// to tasks forever; if a new field is later created reusing the same +// field_key (e.g. because the display name is unchanged), it silently +// inherits data from the old, possibly incompatible, field type. func (r *TaskRepository) DeleteCustomFieldDefinition(ctx context.Context, id uuid.UUID) error { - _, err := r.db.ExecContext(ctx, `DELETE FROM custom_field_definitions WHERE id = $1`, id.String()) - if err != nil { - return fmt.Errorf("custom field repo: delete: %w", err) - } - return nil + return WithTx(ctx, r.db, func(tx *sqlx.Tx) error { + var deleted struct { + ProjectID string `db:"project_id"` + FieldKey string `db:"field_key"` + } + err := tx.GetContext(ctx, &deleted, + `DELETE FROM custom_field_definitions WHERE id = $1 RETURNING project_id, field_key`, + id.String(), + ) + if errors.Is(err, sql.ErrNoRows) { + return taskdom.ErrCustomFieldNotFound + } + if err != nil { + return fmt.Errorf("custom field repo: delete: %w", err) + } + + if _, err := tx.ExecContext(ctx, + `UPDATE tasks SET custom_fields = custom_fields - $1 WHERE project_id = $2`, + deleted.FieldKey, deleted.ProjectID, + ); err != nil { + return fmt.Errorf("custom field repo: delete: purge task values: %w", err) + } + return nil + }) } func toCustomFieldEntity(r *customFieldDefinitionRecord) (*taskdom.CustomFieldDefinition, error) { diff --git a/services/api/internal/repository/postgres/task_repository_test.go b/services/api/internal/repository/postgres/task_repository_test.go index 37300106..bc69f3fc 100644 --- a/services/api/internal/repository/postgres/task_repository_test.go +++ b/services/api/internal/repository/postgres/task_repository_test.go @@ -2,6 +2,8 @@ package postgres import ( "context" + "reflect" + "strings" "testing" taskdom "github.com/Paca-AI/api/internal/domain/task" @@ -162,3 +164,297 @@ func TestSyncTaskAssignees_PreservesUnchangedRows(t *testing.T) { t.Fatalf("expected memberC to be newly inserted, got %+v", got) } } + +// --------------------------------------------------------------------------- +// applyCustomFieldFilters +// --------------------------------------------------------------------------- + +func f64Ptr(v float64) *float64 { return &v } +func strPtr(v string) *string { return &v } + +func TestApplyCustomFieldFilters_Select(t *testing.T) { + b := newQueryBuilder() + applyCustomFieldFilters(b, map[string]taskdom.CustomFieldFilterQuery{ + "priority": {FieldType: "select", Values: []string{"High", "Urgent"}}, + }) + wantClause := "(custom_fields->>$1) IN ($2,$3)" + if len(b.whereClauses) != 1 || b.whereClauses[0] != wantClause { + t.Fatalf("whereClauses = %+v, want [%q]", b.whereClauses, wantClause) + } + wantArgs := []interface{}{"priority", "High", "Urgent"} + if !reflect.DeepEqual(b.args, wantArgs) { + t.Fatalf("args = %+v, want %+v", b.args, wantArgs) + } +} + +func TestApplyCustomFieldFilters_Boolean(t *testing.T) { + b := newQueryBuilder() + applyCustomFieldFilters(b, map[string]taskdom.CustomFieldFilterQuery{ + "is_blocked": {FieldType: "boolean", Values: []string{"true"}}, + }) + wantClause := "(custom_fields->>$1) IN ($2)" + if len(b.whereClauses) != 1 || b.whereClauses[0] != wantClause { + t.Fatalf("whereClauses = %+v, want [%q]", b.whereClauses, wantClause) + } + wantArgs := []interface{}{"is_blocked", "true"} + if !reflect.DeepEqual(b.args, wantArgs) { + t.Fatalf("args = %+v, want %+v", b.args, wantArgs) + } +} + +func TestApplyCustomFieldFilters_MultiSelect(t *testing.T) { + b := newQueryBuilder() + applyCustomFieldFilters(b, map[string]taskdom.CustomFieldFilterQuery{ + "labels": {FieldType: "multi_select", Values: []string{"backend", "urgent"}}, + }) + wantClause := "(custom_fields->$1 @> to_jsonb($2::text) OR custom_fields->$1 @> to_jsonb($3::text))" + if len(b.whereClauses) != 1 || b.whereClauses[0] != wantClause { + t.Fatalf("whereClauses = %+v, want [%q]", b.whereClauses, wantClause) + } + wantArgs := []interface{}{"labels", "backend", "urgent"} + if !reflect.DeepEqual(b.args, wantArgs) { + t.Fatalf("args = %+v, want %+v", b.args, wantArgs) + } +} + +func TestApplyCustomFieldFilters_NumberRange(t *testing.T) { + b := newQueryBuilder() + applyCustomFieldFilters(b, map[string]taskdom.CustomFieldFilterQuery{ + "effort": {FieldType: "number", Min: f64Ptr(2), Max: f64Ptr(8)}, + }) + numExpr := customFieldNumericExpr("$1") + wantClauses := []string{ + numExpr + " >= $2", + numExpr + " <= $3", + } + if !reflect.DeepEqual(b.whereClauses, wantClauses) { + t.Fatalf("whereClauses = %+v, want %+v", b.whereClauses, wantClauses) + } + wantArgs := []interface{}{"effort", 2.0, 8.0} + if !reflect.DeepEqual(b.args, wantArgs) { + t.Fatalf("args = %+v, want %+v", b.args, wantArgs) + } +} + +func TestApplyCustomFieldFilters_NumberMinOnly(t *testing.T) { + b := newQueryBuilder() + applyCustomFieldFilters(b, map[string]taskdom.CustomFieldFilterQuery{ + "effort": {FieldType: "number", Min: f64Ptr(5)}, + }) + wantClauses := []string{customFieldNumericExpr("$1") + " >= $2"} + if !reflect.DeepEqual(b.whereClauses, wantClauses) { + t.Fatalf("whereClauses = %+v, want %+v", b.whereClauses, wantClauses) + } +} + +func TestApplyCustomFieldFilters_DateRange(t *testing.T) { + b := newQueryBuilder() + applyCustomFieldFilters(b, map[string]taskdom.CustomFieldFilterQuery{ + "due": {FieldType: "date", After: strPtr("2024-01-01"), Before: strPtr("2024-06-01")}, + }) + dateExpr := customFieldDateExpr("$1") + wantClauses := []string{ + dateExpr + " >= $2", + dateExpr + " <= $3", + } + if !reflect.DeepEqual(b.whereClauses, wantClauses) { + t.Fatalf("whereClauses = %+v, want %+v", b.whereClauses, wantClauses) + } + wantArgs := []interface{}{"due", "2024-01-01", "2024-06-01"} + if !reflect.DeepEqual(b.args, wantArgs) { + t.Fatalf("args = %+v, want %+v", b.args, wantArgs) + } +} + +// TestApplyCustomFieldFilters_InvalidStoredValueDoesNotCrashQuery is a +// regression test: a task can end up with a stray non-numeric/non-date +// string under a field key (e.g. a select-type field was deleted and a new +// date-type field was created reusing the same field_key before the old +// value was purged). A bare ::numeric/::date cast on such a row aborts the +// entire query with a Postgres error instead of just excluding that row, so +// the generated expression must guard the cast with a regex check. This +// asserts the guard is present in the generated SQL text; the regex/cast +// semantics themselves are Postgres-only and can't run against the SQLite +// harness used elsewhere in this file. +func TestApplyCustomFieldFilters_InvalidStoredValueDoesNotCrashQuery(t *testing.T) { + b := newQueryBuilder() + applyCustomFieldFilters(b, map[string]taskdom.CustomFieldFilterQuery{ + "due": {FieldType: "date", After: strPtr("2024-01-01")}, + }) + if len(b.whereClauses) != 1 { + t.Fatalf("expected 1 where clause, got %+v", b.whereClauses) + } + clause := b.whereClauses[0] + if !strings.Contains(clause, "CASE WHEN") || !strings.Contains(clause, "~") { + t.Fatalf("expected a regex-guarded CASE expression, got %q", clause) + } +} + +func TestApplyCustomFieldFilters_TextContains(t *testing.T) { + b := newQueryBuilder() + applyCustomFieldFilters(b, map[string]taskdom.CustomFieldFilterQuery{ + "notes": {FieldType: "text", Contains: strPtr("100% done_now")}, + }) + wantClause := "(custom_fields->>$1) ILIKE $2" + if len(b.whereClauses) != 1 || b.whereClauses[0] != wantClause { + t.Fatalf("whereClauses = %+v, want [%q]", b.whereClauses, wantClause) + } + wantArgs := []interface{}{"notes", "%100\\% done\\_now%"} + if !reflect.DeepEqual(b.args, wantArgs) { + t.Fatalf("args = %+v, want %+v", b.args, wantArgs) + } +} + +func TestApplyCustomFieldFilters_UrlContains(t *testing.T) { + b := newQueryBuilder() + applyCustomFieldFilters(b, map[string]taskdom.CustomFieldFilterQuery{ + "link": {FieldType: "url", Contains: strPtr("example.com")}, + }) + if len(b.whereClauses) != 1 { + t.Fatalf("expected 1 where clause, got %+v", b.whereClauses) + } +} + +func TestApplyCustomFieldFilters_EmptyOrBlankValuesSkipped(t *testing.T) { + blank := " " + b := newQueryBuilder() + applyCustomFieldFilters(b, map[string]taskdom.CustomFieldFilterQuery{ + "empty_select": {FieldType: "select"}, + "empty_multi": {FieldType: "multi_select"}, + "empty_number": {FieldType: "number"}, + "empty_date": {FieldType: "date"}, + "blank_text": {FieldType: "text", Contains: &blank}, + }) + if len(b.whereClauses) != 0 || len(b.args) != 0 { + t.Fatalf("expected no-op for empty filter criteria, got clauses=%+v args=%+v", b.whereClauses, b.args) + } +} + +func TestApplyCustomFieldFilters_MultipleFieldsAreSortedForDeterminism(t *testing.T) { + b := newQueryBuilder() + applyCustomFieldFilters(b, map[string]taskdom.CustomFieldFilterQuery{ + "zeta": {FieldType: "text", Contains: strPtr("z")}, + "alpha": {FieldType: "text", Contains: strPtr("a")}, + }) + wantArgs := []interface{}{"alpha", "%a%", "zeta", "%z%"} + if !reflect.DeepEqual(b.args, wantArgs) { + t.Fatalf("args = %+v, want %+v (alpha before zeta)", b.args, wantArgs) + } +} + +func TestApplyCustomFieldFilters_ComposesWithBaseFilterClauses(t *testing.T) { + b := newQueryBuilder() + pidP := b.placeholder() + b.args = append(b.args, "proj-1") + b.whereClauses = append(b.whereClauses, "project_id = "+pidP) + applyTaskFilter(b, taskdom.TaskFilter{ + CustomFieldFilters: map[string]taskdom.CustomFieldFilterQuery{ + "priority": {FieldType: "select", Values: []string{"High"}}, + }, + }) + wantClauses := []string{ + "project_id = $1", + "(custom_fields->>$2) IN ($3)", + } + if !reflect.DeepEqual(b.whereClauses, wantClauses) { + t.Fatalf("whereClauses = %+v, want %+v", b.whereClauses, wantClauses) + } +} + +// --------------------------------------------------------------------------- +// Built-in field filters: start_date, due_date, story_points, importance, tags +// --------------------------------------------------------------------------- + +func intPtr(v int) *int { return &v } + +func TestApplyTaskFilter_DateRanges(t *testing.T) { + b := newQueryBuilder() + applyTaskFilter(b, taskdom.TaskFilter{ + StartDateAfter: strPtr("2024-01-01"), + StartDateBefore: strPtr("2024-06-01"), + DueDateAfter: strPtr("2024-02-01"), + DueDateBefore: strPtr("2024-07-01"), + }) + wantClauses := []string{ + "start_date >= $1", + "start_date <= $2", + "due_date >= $3", + "due_date <= $4", + } + if !reflect.DeepEqual(b.whereClauses, wantClauses) { + t.Fatalf("whereClauses = %+v, want %+v", b.whereClauses, wantClauses) + } + wantArgs := []interface{}{"2024-01-01", "2024-06-01", "2024-02-01", "2024-07-01"} + if !reflect.DeepEqual(b.args, wantArgs) { + t.Fatalf("args = %+v, want %+v", b.args, wantArgs) + } +} + +func TestApplyTaskFilter_DateRanges_OnlyOneBoundSet(t *testing.T) { + b := newQueryBuilder() + applyTaskFilter(b, taskdom.TaskFilter{DueDateBefore: strPtr("2024-12-31")}) + wantClauses := []string{"due_date <= $1"} + if !reflect.DeepEqual(b.whereClauses, wantClauses) { + t.Fatalf("whereClauses = %+v, want %+v", b.whereClauses, wantClauses) + } +} + +func TestApplyTaskFilter_StoryPointsRange(t *testing.T) { + b := newQueryBuilder() + applyTaskFilter(b, taskdom.TaskFilter{ + StoryPointsMin: intPtr(2), + StoryPointsMax: intPtr(8), + }) + wantClauses := []string{ + "story_points >= $1", + "story_points <= $2", + } + if !reflect.DeepEqual(b.whereClauses, wantClauses) { + t.Fatalf("whereClauses = %+v, want %+v", b.whereClauses, wantClauses) + } + wantArgs := []interface{}{2, 8} + if !reflect.DeepEqual(b.args, wantArgs) { + t.Fatalf("args = %+v, want %+v", b.args, wantArgs) + } +} + +func TestApplyTaskFilter_ImportanceRanges_ORsMultipleRanges(t *testing.T) { + b := newQueryBuilder() + applyTaskFilter(b, taskdom.TaskFilter{ + // Non-contiguous selection (e.g. "Low" and "Critical" but not + // "Medium"/"High") must be representable as independent ranges. + ImportanceRanges: []taskdom.IntRange{ + {Min: 1, Max: 19}, + {Min: 100, Max: 2147483647}, + }, + }) + wantClause := "((importance BETWEEN $1 AND $2) OR (importance BETWEEN $3 AND $4))" + if len(b.whereClauses) != 1 || b.whereClauses[0] != wantClause { + t.Fatalf("whereClauses = %+v, want [%q]", b.whereClauses, wantClause) + } + wantArgs := []interface{}{1, 19, 100, 2147483647} + if !reflect.DeepEqual(b.args, wantArgs) { + t.Fatalf("args = %+v, want %+v", b.args, wantArgs) + } +} + +func TestApplyTaskFilter_ImportanceRanges_EmptyIsNoop(t *testing.T) { + b := newQueryBuilder() + applyTaskFilter(b, taskdom.TaskFilter{}) + if len(b.whereClauses) != 0 { + t.Fatalf("expected no clauses, got %+v", b.whereClauses) + } +} + +func TestApplyTaskFilter_Tags_ORsAnyMatch(t *testing.T) { + b := newQueryBuilder() + applyTaskFilter(b, taskdom.TaskFilter{Tags: []string{"urgent", "bug"}}) + wantClause := "(tags @> to_jsonb($1::text) OR tags @> to_jsonb($2::text))" + if len(b.whereClauses) != 1 || b.whereClauses[0] != wantClause { + t.Fatalf("whereClauses = %+v, want [%q]", b.whereClauses, wantClause) + } + wantArgs := []interface{}{"urgent", "bug"} + if !reflect.DeepEqual(b.args, wantArgs) { + t.Fatalf("args = %+v, want %+v", b.args, wantArgs) + } +} diff --git a/services/api/internal/transport/http/handler/attachment_handler_test.go b/services/api/internal/transport/http/handler/attachment_handler_test.go index 8f5b37f1..d3b8be4b 100644 --- a/services/api/internal/transport/http/handler/attachment_handler_test.go +++ b/services/api/internal/transport/http/handler/attachment_handler_test.go @@ -46,16 +46,6 @@ var _ attachmentdom.Service = (*fakeAttachmentSvc)(nil) // Router helper // --------------------------------------------------------------------------- -func newAttachmentRouter() chi.Router { - h := handler.NewAttachmentHandler(&fakeAttachmentSvc{}) - r := chi.NewRouter() - r.Route("/projects/{projectId}/tasks/{taskId}/attachments", func(r chi.Router) { - r.Post("/initiate-upload", h.InitiateUpload) - r.Post("/complete-upload", h.CompleteUpload) - }) - return r -} - // injectAuthClaims injects JWT claims for the attachment handler tests // (InitiateUpload/CompleteUpload require claims.Subject for the uploader UUID). func injectAuthClaimsMiddleware(sub string) func(http.Handler) http.Handler { diff --git a/services/api/internal/transport/http/handler/task_handler.go b/services/api/internal/transport/http/handler/task_handler.go index 426cc2a9..4956caf4 100644 --- a/services/api/internal/transport/http/handler/task_handler.go +++ b/services/api/internal/transport/http/handler/task_handler.go @@ -357,6 +357,91 @@ func parseTaskSort(ctx context.Context, svc taskdom.Service, projectID uuid.UUID } } +// customFieldFilterParam is the wire shape of one entry in the +// custom_field_filters query parameter (a JSON object keyed by field key). +// Which members are meaningful is determined server-side from the field's +// CustomFieldDefinition — see parseCustomFieldFilters. +type customFieldFilterParam struct { + Values []string `json:"values,omitempty"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + After *string `json:"after,omitempty"` + Before *string `json:"before,omitempty"` + Contains *string `json:"contains,omitempty"` +} + +// parseCustomFieldFilters decodes the custom_field_filters query parameter +// (a JSON object keyed by custom field key) and resolves each entry's +// FieldType against the project's custom field definitions — the field type +// is never trusted from the client, since it determines how the value is +// cast in SQL. Unknown field keys are silently ignored (e.g. a stale saved +// view referencing a since-deleted custom field). +func parseCustomFieldFilters(ctx context.Context, svc taskdom.Service, projectID uuid.UUID, raw string) (map[string]taskdom.CustomFieldFilterQuery, error) { + var parsed map[string]customFieldFilterParam + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return nil, apierr.New(apierr.CodeBadRequest, "invalid custom_field_filters") + } + if len(parsed) == 0 { + return nil, nil + } + cfs, err := svc.ListCustomFieldDefinitions(ctx, projectID) + if err != nil { + return nil, err + } + cfByKey := make(map[string]*taskdom.CustomFieldDefinition, len(cfs)) + for _, cf := range cfs { + cfByKey[cf.FieldKey] = cf + } + resolved := make(map[string]taskdom.CustomFieldFilterQuery, len(parsed)) + for key, p := range parsed { + cf, ok := cfByKey[key] + if !ok { + continue + } + resolved[key] = taskdom.CustomFieldFilterQuery{ + FieldType: string(cf.FieldType), + Values: p.Values, + Min: p.Min, + Max: p.Max, + After: p.After, + Before: p.Before, + Contains: p.Contains, + } + } + return resolved, nil +} + +// isValidDateString reports whether s is a valid "YYYY-MM-DD" date, matching +// the format the start_date/due_date columns and their filter query params use. +func isValidDateString(s string) bool { + _, err := time.Parse("2006-01-02", s) + return err == nil +} + +// importanceRangeParam is the wire shape of one entry in the +// importance_ranges query parameter (a JSON array of inclusive [min,max] +// ranges, OR'd together — see taskdom.TaskFilter.ImportanceRanges). +type importanceRangeParam struct { + Min int `json:"min"` + Max int `json:"max"` +} + +// parseImportanceRanges decodes the importance_ranges query parameter. +func parseImportanceRanges(raw string) ([]taskdom.IntRange, error) { + var parsed []importanceRangeParam + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return nil, apierr.New(apierr.CodeBadRequest, "invalid importance_ranges") + } + ranges := make([]taskdom.IntRange, 0, len(parsed)) + for _, p := range parsed { + if p.Min > p.Max { + return nil, apierr.New(apierr.CodeBadRequest, "invalid importance_ranges: min > max") + } + ranges = append(ranges, taskdom.IntRange{Min: p.Min, Max: p.Max}) + } + return ranges, nil +} + // ListTasks handles GET /projects/:projectId/tasks. // Supported filter query params: // - sprint_id=|null or sprint_ids= @@ -365,6 +450,13 @@ func parseTaskSort(ctx context.Context, svc taskdom.Service, projectID uuid.UUID // - task_type_ids= // - parent_task_id= // - search= (matches title or "#", case-insensitive) +// - custom_field_filters= (object keyed by custom field key; see +// customFieldFilterParam for the shape of each entry) +// - start_date_after=, start_date_before= +// - due_date_after=, due_date_before= +// - story_points_min=, story_points_max= +// - importance_ranges= (array of {"min":int,"max":int}, OR'd together) +// - tags= (matches tasks with ANY of these tags) func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) { projectID, err := parseProjectID(r) if err != nil { @@ -459,6 +551,75 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) { if raw := strings.TrimSpace(r.URL.Query().Get("search")); raw != "" { filter.Search = &raw } + if raw := strings.TrimSpace(r.URL.Query().Get("custom_field_filters")); raw != "" { + cfFilters, err := parseCustomFieldFilters(r.Context(), h.svc, projectID, raw) + if err != nil { + presenter.Error(w, r, err) + return + } + filter.CustomFieldFilters = cfFilters + } + if raw := strings.TrimSpace(r.URL.Query().Get("start_date_after")); raw != "" { + if !isValidDateString(raw) { + presenter.Error(w, r, apierr.New(apierr.CodeBadRequest, "invalid start_date_after")) + return + } + filter.StartDateAfter = &raw + } + if raw := strings.TrimSpace(r.URL.Query().Get("start_date_before")); raw != "" { + if !isValidDateString(raw) { + presenter.Error(w, r, apierr.New(apierr.CodeBadRequest, "invalid start_date_before")) + return + } + filter.StartDateBefore = &raw + } + if raw := strings.TrimSpace(r.URL.Query().Get("due_date_after")); raw != "" { + if !isValidDateString(raw) { + presenter.Error(w, r, apierr.New(apierr.CodeBadRequest, "invalid due_date_after")) + return + } + filter.DueDateAfter = &raw + } + if raw := strings.TrimSpace(r.URL.Query().Get("due_date_before")); raw != "" { + if !isValidDateString(raw) { + presenter.Error(w, r, apierr.New(apierr.CodeBadRequest, "invalid due_date_before")) + return + } + filter.DueDateBefore = &raw + } + if raw := strings.TrimSpace(r.URL.Query().Get("story_points_min")); raw != "" { + v, err := strconv.Atoi(raw) + if err != nil { + presenter.Error(w, r, apierr.New(apierr.CodeBadRequest, "invalid story_points_min")) + return + } + filter.StoryPointsMin = &v + } + if raw := strings.TrimSpace(r.URL.Query().Get("story_points_max")); raw != "" { + v, err := strconv.Atoi(raw) + if err != nil { + presenter.Error(w, r, apierr.New(apierr.CodeBadRequest, "invalid story_points_max")) + return + } + filter.StoryPointsMax = &v + } + if raw := strings.TrimSpace(r.URL.Query().Get("importance_ranges")); raw != "" { + ranges, err := parseImportanceRanges(raw) + if err != nil { + presenter.Error(w, r, err) + return + } + filter.ImportanceRanges = ranges + } + if raw := strings.TrimSpace(r.URL.Query().Get("tags")); raw != "" { + var tags []string + for _, tag := range strings.Split(raw, ",") { + if tag = strings.TrimSpace(tag); tag != "" { + tags = append(tags, tag) + } + } + filter.Tags = tags + } if cursorRaw := r.URL.Query().Get("cursor"); cursorRaw != "" { filter.CursorAfter = &cursorRaw } diff --git a/services/api/internal/transport/http/handler/task_handler_test.go b/services/api/internal/transport/http/handler/task_handler_test.go index 26d6d369..12dfa46e 100644 --- a/services/api/internal/transport/http/handler/task_handler_test.go +++ b/services/api/internal/transport/http/handler/task_handler_test.go @@ -7,6 +7,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" + "reflect" "strings" "sync" "testing" @@ -31,6 +33,7 @@ type fakeTaskSvc struct { types map[uuid.UUID]*taskdom.TaskType lastProjectID uuid.UUID lastFilter taskdom.TaskFilter + customFields []*taskdom.CustomFieldDefinition } func newFakeTaskSvc() *fakeTaskSvc { @@ -235,7 +238,9 @@ func (f *fakeTaskSvc) DeleteTask(_ context.Context, _, id uuid.UUID) error { // -- CustomFieldDefinitionService -- func (f *fakeTaskSvc) ListCustomFieldDefinitions(_ context.Context, _ uuid.UUID) ([]*taskdom.CustomFieldDefinition, error) { - return nil, nil + f.mu.RLock() + defer f.mu.RUnlock() + return f.customFields, nil } func (f *fakeTaskSvc) GetCustomFieldDefinition(_ context.Context, _, _ uuid.UUID) (*taskdom.CustomFieldDefinition, error) { @@ -603,6 +608,215 @@ func TestTaskHandler_ListTasks_TaskTypeIDsDriveFiltering(t *testing.T) { } } +func TestTaskHandler_ListTasks_CustomFieldFilters_SelectResolvesFieldType(t *testing.T) { + svc := newFakeTaskSvc() + svc.customFields = []*taskdom.CustomFieldDefinition{ + {FieldKey: "priority", FieldType: taskdom.FieldTypeSelect, Options: []string{"Low", "High"}}, + } + r := buildTaskHandlerRouter(svc) + projectID := uuid.New() + + raw := `{"priority":{"values":["High"]}}` + path := fmt.Sprintf("/projects/%s/tasks?custom_field_filters=%s", projectID, url.QueryEscape(raw)) + w := doTaskRequest(r, http.MethodGet, path, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + got, ok := svc.lastFilter.CustomFieldFilters["priority"] + if !ok { + t.Fatalf("expected CustomFieldFilters to include %q, got %+v", "priority", svc.lastFilter.CustomFieldFilters) + } + want := taskdom.CustomFieldFilterQuery{FieldType: "select", Values: []string{"High"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("CustomFieldFilters[%q] = %+v, want %+v", "priority", got, want) + } +} + +func TestTaskHandler_ListTasks_CustomFieldFilters_NumberRange(t *testing.T) { + svc := newFakeTaskSvc() + svc.customFields = []*taskdom.CustomFieldDefinition{ + {FieldKey: "effort", FieldType: taskdom.FieldTypeNumber}, + } + r := buildTaskHandlerRouter(svc) + projectID := uuid.New() + + raw := `{"effort":{"min":2,"max":8}}` + path := fmt.Sprintf("/projects/%s/tasks?custom_field_filters=%s", projectID, url.QueryEscape(raw)) + w := doTaskRequest(r, http.MethodGet, path, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + got, ok := svc.lastFilter.CustomFieldFilters["effort"] + if !ok { + t.Fatalf("expected CustomFieldFilters to include %q, got %+v", "effort", svc.lastFilter.CustomFieldFilters) + } + if got.FieldType != "number" || got.Min == nil || *got.Min != 2 || got.Max == nil || *got.Max != 8 { + t.Fatalf("CustomFieldFilters[%q] = %+v, want FieldType=number Min=2 Max=8", "effort", got) + } +} + +func TestTaskHandler_ListTasks_CustomFieldFilters_UnknownFieldKeyIgnored(t *testing.T) { + svc := newFakeTaskSvc() + svc.customFields = []*taskdom.CustomFieldDefinition{ + {FieldKey: "priority", FieldType: taskdom.FieldTypeSelect, Options: []string{"Low", "High"}}, + } + r := buildTaskHandlerRouter(svc) + projectID := uuid.New() + + raw := `{"deleted_field":{"values":["x"]}}` + path := fmt.Sprintf("/projects/%s/tasks?custom_field_filters=%s", projectID, url.QueryEscape(raw)) + w := doTaskRequest(r, http.MethodGet, path, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if len(svc.lastFilter.CustomFieldFilters) != 0 { + t.Fatalf("expected stale field key to be ignored, got %+v", svc.lastFilter.CustomFieldFilters) + } +} + +func TestTaskHandler_ListTasks_CustomFieldFilters_InvalidJSONReturns400(t *testing.T) { + svc := newFakeTaskSvc() + r := buildTaskHandlerRouter(svc) + projectID := uuid.New() + + path := fmt.Sprintf("/projects/%s/tasks?custom_field_filters=%s", projectID, url.QueryEscape("not-json")) + w := doTaskRequest(r, http.MethodGet, path, nil) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestTaskHandler_ListTasks_DateRangeFilters(t *testing.T) { + svc := newFakeTaskSvc() + r := buildTaskHandlerRouter(svc) + projectID := uuid.New() + + path := fmt.Sprintf( + "/projects/%s/tasks?start_date_after=2024-01-01&start_date_before=2024-06-01&due_date_after=2024-02-01&due_date_before=2024-07-01", + projectID, + ) + w := doTaskRequest(r, http.MethodGet, path, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + f := svc.lastFilter + if f.StartDateAfter == nil || *f.StartDateAfter != "2024-01-01" { + t.Errorf("expected StartDateAfter=2024-01-01, got %+v", f.StartDateAfter) + } + if f.StartDateBefore == nil || *f.StartDateBefore != "2024-06-01" { + t.Errorf("expected StartDateBefore=2024-06-01, got %+v", f.StartDateBefore) + } + if f.DueDateAfter == nil || *f.DueDateAfter != "2024-02-01" { + t.Errorf("expected DueDateAfter=2024-02-01, got %+v", f.DueDateAfter) + } + if f.DueDateBefore == nil || *f.DueDateBefore != "2024-07-01" { + t.Errorf("expected DueDateBefore=2024-07-01, got %+v", f.DueDateBefore) + } +} + +func TestTaskHandler_ListTasks_InvalidDateFilterReturns400(t *testing.T) { + svc := newFakeTaskSvc() + r := buildTaskHandlerRouter(svc) + projectID := uuid.New() + + path := fmt.Sprintf("/projects/%s/tasks?due_date_after=not-a-date", projectID) + w := doTaskRequest(r, http.MethodGet, path, nil) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestTaskHandler_ListTasks_StoryPointsRangeFilter(t *testing.T) { + svc := newFakeTaskSvc() + r := buildTaskHandlerRouter(svc) + projectID := uuid.New() + + path := fmt.Sprintf("/projects/%s/tasks?story_points_min=2&story_points_max=8", projectID) + w := doTaskRequest(r, http.MethodGet, path, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + f := svc.lastFilter + if f.StoryPointsMin == nil || *f.StoryPointsMin != 2 { + t.Errorf("expected StoryPointsMin=2, got %+v", f.StoryPointsMin) + } + if f.StoryPointsMax == nil || *f.StoryPointsMax != 8 { + t.Errorf("expected StoryPointsMax=8, got %+v", f.StoryPointsMax) + } +} + +func TestTaskHandler_ListTasks_InvalidStoryPointsFilterReturns400(t *testing.T) { + svc := newFakeTaskSvc() + r := buildTaskHandlerRouter(svc) + projectID := uuid.New() + + path := fmt.Sprintf("/projects/%s/tasks?story_points_min=abc", projectID) + w := doTaskRequest(r, http.MethodGet, path, nil) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestTaskHandler_ListTasks_ImportanceRangesFilter(t *testing.T) { + svc := newFakeTaskSvc() + r := buildTaskHandlerRouter(svc) + projectID := uuid.New() + + raw := `[{"min":1,"max":19},{"min":100,"max":2147483647}]` + path := fmt.Sprintf("/projects/%s/tasks?importance_ranges=%s", projectID, url.QueryEscape(raw)) + w := doTaskRequest(r, http.MethodGet, path, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + want := []taskdom.IntRange{{Min: 1, Max: 19}, {Min: 100, Max: 2147483647}} + if !reflect.DeepEqual(svc.lastFilter.ImportanceRanges, want) { + t.Fatalf("ImportanceRanges = %+v, want %+v", svc.lastFilter.ImportanceRanges, want) + } +} + +func TestTaskHandler_ListTasks_ImportanceRanges_InvalidJSONReturns400(t *testing.T) { + svc := newFakeTaskSvc() + r := buildTaskHandlerRouter(svc) + projectID := uuid.New() + + path := fmt.Sprintf("/projects/%s/tasks?importance_ranges=%s", projectID, url.QueryEscape("not-json")) + w := doTaskRequest(r, http.MethodGet, path, nil) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestTaskHandler_ListTasks_ImportanceRanges_MinGreaterThanMaxReturns400(t *testing.T) { + svc := newFakeTaskSvc() + r := buildTaskHandlerRouter(svc) + projectID := uuid.New() + + raw := `[{"min":50,"max":10}]` + path := fmt.Sprintf("/projects/%s/tasks?importance_ranges=%s", projectID, url.QueryEscape(raw)) + w := doTaskRequest(r, http.MethodGet, path, nil) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestTaskHandler_ListTasks_TagsFilter(t *testing.T) { + svc := newFakeTaskSvc() + r := buildTaskHandlerRouter(svc) + projectID := uuid.New() + + path := fmt.Sprintf("/projects/%s/tasks?tags=urgent,%s", projectID, url.QueryEscape("needs review")) + w := doTaskRequest(r, http.MethodGet, path, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + want := []string{"urgent", "needs review"} + if !reflect.DeepEqual(svc.lastFilter.Tags, want) { + t.Fatalf("Tags = %+v, want %+v", svc.lastFilter.Tags, want) + } +} + func TestTaskHandler_ListTasks_ResponseIncludesTotalCount(t *testing.T) { svc := newFakeTaskSvc() r := buildTaskHandlerRouter(svc) diff --git a/services/api/test/e2e/task_filter_test.go b/services/api/test/e2e/task_filter_test.go new file mode 100644 index 00000000..d5bb65b8 --- /dev/null +++ b/services/api/test/e2e/task_filter_test.go @@ -0,0 +1,879 @@ +package e2e_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "testing" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// createTaskViaAPIWithBody creates a task from an arbitrary request body and +// returns the full decoded task response (id plus every field echoed back), +// so filter tests can set any combination of built-in/custom fields on one +// task and later reference its id. +func createTaskViaAPIWithBody(t *testing.T, env *e2eEnv, client *http.Client, token, projID string, body map[string]any) map[string]any { + t.Helper() + req := mustRequest(env.ctx, t, http.MethodPost, + fmt.Sprintf("%s/api/v1/projects/%s/tasks", env.base, projID), jsonBody(t, body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp := mustDo(t, client, req) + defer func() { _ = resp.Body.Close() }() + assertStatus(t, resp, http.StatusCreated) + var env2 envelope + decodeJSON(t, resp, &env2) + return assertDataMap(t, env2) +} + +// idOf extracts the "id" field from a decoded task/entity response map. +func idOf(data map[string]any) string { + s, _ := data["id"].(string) + return s +} + +// idsEqualSet reports whether got and want contain exactly the same set of +// ids, ignoring order and duplicates. +func idsEqualSet(got, want []string) bool { + if len(got) != len(want) { + return false + } + set := make(map[string]bool, len(want)) + for _, w := range want { + set[w] = true + } + for _, g := range got { + if !set[g] { + return false + } + } + return true +} + +// mustJSONString marshals v to a JSON string, for building the +// custom_field_filters / importance_ranges query parameter values. +func mustJSONString(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal json: %v", err) + } + return string(b) +} + +// getTasksExpectBadRequest issues GET /tasks with the given raw query string +// and asserts a 400 BAD_REQUEST response. +func getTasksExpectBadRequest(t *testing.T, env *e2eEnv, client *http.Client, token, projID, rawQuery string) { + t.Helper() + path := fmt.Sprintf("%s/api/v1/projects/%s/tasks?%s", env.base, projID, rawQuery) + req := mustRequest(env.ctx, t, http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp := mustDo(t, client, req) + defer func() { _ = resp.Body.Close() }() + assertStatus(t, resp, http.StatusBadRequest) + assertErrorCode(t, resp, "BAD_REQUEST") +} + +// --------------------------------------------------------------------------- +// Custom field filters — select +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_CustomFieldSelect(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "cf-select-filter-user", "cfselectpass1") + client, token := taskMemberLogin(t, env, "cf-select-filter-user", "cfselectpass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "priority", + "display_name": "Priority", + "field_type": "select", + "options": []string{"Open", "Closed", "Blocked"}, + }) + + open1 := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Open 1", "custom_fields": map[string]any{"priority": "Open"}, + }) + open2 := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Open 2", "custom_fields": map[string]any{"priority": "Open"}, + }) + closed1 := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Closed 1", "custom_fields": map[string]any{"priority": "Closed"}, + }) + blocked1 := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Blocked 1", "custom_fields": map[string]any{"priority": "Blocked"}, + }) + noValue := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "No Priority Set", + }) + + t.Run("single_value_matches_only_that_value", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"priority": map[string]any{"values": []string{"Open"}}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + want := []string{idOf(open1), idOf(open2)} + if got := itemIDs(data); !idsEqualSet(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + }) + + t.Run("multiple_values_match_any", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"priority": map[string]any{"values": []string{"Closed", "Blocked"}}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + want := []string{idOf(closed1), idOf(blocked1)} + if got := itemIDs(data); !idsEqualSet(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + }) + + t.Run("no_match_returns_empty", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"priority": map[string]any{"values": []string{"NoSuchOption"}}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + if got := itemIDs(data); len(got) != 0 { + t.Fatalf("expected no matches, got %v", got) + } + }) + + t.Run("task_with_no_value_excluded_when_filter_active", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"priority": map[string]any{"values": []string{"Open", "Closed", "Blocked"}}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + got := itemIDs(data) + for _, id := range got { + if id == idOf(noValue) { + t.Fatalf("expected task with no custom field value to be excluded, got it in results: %v", got) + } + } + if len(got) != 4 { + t.Fatalf("expected 4 tasks with any priority value, got %d: %v", len(got), got) + } + }) +} + +// --------------------------------------------------------------------------- +// Custom field filters — multi_select +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_CustomFieldMultiSelect(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "cf-multiselect-filter-user", "cfmultipass1") + client, token := taskMemberLogin(t, env, "cf-multiselect-filter-user", "cfmultipass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "labels", + "display_name": "Labels", + "field_type": "multi_select", + "options": []string{"backend", "frontend", "urgent"}, + }) + + backendUrgent := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Backend Urgent", "custom_fields": map[string]any{"labels": []string{"backend", "urgent"}}, + }) + frontendOnly := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Frontend Only", "custom_fields": map[string]any{"labels": []string{"frontend"}}, + }) + createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "No Labels", "custom_fields": map[string]any{"labels": []string{}}, + }) + + t.Run("matches_any_overlapping_value", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"labels": map[string]any{"values": []string{"urgent"}}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(backendUrgent)}) { + t.Fatalf("expected only backendUrgent task, got %v", got) + } + }) + + t.Run("multiple_filter_values_union_matches", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"labels": map[string]any{"values": []string{"backend", "frontend"}}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + want := []string{idOf(backendUrgent), idOf(frontendOnly)} + if got := itemIDs(data); !idsEqualSet(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + }) + + t.Run("no_overlap_excluded", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"labels": map[string]any{"values": []string{"nonexistent"}}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + if got := itemIDs(data); len(got) != 0 { + t.Fatalf("expected no matches, got %v", got) + } + }) +} + +// --------------------------------------------------------------------------- +// Custom field filters — boolean +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_CustomFieldBoolean(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "cf-boolean-filter-user", "cfboolpass1") + client, token := taskMemberLogin(t, env, "cf-boolean-filter-user", "cfboolpass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "flag", "display_name": "Flag", "field_type": "boolean", + }) + + trueTask := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Flag True", "custom_fields": map[string]any{"flag": true}, + }) + falseTask := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Flag False", "custom_fields": map[string]any{"flag": false}, + }) + createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Flag Unset", + }) + + t.Run("filters_true_only", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"flag": map[string]any{"values": []string{"true"}}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(trueTask)}) { + t.Fatalf("expected only true task, got %v", got) + } + }) + + t.Run("filters_false_only", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"flag": map[string]any{"values": []string{"false"}}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(falseTask)}) { + t.Fatalf("expected only false task, got %v", got) + } + }) + + t.Run("filters_both_excludes_unset", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"flag": map[string]any{"values": []string{"true", "false"}}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + want := []string{idOf(trueTask), idOf(falseTask)} + if got := itemIDs(data); !idsEqualSet(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + }) +} + +// --------------------------------------------------------------------------- +// Custom field filters — number +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_CustomFieldNumber(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "cf-number-filter-user", "cfnumberpass1") + client, token := taskMemberLogin(t, env, "cf-number-filter-user", "cfnumberpass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "effort", "display_name": "Effort", "field_type": "number", + }) + + low := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Effort 3", "custom_fields": map[string]any{"effort": 3}, + }) + mid := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Effort 5", "custom_fields": map[string]any{"effort": 5}, + }) + high := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Effort 8", "custom_fields": map[string]any{"effort": 8}, + }) + noValue := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "No Effort", + }) + + t.Run("min_only", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"effort": map[string]any{"min": 5}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + want := []string{idOf(mid), idOf(high)} + if got := itemIDs(data); !idsEqualSet(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + }) + + t.Run("max_only", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"effort": map[string]any{"max": 5}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + want := []string{idOf(low), idOf(mid)} + if got := itemIDs(data); !idsEqualSet(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + }) + + t.Run("range_inclusive_exact_boundary", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"effort": map[string]any{"min": 3, "max": 3}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(low)}) { + t.Fatalf("expected exact boundary match, got %v", got) + } + }) + + t.Run("no_match_outside_range", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"effort": map[string]any{"min": 100}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + if got := itemIDs(data); len(got) != 0 { + t.Fatalf("expected no matches, got %v", got) + } + }) + + t.Run("task_with_no_value_excluded", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"effort": map[string]any{"min": 0}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + got := itemIDs(data) + for _, id := range got { + if id == idOf(noValue) { + t.Fatalf("expected task with no effort value to be excluded, got it: %v", got) + } + } + if len(got) != 3 { + t.Fatalf("expected 3 tasks with an effort value, got %d: %v", len(got), got) + } + }) +} + +// --------------------------------------------------------------------------- +// Custom field filters — date +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_CustomFieldDate(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "cf-date-filter-user", "cfdatepass1") + client, token := taskMemberLogin(t, env, "cf-date-filter-user", "cfdatepass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "review_date", "display_name": "Review Date", "field_type": "date", + }) + + early := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Early", "custom_fields": map[string]any{"review_date": "2024-01-15"}, + }) + mid := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Mid", "custom_fields": map[string]any{"review_date": "2024-03-15"}, + }) + late := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Late", "custom_fields": map[string]any{"review_date": "2024-06-15"}, + }) + + t.Run("after_only", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"review_date": map[string]any{"after": "2024-03-01"}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + want := []string{idOf(mid), idOf(late)} + if got := itemIDs(data); !idsEqualSet(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + }) + + t.Run("before_only", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"review_date": map[string]any{"before": "2024-03-01"}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(early)}) { + t.Fatalf("expected only early task, got %v", got) + } + }) + + t.Run("range_inclusive_exact_day_boundary", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"review_date": map[string]any{"after": "2024-03-15", "before": "2024-03-15"}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(mid)}) { + t.Fatalf("expected exact-day boundary match, got %v", got) + } + }) +} + +// --------------------------------------------------------------------------- +// Custom field filters — text / url +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_CustomFieldTextAndURL(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "cf-text-filter-user", "cftextpass1") + client, token := taskMemberLogin(t, env, "cf-text-filter-user", "cftextpass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "notes", "display_name": "Notes", "field_type": "text", + }) + createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "link", "display_name": "Link", "field_type": "url", + }) + + matchText := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Match Text", "custom_fields": map[string]any{"notes": "Needs URGENT review"}, + }) + createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "No Match Text", "custom_fields": map[string]any{"notes": "All good here"}, + }) + matchURL := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Match URL", "custom_fields": map[string]any{"link": "https://example.com/docs"}, + }) + + t.Run("text_contains_case_insensitive", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"notes": map[string]any{"contains": "urgent"}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(matchText)}) { + t.Fatalf("expected only matchText task, got %v", got) + } + }) + + t.Run("url_contains", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"link": map[string]any{"contains": "example.com"}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(matchURL)}) { + t.Fatalf("expected only matchURL task, got %v", got) + } + }) + + t.Run("no_match_returns_empty", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"notes": map[string]any{"contains": "nonexistent phrase"}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + if got := itemIDs(data); len(got) != 0 { + t.Fatalf("expected no matches, got %v", got) + } + }) +} + +// --------------------------------------------------------------------------- +// Custom field filters — multiple fields combined (AND) +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_CustomFieldsCombinedAND(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "cf-combined-filter-user", "cfcombinedpass1") + client, token := taskMemberLogin(t, env, "cf-combined-filter-user", "cfcombinedpass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "priority", "display_name": "Priority", "field_type": "select", + "options": []string{"High", "Low"}, + }) + createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "effort", "display_name": "Effort", "field_type": "number", + }) + + both := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "High + Effort 5", "custom_fields": map[string]any{"priority": "High", "effort": 5}, + }) + createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "High + Effort 1", "custom_fields": map[string]any{"priority": "High", "effort": 1}, + }) + createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Low + Effort 5", "custom_fields": map[string]any{"priority": "Low", "effort": 5}, + }) + + raw := mustJSONString(t, map[string]any{ + "priority": map[string]any{"values": []string{"High"}}, + "effort": map[string]any{"min": 3}, + }) + data := listTasksPage(t, env, client, token, projID, url.Values{"custom_field_filters": {raw}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(both)}) { + t.Fatalf("expected only the task matching BOTH filters, got %v", got) + } +} + +// --------------------------------------------------------------------------- +// Built-in filters — start_date +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_StartDateRange(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "startdate-filter-user", "startdatepass1") + client, token := taskMemberLogin(t, env, "startdate-filter-user", "startdatepass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + early := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Early", "start_date": "2024-01-15T00:00:00Z", + }) + mid := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Mid", "start_date": "2024-03-15T00:00:00Z", + }) + late := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Late", "start_date": "2024-06-15T00:00:00Z", + }) + noDate := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "No Start Date", + }) + + t.Run("after_only", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{"start_date_after": {"2024-03-01"}}) + want := []string{idOf(mid), idOf(late)} + if got := itemIDs(data); !idsEqualSet(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + }) + + t.Run("before_only", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{"start_date_before": {"2024-03-01"}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(early)}) { + t.Fatalf("expected only early task, got %v", got) + } + }) + + t.Run("range_exact_day_boundary", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{ + "start_date_after": {"2024-03-15"}, + "start_date_before": {"2024-03-15"}, + }) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(mid)}) { + t.Fatalf("expected exact-day boundary match, got %v", got) + } + }) + + t.Run("task_with_no_start_date_excluded", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{"start_date_after": {"2000-01-01"}}) + got := itemIDs(data) + for _, id := range got { + if id == idOf(noDate) { + t.Fatalf("expected task with no start_date to be excluded, got it: %v", got) + } + } + if len(got) != 3 { + t.Fatalf("expected 3 tasks with a start_date, got %d: %v", len(got), got) + } + }) +} + +// --------------------------------------------------------------------------- +// Built-in filters — due_date +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_DueDateRange(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "duedate-filter-user", "duedatepass1") + client, token := taskMemberLogin(t, env, "duedate-filter-user", "duedatepass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + early := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Early Due", "due_date": "2024-01-15T00:00:00Z", + }) + late := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Late Due", "due_date": "2024-06-15T00:00:00Z", + }) + + t.Run("after_only", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{"due_date_after": {"2024-03-01"}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(late)}) { + t.Fatalf("expected only late task, got %v", got) + } + }) + + t.Run("before_only", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{"due_date_before": {"2024-03-01"}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(early)}) { + t.Fatalf("expected only early task, got %v", got) + } + }) + + t.Run("range_excludes_both_outliers", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{ + "due_date_after": {"2024-02-01"}, + "due_date_before": {"2024-05-01"}, + }) + if got := itemIDs(data); len(got) != 0 { + t.Fatalf("expected no tasks in narrow middle range, got %v", got) + } + }) +} + +// --------------------------------------------------------------------------- +// Built-in filters — story_points +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_StoryPointsRange(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "storypoints-filter-user", "spfilterpass1") + client, token := taskMemberLogin(t, env, "storypoints-filter-user", "spfilterpass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + sp1 := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{"title": "SP 1", "story_points": 1}) + sp5 := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{"title": "SP 5", "story_points": 5}) + sp13 := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{"title": "SP 13", "story_points": 13}) + noSP := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{"title": "No SP"}) + + t.Run("min_only", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{"story_points_min": {"5"}}) + want := []string{idOf(sp5), idOf(sp13)} + if got := itemIDs(data); !idsEqualSet(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + }) + + t.Run("max_only", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{"story_points_max": {"5"}}) + want := []string{idOf(sp1), idOf(sp5)} + if got := itemIDs(data); !idsEqualSet(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + }) + + t.Run("range_exact_boundary", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{ + "story_points_min": {"1"}, "story_points_max": {"1"}, + }) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(sp1)}) { + t.Fatalf("expected exact boundary match, got %v", got) + } + }) + + t.Run("task_with_no_story_points_excluded", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{"story_points_min": {"0"}}) + got := itemIDs(data) + for _, id := range got { + if id == idOf(noSP) { + t.Fatalf("expected task with no story_points to be excluded, got it: %v", got) + } + } + if len(got) != 3 { + t.Fatalf("expected 3 tasks with story_points set, got %d: %v", len(got), got) + } + }) +} + +// --------------------------------------------------------------------------- +// Built-in filters — importance_ranges +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_ImportanceRanges(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "importance-filter-user", "importancepass1") + client, token := taskMemberLogin(t, env, "importance-filter-user", "importancepass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + none := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{"title": "None", "importance": 0}) + low := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{"title": "Low", "importance": 10}) + medium := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{"title": "Medium", "importance": 35}) + high := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{"title": "High", "importance": 75}) + critical := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{"title": "Critical", "importance": 150}) + + t.Run("single_range_matches_one_bucket", func(t *testing.T) { + raw := mustJSONString(t, []map[string]any{{"min": 1, "max": 19}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"importance_ranges": {raw}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(low)}) { + t.Fatalf("expected only low-importance task, got %v", got) + } + }) + + t.Run("multiple_ranges_or_together_non_contiguous", func(t *testing.T) { + // Selecting Low and Critical but skipping Medium/High must not + // silently include the skipped buckets. + raw := mustJSONString(t, []map[string]any{ + {"min": 1, "max": 19}, + {"min": 100, "max": 2147483647}, + }) + data := listTasksPage(t, env, client, token, projID, url.Values{"importance_ranges": {raw}}) + want := []string{idOf(low), idOf(critical)} + got := itemIDs(data) + if !idsEqualSet(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + for _, id := range got { + if id == idOf(medium) || id == idOf(high) { + t.Fatalf("expected medium/high to be excluded from a non-contiguous selection, got %v", got) + } + } + }) + + t.Run("none_bucket_zero_zero", func(t *testing.T) { + raw := mustJSONString(t, []map[string]any{{"min": 0, "max": 0}}) + data := listTasksPage(t, env, client, token, projID, url.Values{"importance_ranges": {raw}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(none)}) { + t.Fatalf("expected only none-importance task, got %v", got) + } + }) + + t.Run("invalid_json_returns_400", func(t *testing.T) { + getTasksExpectBadRequest(t, env, client, token, projID, + "importance_ranges="+url.QueryEscape("not-json")) + }) + + t.Run("min_greater_than_max_returns_400", func(t *testing.T) { + raw := mustJSONString(t, []map[string]any{{"min": 50, "max": 10}}) + getTasksExpectBadRequest(t, env, client, token, projID, + "importance_ranges="+url.QueryEscape(raw)) + }) +} + +// --------------------------------------------------------------------------- +// Built-in filters — tags +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_Tags(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "tags-filter-user", "tagsfilterpass1") + client, token := taskMemberLogin(t, env, "tags-filter-user", "tagsfilterpass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + urgentBug := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Urgent Bug", "tags": []string{"urgent", "bug"}, + }) + backend := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Backend Task", "tags": []string{"backend"}, + }) + needsReview := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Needs Review", "tags": []string{"needs review"}, + }) + createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "No Tags", + }) + + t.Run("single_tag_matches", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{"tags": {"urgent"}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(urgentBug)}) { + t.Fatalf("expected only urgentBug task, got %v", got) + } + }) + + t.Run("multiple_tags_match_any", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{"tags": {"urgent,backend"}}) + want := []string{idOf(urgentBug), idOf(backend)} + if got := itemIDs(data); !idsEqualSet(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + }) + + t.Run("tag_with_space_matches_exactly", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{"tags": {"needs review"}}) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(needsReview)}) { + t.Fatalf("expected only needsReview task, got %v", got) + } + }) + + t.Run("no_match_returns_empty", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{"tags": {"nonexistent"}}) + if got := itemIDs(data); len(got) != 0 { + t.Fatalf("expected no matches, got %v", got) + } + }) +} + +// --------------------------------------------------------------------------- +// Combined filters across dimensions (AND semantics) +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_CombinedAcrossDimensions(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "combined-filter-user", "combinedpass1") + client, token := taskMemberLogin(t, env, "combined-filter-user", "combinedpass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + statuses := listTaskStatusesViaAPI(t, env, client, token, projID) + todoID := statusIDByName(statuses, "Todo") + doneID := statusIDByName(statuses, "Done") + if todoID == "" || doneID == "" { + t.Fatalf("expected default Todo/Done statuses, got %+v", statuses) + } + + createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "priority", "display_name": "Priority", "field_type": "select", + "options": []string{"High", "Low"}, + }) + + match := createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Matches everything", "status_id": todoID, "tags": []string{"urgent"}, + "custom_fields": map[string]any{"priority": "High"}, "start_date": "2024-03-01T00:00:00Z", + }) + createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Wrong status", "status_id": doneID, "tags": []string{"urgent"}, + "custom_fields": map[string]any{"priority": "High"}, "start_date": "2024-03-01T00:00:00Z", + }) + createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Wrong tag", "status_id": todoID, "tags": []string{"other"}, + "custom_fields": map[string]any{"priority": "High"}, "start_date": "2024-03-01T00:00:00Z", + }) + createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Wrong priority", "status_id": todoID, "tags": []string{"urgent"}, + "custom_fields": map[string]any{"priority": "Low"}, "start_date": "2024-03-01T00:00:00Z", + }) + createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Wrong date", "status_id": todoID, "tags": []string{"urgent"}, + "custom_fields": map[string]any{"priority": "High"}, "start_date": "2024-01-01T00:00:00Z", + }) + + raw := mustJSONString(t, map[string]any{"priority": map[string]any{"values": []string{"High"}}}) + data := listTasksPage(t, env, client, token, projID, url.Values{ + "status_ids": {todoID}, + "tags": {"urgent"}, + "custom_field_filters": {raw}, + "start_date_after": {"2024-02-01"}, + "start_date_before": {"2024-04-01"}, + }) + if got := itemIDs(data); !idsEqualSet(got, []string{idOf(match)}) { + t.Fatalf("expected only the task matching every filter dimension, got %v", got) + } +} + +// --------------------------------------------------------------------------- +// total_count / field_sum respect active filters +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_TotalCountAndFieldSumRespectFilters(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "count-sum-filter-user", "countsumpass1") + client, token := taskMemberLogin(t, env, "count-sum-filter-user", "countsumpass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "priority", "display_name": "Priority", "field_type": "select", + "options": []string{"High", "Low"}, + }) + + createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "High 3", "story_points": 3, "custom_fields": map[string]any{"priority": "High"}, + }) + createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "High 5", "story_points": 5, "custom_fields": map[string]any{"priority": "High"}, + }) + createTaskViaAPIWithBody(t, env, client, token, projID, map[string]any{ + "title": "Low 7", "story_points": 7, "custom_fields": map[string]any{"priority": "Low"}, + }) + + t.Run("total_count_respects_story_points_filter", func(t *testing.T) { + data := listTasksPage(t, env, client, token, projID, url.Values{"story_points_min": {"5"}}) + if got := totalCountFromData(data); got != 2 { + t.Errorf("expected total_count=2, got %d", got) + } + }) + + t.Run("field_sum_respects_custom_field_filter", func(t *testing.T) { + raw := mustJSONString(t, map[string]any{"priority": map[string]any{"values": []string{"High"}}}) + data := listTasksPage(t, env, client, token, projID, url.Values{ + "sum_field": {"story_points"}, + "custom_field_filters": {raw}, + }) + sum, ok := fieldSumFromData(data) + if !ok { + t.Fatal("expected field_sum in response") + } + if sum != 8 { + t.Errorf("expected field_sum=8 (3+5, excluding the Low task), got %v", sum) + } + }) +} + +// --------------------------------------------------------------------------- +// Validation errors for malformed filter query params +// --------------------------------------------------------------------------- + +func TestE2ETaskFilters_ValidationErrors(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "validation-filter-user", "validationpass1") + client, token := taskMemberLogin(t, env, "validation-filter-user", "validationpass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + cases := []struct { + name string + query string + }{ + {"invalid_start_date_after", "start_date_after=not-a-date"}, + {"invalid_start_date_before", "start_date_before=" + url.QueryEscape("2024/06/01")}, + {"invalid_due_date_after", "due_date_after=garbage"}, + {"invalid_due_date_before", "due_date_before=" + url.QueryEscape("01-01-2024")}, + {"invalid_story_points_min", "story_points_min=abc"}, + {"invalid_story_points_max", "story_points_max=" + url.QueryEscape("5.5")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + getTasksExpectBadRequest(t, env, client, token, projID, tc.query) + }) + } +} diff --git a/services/api/test/e2e/task_pagination_test.go b/services/api/test/e2e/task_pagination_test.go index 67dc1e1e..1cb8a0f2 100644 --- a/services/api/test/e2e/task_pagination_test.go +++ b/services/api/test/e2e/task_pagination_test.go @@ -1,6 +1,7 @@ package e2e_test import ( + "encoding/json" "fmt" "net/http" "net/url" @@ -992,3 +993,106 @@ func TestE2EListTaskPagination_ViewPositionSort(t *testing.T) { } }) } + +// TestE2ECustomFieldFilters_FieldKeyReuseAfterDeleteDoesNotCrash is a +// regression test: deleting a custom field definition used to leave its +// stray values behind under that key in every task's custom_fields JSONB +// column. If a new field definition was then created reusing the same +// field_key but an incompatible type (e.g. the original field was "select" +// and the new one is "date"), filtering by the new field crashed the whole +// query with a Postgres cast error (SQLSTATE 22007: invalid input syntax for +// type date) on the leftover value, instead of just excluding that task. +func TestE2ECustomFieldFilters_FieldKeyReuseAfterDeleteDoesNotCrash(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "cf-reuse-user", "cfreusepass1") + client, token := taskMemberLogin(t, env, "cf-reuse-user", "cfreusepass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + selectFieldID := createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "custom_1", + "display_name": "Custom 1", + "field_type": "select", + "options": []string{"Test 3", "Other"}, + }) + createTaskWithCustomFieldViaAPI(t, env, client, token, projID, "Stale select value", + map[string]any{"custom_1": "Test 3"}) + + delReq := mustRequest(env.ctx, t, http.MethodDelete, + fmt.Sprintf("%s/api/v1/projects/%s/custom-fields/%s", env.base, projID, selectFieldID), nil) + delReq.Header.Set("Authorization", "Bearer "+token) + delResp := mustDo(t, client, delReq) + _ = delResp.Body.Close() + assertStatus(t, delResp, http.StatusOK) + + // Recreate a field with the same field_key but an incompatible type. + createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "custom_1", + "display_name": "Custom 1 (date)", + "field_type": "date", + }) + createTaskWithCustomFieldViaAPI(t, env, client, token, projID, "Valid date value", + map[string]any{"custom_1": "2024-03-15"}) + + filters, err := json.Marshal(map[string]any{ + "custom_1": map[string]any{"after": "2024-01-01", "before": "2024-12-31"}, + }) + if err != nil { + t.Fatalf("marshal filters: %v", err) + } + + data := listTasksPage(t, env, client, token, projID, url.Values{ + "custom_field_filters": {string(filters)}, + }) + ids := itemIDs(data) + if len(ids) != 1 { + t.Fatalf("expected exactly 1 matching task (the valid date; the stale select value must be excluded, not crash the query), got %d: %v", len(ids), ids) + } +} + +// TestE2ECustomFieldFilters_InvalidStoredValueDoesNotCrash is a regression +// test for the more general case underlying the same bug class: custom_fields +// has no write-time type validation (task creation/update accepts any JSON +// value regardless of the field's declared type), so a task can end up with +// a value that doesn't match its field's type through any write path, not +// just field deletion + key reuse. Filtering or sorting by that field must +// exclude the invalid row instead of crashing the whole query. +func TestE2ECustomFieldFilters_InvalidStoredValueDoesNotCrash(t *testing.T) { + env := newE2EEnv(t) + seedTaskMemberUser(t, env, "cf-invalid-user", "cfinvalidpass1") + client, token := taskMemberLogin(t, env, "cf-invalid-user", "cfinvalidpass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + createCustomFieldViaAPI(t, env, client, token, projID, map[string]any{ + "field_key": "due", + "display_name": "Due", + "field_type": "date", + }) + // No write-time type validation exists today, so this succeeds despite + // "not-a-date" being an invalid value for a "date" field. + createTaskWithCustomFieldViaAPI(t, env, client, token, projID, "Bad value", + map[string]any{"due": "not-a-date"}) + createTaskWithCustomFieldViaAPI(t, env, client, token, projID, "Good value", + map[string]any{"due": "2024-03-15"}) + + filters, err := json.Marshal(map[string]any{ + "due": map[string]any{"after": "2024-01-01", "before": "2024-12-31"}, + }) + if err != nil { + t.Fatalf("marshal filters: %v", err) + } + + data := listTasksPage(t, env, client, token, projID, url.Values{ + "custom_field_filters": {string(filters)}, + }) + ids := itemIDs(data) + if len(ids) != 1 { + t.Fatalf("expected exactly 1 matching task (invalid date value must be excluded, not crash the query), got %d: %v", len(ids), ids) + } + + // Sorting by the same field must not crash either — it hits the same + // unguarded-cast bug class in buildCFOrderBy. + sortData := listTasksPage(t, env, client, token, projID, url.Values{"sort_by": {"due"}}) + if got := len(itemIDs(sortData)); got != 2 { + t.Fatalf("expected sort_by=due to return both tasks without crashing, got %d: %v", got, itemIDs(sortData)) + } +} From da794e15135873e9fb53a3a966fa7fec26fd431c Mon Sep 17 00:00:00 2001 From: pikann22 Date: Fri, 17 Jul 2026 17:20:34 +0000 Subject: [PATCH 2/2] fix: adjust custom field date expression regex for accurate date matching --- apps/web/src/components/projects/interactions/priority.ts | 5 ++++- services/api/internal/repository/postgres/task_repository.go | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/projects/interactions/priority.ts b/apps/web/src/components/projects/interactions/priority.ts index 46b031ad..724d202f 100644 --- a/apps/web/src/components/projects/interactions/priority.ts +++ b/apps/web/src/components/projects/interactions/priority.ts @@ -69,6 +69,9 @@ export function getImportanceBucketBounds(bucket: number): { case 3: return { min: 50, max: 99 }; default: - return { min: 100, max: Number.MAX_SAFE_INTEGER }; + // Matches Postgres' `importance` column type (INTEGER, max 2147483647) — + // the API binds this value directly into a SQL integer comparison, so a + // larger bound (e.g. Number.MAX_SAFE_INTEGER) overflows and errors. + return { min: 100, max: 2147483647 }; } } diff --git a/services/api/internal/repository/postgres/task_repository.go b/services/api/internal/repository/postgres/task_repository.go index dd61606f..90462197 100644 --- a/services/api/internal/repository/postgres/task_repository.go +++ b/services/api/internal/repository/postgres/task_repository.go @@ -664,7 +664,7 @@ func customFieldNumericExpr(keyP string) string { // customFieldDateExpr is the date-typed counterpart of customFieldNumericExpr. func customFieldDateExpr(keyP string) string { - return "(CASE WHEN custom_fields->>" + keyP + " ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}' THEN (custom_fields->>" + keyP + ")::date END)" + return "(CASE WHEN custom_fields->>" + keyP + " ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN (custom_fields->>" + keyP + ")::date END)" } // escapeLikePattern escapes the LIKE/ILIKE wildcard characters (% and _) and