diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index be86fed6..bcb0aada 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -1438,6 +1438,8 @@ async def _lead_effective_dependencies( *, update: _TaskUpdateInput, ) -> tuple[list[UUID], list[UUID]]: + # Use newly normalized dependency updates when supplied; otherwise fall back + # to the task's current dependencies for blocked-by evaluation. normalized_deps: list[UUID] | None = None if update.depends_on_task_ids is not None: if update.task.status == "done": @@ -1659,6 +1661,8 @@ async def _apply_non_lead_agent_task_rules( and update.actor.agent.board_id != update.task.board_id ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + # Agents are limited to status/comment updates, and non-inbox status moves + # must pass dependency checks before they can proceed. allowed_fields = {"status", "comment"} if ( update.depends_on_task_ids is not None @@ -1732,6 +1736,8 @@ async def _apply_admin_task_rules( target_status = _required_status_value( update.updates.get("status", update.task.status), ) + # Reset blocked tasks to inbox unless the task is already done and remains + # done, which is the explicit done-task exception. if blocked_ids and not (update.task.status == "done" and target_status == "done"): update.task.status = "inbox" update.task.assigned_agent_id = None @@ -1788,6 +1794,8 @@ async def _record_task_update_activity( actor_agent_id = ( update.actor.agent.id if update.actor.actor_type == "agent" and update.actor.agent else None ) + # Record the task transition first, then reconcile dependents so any + # cascaded dependency effects are logged after the source change. record_activity( session, event_type=event_type, @@ -1888,6 +1896,8 @@ async def _finalize_updated_task( update.task.updated_at = utcnow() status_raw = update.updates.get("status") + # Entering review requires either a new comment or a valid recent one to + # ensure reviewers get context on readiness. if status_raw == "review": comment_text = (update.comment or "").strip() if not comment_text and not await has_valid_recent_comment( diff --git a/backend/app/services/board_snapshot.py b/backend/app/services/board_snapshot.py index 22f0d5ca..4fa50631 100644 --- a/backend/app/services/board_snapshot.py +++ b/backend/app/services/board_snapshot.py @@ -149,6 +149,8 @@ async def build_board_snapshot(session: AsyncSession, board: Board) -> BoardSnap approval_ids=approval_ids, ) task_title_by_id = {task.id: task.title for task in tasks} + # Hydrate each approval with linked task metadata, falling back to legacy + # single-task fields so older rows still render complete approval cards. approval_reads = [ _approval_to_read( approval, diff --git a/backend/app/services/organizations.py b/backend/app/services/organizations.py index 84a426d9..b39dcfbd 100644 --- a/backend/app/services/organizations.py +++ b/backend/app/services/organizations.py @@ -175,6 +175,8 @@ async def accept_invite( session.add(member) await session.flush() + # For scoped invites, copy invite board-access rows onto the member at accept + # time so effective permissions survive invite lifecycle cleanup. if not (invite.all_boards_read or invite.all_boards_write): access_rows = list( await session.exec( diff --git a/backend/app/services/task_dependencies.py b/backend/app/services/task_dependencies.py index a9c8cd66..366bfa55 100644 --- a/backend/app/services/task_dependencies.py +++ b/backend/app/services/task_dependencies.py @@ -164,7 +164,8 @@ async def validate_dependency_update( }, ) - # Ensure the dependency graph is acyclic after applying the update. + # Rebuild the board-wide graph and overlay the pending edit for this task so + # validation catches indirect cycles created through existing edges. task_ids = list( await session.exec( select(col(Task.id)).where(col(Task.board_id) == board_id), diff --git a/backend/tests/test_boards_delete.py b/backend/tests/test_boards_delete.py index 104c6e3d..910f965a 100644 --- a/backend/tests/test_boards_delete.py +++ b/backend/tests/test_boards_delete.py @@ -63,3 +63,25 @@ async def test_delete_board_cleans_org_board_access_rows() -> None: assert "organization_invite_board_access" in deleted_table_names assert board in session.deleted assert session.committed == 1 + + +@pytest.mark.asyncio +async def test_delete_board_cleans_tag_assignments_before_tasks() -> None: + """Deleting a board should remove task-tag links before deleting tasks.""" + session: Any = _FakeSession(exec_results=[[], [uuid4()]]) + board = Board( + id=uuid4(), + organization_id=uuid4(), + name="Demo Board", + slug="demo-board", + gateway_id=None, + ) + + await boards.delete_board( + session=session, + board=board, + ) + + deleted_table_names = [statement.table.name for statement in session.executed] + assert "tag_assignments" in deleted_table_names + assert deleted_table_names.index("tag_assignments") < deleted_table_names.index("tasks") diff --git a/frontend/src/components/organisms/TaskBoard.tsx b/frontend/src/components/organisms/TaskBoard.tsx index 11c0e794..00c342b6 100644 --- a/frontend/src/components/organisms/TaskBoard.tsx +++ b/frontend/src/components/organisms/TaskBoard.tsx @@ -155,6 +155,7 @@ export const TaskBoard = memo(function TaskBoard({ return positions; }, []); + // Animate card reordering smoothly by applying FLIP whenever layout positions change. useLayoutEffect(() => { const cardRefsSnapshot = cardRefs.current; if (animationRafRef.current !== null) { @@ -275,6 +276,7 @@ export const TaskBoard = memo(function TaskBoard({ return buckets; }, [tasks]); + // Keep drag/drop state and payload handling centralized for column move interactions. const handleDragStart = (task: Task) => (event: React.DragEvent) => { if (readOnly) { @@ -344,6 +346,7 @@ export const TaskBoard = memo(function TaskBoard({ > {columns.map((column) => { const columnTasks = grouped[column.status] ?? []; + // Derive review tab counts and the active subset from one canonical task list. const reviewCounts = column.status === "review" ? columnTasks.reduce( diff --git a/frontend/src/components/ui/dropdown-select.tsx b/frontend/src/components/ui/dropdown-select.tsx index cf7dd33d..178fba62 100644 --- a/frontend/src/components/ui/dropdown-select.tsx +++ b/frontend/src/components/ui/dropdown-select.tsx @@ -40,6 +40,7 @@ type DropdownSelectProps = { emptyMessage?: string; }; +// Resolve trigger placeholder text with explicit prop override first, then accessible fallback. const resolvePlaceholder = (ariaLabel: string, placeholder?: string) => { if (placeholder) { return placeholder; @@ -51,6 +52,7 @@ const resolvePlaceholder = (ariaLabel: string, placeholder?: string) => { return trimmed.endsWith("...") ? trimmed : `${trimmed}...`; }; +// Resolve search input placeholder from explicit override or a normalized aria label. const resolveSearchPlaceholder = ( ariaLabel: string, searchPlaceholder?: string, @@ -107,6 +109,7 @@ export default function DropdownSelect({ handleOpenChange(false); }; + // Reset list scroll when opening or refining search so results start at the top. React.useEffect(() => { if (!open) { return;