diff --git a/backend/app/api/board_onboarding.py b/backend/app/api/board_onboarding.py index ae7c70ba..e0ac1a7b 100644 --- a/backend/app/api/board_onboarding.py +++ b/backend/app/api/board_onboarding.py @@ -86,6 +86,41 @@ def _parse_draft_lead_agent( return None +def _normalize_autonomy_token(value: object) -> str | None: + if not isinstance(value, str): + return None + text = value.strip().lower() + if not text: + return None + return text.replace("_", "-") + + +def _is_fully_autonomous_choice(value: object) -> bool: + token = _normalize_autonomy_token(value) + if token is None: + return False + if token in {"autonomous", "fully-autonomous", "full-autonomy"}: + return True + return "autonom" in token and "fully" in token + + +def _require_approval_for_done_from_draft(draft_goal: object) -> bool: + """Enable done-approval gate unless onboarding selected fully autonomous mode.""" + if not isinstance(draft_goal, dict): + return True + raw_lead = draft_goal.get("lead_agent") + if not isinstance(raw_lead, dict): + return True + if _is_fully_autonomous_choice(raw_lead.get("autonomy_level")): + return False + raw_identity_profile = raw_lead.get("identity_profile") + if isinstance(raw_identity_profile, dict): + for key in ("autonomy_level", "autonomy", "mode"): + if _is_fully_autonomous_choice(raw_identity_profile.get(key)): + return False + return True + + def _apply_user_profile( auth: AuthContext, profile: BoardOnboardingUserProfile | None, @@ -408,6 +443,9 @@ async def confirm_onboarding( board.target_date = payload.target_date board.goal_confirmed = True board.goal_source = "lead_agent_onboarding" + board.require_approval_for_done = _require_approval_for_done_from_draft( + onboarding.draft_goal, + ) onboarding.status = "confirmed" onboarding.updated_at = utcnow() diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index f43522b7..5a86d69b 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -42,7 +42,10 @@ from app.schemas.errors import BlockedTaskError from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate from app.services.activity_log import record_activity -from app.services.approval_task_links import load_task_ids_by_approval +from app.services.approval_task_links import ( + load_task_ids_by_approval, + pending_approval_conflicts_by_task, +) from app.services.mentions import extract_mentions, matches_agent_mention from app.services.openclaw.gateway_dispatch import GatewayDispatchService from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig @@ -113,6 +116,151 @@ def _blocked_task_error(blocked_by_task_ids: Sequence[UUID]) -> HTTPException: ) +def _approval_required_for_done_error() -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": ( + "Task can only be marked done when a linked approval has been approved." + ), + "blocked_by_task_ids": [], + }, + ) + + +def _review_required_for_done_error() -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": ( + "Task can only be marked done from review when the board rule is enabled." + ), + "blocked_by_task_ids": [], + }, + ) + + +def _pending_approval_blocks_status_change_error() -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": ( + "Task status cannot be changed while a linked approval is pending." + ), + "blocked_by_task_ids": [], + }, + ) + + +async def _task_has_approved_linked_approval( + session: AsyncSession, + *, + board_id: UUID, + task_id: UUID, +) -> bool: + linked_approval_ids = select(col(ApprovalTaskLink.approval_id)).where( + col(ApprovalTaskLink.task_id) == task_id, + ) + statement = ( + select(col(Approval.id)) + .where(col(Approval.board_id) == board_id) + .where(col(Approval.status) == "approved") + .where( + or_( + col(Approval.task_id) == task_id, + col(Approval.id).in_(linked_approval_ids), + ), + ) + .limit(1) + ) + return (await session.exec(statement)).first() is not None + + +async def _task_has_pending_linked_approval( + session: AsyncSession, + *, + board_id: UUID, + task_id: UUID, +) -> bool: + conflicts = await pending_approval_conflicts_by_task( + session, + board_id=board_id, + task_ids=[task_id], + ) + return task_id in conflicts + + +async def _require_approved_linked_approval_for_done( + session: AsyncSession, + *, + board_id: UUID, + task_id: UUID, + previous_status: str, + target_status: str, +) -> None: + if previous_status == "done" or target_status != "done": + return + requires_approval = ( + await session.exec( + select(col(Board.require_approval_for_done)).where(col(Board.id) == board_id), + ) + ).first() + if requires_approval is False: + return + if not await _task_has_approved_linked_approval( + session, + board_id=board_id, + task_id=task_id, + ): + raise _approval_required_for_done_error() + + +async def _require_review_before_done_when_enabled( + session: AsyncSession, + *, + board_id: UUID, + previous_status: str, + target_status: str, +) -> None: + if previous_status == "done" or target_status != "done": + return + requires_review = ( + await session.exec( + select(col(Board.require_review_before_done)).where(col(Board.id) == board_id), + ) + ).first() + if requires_review and previous_status != "review": + raise _review_required_for_done_error() + + +async def _require_no_pending_approval_for_status_change_when_enabled( + session: AsyncSession, + *, + board_id: UUID, + task_id: UUID, + previous_status: str, + target_status: str, + status_requested: bool, +) -> None: + if not status_requested or previous_status == target_status: + return + blocks_status_change = ( + await session.exec( + select(col(Board.block_status_changes_with_pending_approval)).where( + col(Board.id) == board_id, + ), + ) + ).first() + if not blocks_status_change: + return + if await _task_has_pending_linked_approval( + session, + board_id=board_id, + task_id=task_id, + ): + raise _pending_approval_blocks_status_change_error() + + def _truncate_snippet(value: str) -> str: text = value.strip() if len(text) <= TASK_SNIPPET_MAX_LEN: @@ -1447,6 +1595,27 @@ async def _apply_lead_task_update( else: await _lead_apply_assignment(session, update=update) _lead_apply_status(update) + await _require_no_pending_approval_for_status_change_when_enabled( + session, + board_id=update.board_id, + task_id=update.task.id, + previous_status=update.previous_status, + target_status=update.task.status, + status_requested="status" in update.updates, + ) + await _require_review_before_done_when_enabled( + session, + board_id=update.board_id, + previous_status=update.previous_status, + target_status=update.task.status, + ) + await _require_approved_linked_approval_for_done( + session, + board_id=update.board_id, + task_id=update.task.id, + previous_status=update.previous_status, + target_status=update.task.status, + ) if normalized_tag_ids is not None: await replace_tags( @@ -1701,6 +1870,27 @@ async def _finalize_updated_task( ) -> TaskRead: for key, value in update.updates.items(): setattr(update.task, key, value) + await _require_no_pending_approval_for_status_change_when_enabled( + session, + board_id=update.board_id, + task_id=update.task.id, + previous_status=update.previous_status, + target_status=update.task.status, + status_requested="status" in update.updates, + ) + await _require_review_before_done_when_enabled( + session, + board_id=update.board_id, + previous_status=update.previous_status, + target_status=update.task.status, + ) + await _require_approved_linked_approval_for_done( + session, + board_id=update.board_id, + task_id=update.task.id, + previous_status=update.previous_status, + target_status=update.task.status, + ) update.task.updated_at = utcnow() status_raw = update.updates.get("status") diff --git a/backend/app/models/boards.py b/backend/app/models/boards.py index 8731128b..286252c5 100644 --- a/backend/app/models/boards.py +++ b/backend/app/models/boards.py @@ -39,5 +39,8 @@ class Board(TenantScoped, table=True): target_date: datetime | None = None goal_confirmed: bool = Field(default=False) goal_source: str | None = None + require_approval_for_done: bool = Field(default=True) + require_review_before_done: bool = Field(default=False) + block_status_changes_with_pending_approval: bool = Field(default=False) created_at: datetime = Field(default_factory=utcnow) updated_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/schemas/boards.py b/backend/app/schemas/boards.py index 3727cd2a..584523b3 100644 --- a/backend/app/schemas/boards.py +++ b/backend/app/schemas/boards.py @@ -29,6 +29,9 @@ class BoardBase(SQLModel): target_date: datetime | None = None goal_confirmed: bool = False goal_source: str | None = None + require_approval_for_done: bool = True + require_review_before_done: bool = False + block_status_changes_with_pending_approval: bool = False class BoardCreate(BoardBase): @@ -68,6 +71,9 @@ class BoardUpdate(SQLModel): target_date: datetime | None = None goal_confirmed: bool | None = None goal_source: str | None = None + require_approval_for_done: bool | None = None + require_review_before_done: bool | None = None + block_status_changes_with_pending_approval: bool | None = None @model_validator(mode="after") def validate_gateway_id(self) -> Self: diff --git a/backend/migrations/versions/c2e9f1a6d4b8_add_board_pending_approval_status_gate.py b/backend/migrations/versions/c2e9f1a6d4b8_add_board_pending_approval_status_gate.py new file mode 100644 index 00000000..f1f687f4 --- /dev/null +++ b/backend/migrations/versions/c2e9f1a6d4b8_add_board_pending_approval_status_gate.py @@ -0,0 +1,55 @@ +"""add board rule toggles + +Revision ID: c2e9f1a6d4b8 +Revises: e2f9c6b4a1d3 +Create Date: 2026-02-12 23:55:00.000000 + +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c2e9f1a6d4b8" +down_revision = "e2f9c6b4a1d3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "boards", + sa.Column( + "require_approval_for_done", + sa.Boolean(), + nullable=False, + server_default=sa.true(), + ), + ) + op.add_column( + "boards", + sa.Column( + "require_review_before_done", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + op.add_column( + "boards", + sa.Column( + "block_status_changes_with_pending_approval", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + +def downgrade() -> None: + op.drop_column("boards", "block_status_changes_with_pending_approval") + op.drop_column("boards", "require_review_before_done") + op.drop_column("boards", "require_approval_for_done") diff --git a/backend/tests/test_board_onboarding_autonomy_toggle.py b/backend/tests/test_board_onboarding_autonomy_toggle.py new file mode 100644 index 00000000..9b834239 --- /dev/null +++ b/backend/tests/test_board_onboarding_autonomy_toggle.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from app.api.board_onboarding import _require_approval_for_done_from_draft + + +def test_require_approval_for_done_defaults_true_without_lead_agent_draft() -> None: + assert _require_approval_for_done_from_draft(None) is True + assert _require_approval_for_done_from_draft({}) is True + assert _require_approval_for_done_from_draft({"lead_agent": "invalid"}) is True + + +def test_require_approval_for_done_stays_enabled_for_non_fully_autonomous_modes() -> None: + assert ( + _require_approval_for_done_from_draft( + {"lead_agent": {"autonomy_level": "ask_first"}}, + ) + is True + ) + assert ( + _require_approval_for_done_from_draft( + {"lead_agent": {"autonomy_level": "balanced"}}, + ) + is True + ) + + +def test_require_approval_for_done_disables_for_fully_autonomous_choices() -> None: + assert ( + _require_approval_for_done_from_draft( + {"lead_agent": {"autonomy_level": "autonomous"}}, + ) + is False + ) + assert ( + _require_approval_for_done_from_draft( + {"lead_agent": {"autonomy_level": "fully-autonomous"}}, + ) + is False + ) + assert ( + _require_approval_for_done_from_draft( + {"lead_agent": {"identity_profile": {"autonomy_level": "fully autonomous"}}}, + ) + is False + ) diff --git a/backend/tests/test_board_schema.py b/backend/tests/test_board_schema.py index 5c27781e..35e60ed4 100644 --- a/backend/tests/test_board_schema.py +++ b/backend/tests/test_board_schema.py @@ -76,6 +76,28 @@ def test_board_update_rejects_empty_description_patch() -> None: BoardUpdate(description=" ") +def test_board_rule_toggles_have_expected_defaults() -> None: + """Boards should default to approval-gated done and optional review gating.""" + created = BoardCreate( + name="Ops Board", + slug="ops-board", + description="Operations workflow board.", + gateway_id=uuid4(), + ) + assert created.require_approval_for_done is True + assert created.require_review_before_done is False + assert created.block_status_changes_with_pending_approval is False + + updated = BoardUpdate( + require_approval_for_done=False, + require_review_before_done=True, + block_status_changes_with_pending_approval=True, + ) + assert updated.require_approval_for_done is False + assert updated.require_review_before_done is True + assert updated.block_status_changes_with_pending_approval is True + + def test_onboarding_confirm_requires_goal_fields() -> None: """Onboarding confirm should enforce goal fields for goal board types.""" with pytest.raises( diff --git a/backend/tests/test_tasks_done_approval_gate.py b/backend/tests/test_tasks_done_approval_gate.py new file mode 100644 index 00000000..ea042c63 --- /dev/null +++ b/backend/tests/test_tasks_done_approval_gate.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +from typing import Literal +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from sqlmodel import SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.api import tasks as tasks_api +from app.api.deps import ActorContext +from app.models.agents import Agent +from app.models.approval_task_links import ApprovalTaskLink +from app.models.approvals import Approval +from app.models.boards import Board +from app.models.gateways import Gateway +from app.models.organizations import Organization +from app.models.tasks import Task +from app.schemas.tasks import TaskUpdate + + +async def _make_engine() -> AsyncEngine: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.connect() as conn, conn.begin(): + await conn.run_sync(SQLModel.metadata.create_all) + return engine + + +async def _make_session(engine: AsyncEngine) -> AsyncSession: + return AsyncSession(engine, expire_on_commit=False) + + +async def _seed_board_task_and_agent( + session: AsyncSession, + *, + task_status: str = "review", + require_approval_for_done: bool = True, + require_review_before_done: bool = False, + block_status_changes_with_pending_approval: bool = False, +) -> tuple[Board, Task, Agent]: + organization_id = uuid4() + gateway = Gateway( + id=uuid4(), + organization_id=organization_id, + name="gateway", + url="https://gateway.local", + workspace_root="/tmp/workspace", + ) + board = Board( + id=uuid4(), + organization_id=organization_id, + gateway_id=gateway.id, + name="board", + slug=f"board-{uuid4()}", + require_approval_for_done=require_approval_for_done, + require_review_before_done=require_review_before_done, + block_status_changes_with_pending_approval=block_status_changes_with_pending_approval, + ) + task = Task(id=uuid4(), board_id=board.id, title="Task", status=task_status) + agent = Agent( + id=uuid4(), + board_id=board.id, + gateway_id=gateway.id, + name="agent", + status="online", + is_board_lead=False, + ) + + session.add(Organization(id=organization_id, name=f"org-{organization_id}")) + session.add(gateway) + session.add(board) + session.add(task) + session.add(agent) + await session.commit() + return board, task, agent + + +async def _update_task_to_done( + session: AsyncSession, + *, + task: Task, + agent: Agent, +) -> None: + await _update_task_status( + session, + task=task, + agent=agent, + status="done", + ) + + +async def _update_task_status( + session: AsyncSession, + *, + task: Task, + agent: Agent, + status: Literal["inbox", "in_progress", "review", "done"], +) -> None: + await tasks_api.update_task( + payload=TaskUpdate(status=status), + task=task, + session=session, + actor=ActorContext(actor_type="agent", agent=agent), + ) + + +@pytest.mark.asyncio +async def test_update_task_rejects_done_without_approved_linked_approval() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + board, task, agent = await _seed_board_task_and_agent(session) + session.add( + Approval( + id=uuid4(), + board_id=board.id, + task_id=task.id, + action_type="task.review", + confidence=65, + status="pending", + ), + ) + await session.commit() + + with pytest.raises(HTTPException) as exc: + await _update_task_to_done(session, task=task, agent=agent) + + assert exc.value.status_code == 409 + detail = exc.value.detail + assert isinstance(detail, dict) + assert detail["message"] == ( + "Task can only be marked done when a linked approval has been approved." + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_update_task_allows_done_with_approved_primary_task_approval() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + board, task, agent = await _seed_board_task_and_agent(session) + session.add( + Approval( + id=uuid4(), + board_id=board.id, + task_id=task.id, + action_type="task.review", + confidence=92, + status="approved", + ), + ) + await session.commit() + + updated = await tasks_api.update_task( + payload=TaskUpdate(status="done"), + task=task, + session=session, + actor=ActorContext(actor_type="agent", agent=agent), + ) + + assert updated.status == "done" + assert updated.assigned_agent_id == agent.id + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_update_task_allows_done_with_approved_multi_task_link() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + board, task, agent = await _seed_board_task_and_agent(session) + primary_task_id = uuid4() + session.add(Task(id=primary_task_id, board_id=board.id, title="Primary")) + + approval_id = uuid4() + session.add( + Approval( + id=approval_id, + board_id=board.id, + task_id=primary_task_id, + action_type="task.batch_review", + confidence=88, + status="approved", + ), + ) + await session.commit() + + session.add(ApprovalTaskLink(approval_id=approval_id, task_id=task.id)) + await session.commit() + + updated = await tasks_api.update_task( + payload=TaskUpdate(status="done"), + task=task, + session=session, + actor=ActorContext(actor_type="agent", agent=agent), + ) + + assert updated.status == "done" + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_update_task_allows_done_without_approval_when_board_toggle_disabled() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + _board, task, agent = await _seed_board_task_and_agent( + session, + require_approval_for_done=False, + ) + + updated = await tasks_api.update_task( + payload=TaskUpdate(status="done"), + task=task, + session=session, + actor=ActorContext(actor_type="agent", agent=agent), + ) + + assert updated.status == "done" + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_update_task_rejects_done_from_in_progress_when_review_toggle_enabled() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + _board, task, agent = await _seed_board_task_and_agent( + session, + task_status="in_progress", + require_approval_for_done=False, + require_review_before_done=True, + ) + + with pytest.raises(HTTPException) as exc: + await _update_task_to_done(session, task=task, agent=agent) + + assert exc.value.status_code == 409 + detail = exc.value.detail + assert isinstance(detail, dict) + assert detail["message"] == ( + "Task can only be marked done from review when the board rule is enabled." + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_update_task_allows_done_from_review_when_review_toggle_enabled() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + _board, task, agent = await _seed_board_task_and_agent( + session, + task_status="review", + require_approval_for_done=False, + require_review_before_done=True, + ) + + updated = await tasks_api.update_task( + payload=TaskUpdate(status="done"), + task=task, + session=session, + actor=ActorContext(actor_type="agent", agent=agent), + ) + + assert updated.status == "done" + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_update_task_rejects_status_change_with_pending_approval_when_toggle_enabled() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + board, task, agent = await _seed_board_task_and_agent( + session, + task_status="inbox", + require_approval_for_done=False, + block_status_changes_with_pending_approval=True, + ) + session.add( + Approval( + id=uuid4(), + board_id=board.id, + task_id=task.id, + action_type="task.execute", + confidence=70, + status="pending", + ), + ) + await session.commit() + + with pytest.raises(HTTPException) as exc: + await _update_task_status( + session, + task=task, + agent=agent, + status="in_progress", + ) + + assert exc.value.status_code == 409 + detail = exc.value.detail + assert isinstance(detail, dict) + assert detail["message"] == ( + "Task status cannot be changed while a linked approval is pending." + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_update_task_allows_status_change_with_pending_approval_when_toggle_disabled() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + board, task, agent = await _seed_board_task_and_agent( + session, + task_status="inbox", + require_approval_for_done=False, + block_status_changes_with_pending_approval=False, + ) + session.add( + Approval( + id=uuid4(), + board_id=board.id, + task_id=task.id, + action_type="task.execute", + confidence=70, + status="pending", + ), + ) + await session.commit() + + updated = await tasks_api.update_task( + payload=TaskUpdate(status="in_progress"), + task=task, + session=session, + actor=ActorContext(actor_type="agent", agent=agent), + ) + + assert updated.status == "in_progress" + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_update_task_rejects_status_change_for_pending_multi_task_link_when_toggle_enabled() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + board, task, agent = await _seed_board_task_and_agent( + session, + task_status="inbox", + require_approval_for_done=False, + block_status_changes_with_pending_approval=True, + ) + primary_task_id = uuid4() + session.add(Task(id=primary_task_id, board_id=board.id, title="Primary")) + + approval_id = uuid4() + session.add( + Approval( + id=approval_id, + board_id=board.id, + task_id=primary_task_id, + action_type="task.batch_execute", + confidence=73, + status="pending", + ), + ) + await session.commit() + + session.add(ApprovalTaskLink(approval_id=approval_id, task_id=task.id)) + await session.commit() + + with pytest.raises(HTTPException) as exc: + await _update_task_status( + session, + task=task, + agent=agent, + status="in_progress", + ) + + assert exc.value.status_code == 409 + finally: + await engine.dispose() diff --git a/frontend/src/api/generated/model/boardCreate.ts b/frontend/src/api/generated/model/boardCreate.ts index 421ae79d..8850b1d2 100644 --- a/frontend/src/api/generated/model/boardCreate.ts +++ b/frontend/src/api/generated/model/boardCreate.ts @@ -21,4 +21,7 @@ export interface BoardCreate { target_date?: string | null; goal_confirmed?: boolean; goal_source?: string | null; + require_approval_for_done?: boolean; + require_review_before_done?: boolean; + block_status_changes_with_pending_approval?: boolean; } diff --git a/frontend/src/api/generated/model/boardRead.ts b/frontend/src/api/generated/model/boardRead.ts index 11f75e2f..2bd5231a 100644 --- a/frontend/src/api/generated/model/boardRead.ts +++ b/frontend/src/api/generated/model/boardRead.ts @@ -21,6 +21,9 @@ export interface BoardRead { target_date?: string | null; goal_confirmed?: boolean; goal_source?: string | null; + require_approval_for_done?: boolean; + require_review_before_done?: boolean; + block_status_changes_with_pending_approval?: boolean; id: string; organization_id: string; created_at: string; diff --git a/frontend/src/api/generated/model/boardUpdate.ts b/frontend/src/api/generated/model/boardUpdate.ts index 5cefd42f..b70f8bac 100644 --- a/frontend/src/api/generated/model/boardUpdate.ts +++ b/frontend/src/api/generated/model/boardUpdate.ts @@ -21,4 +21,7 @@ export interface BoardUpdate { target_date?: string | null; goal_confirmed?: boolean | null; goal_source?: string | null; + require_approval_for_done?: boolean | null; + require_review_before_done?: boolean | null; + block_status_changes_with_pending_approval?: boolean | null; } diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx index 6a5e561d..2320c24b 100644 --- a/frontend/src/app/boards/[boardId]/edit/page.tsx +++ b/frontend/src/app/boards/[boardId]/edit/page.tsx @@ -72,6 +72,16 @@ export default function EditBoardPage() { ); const [boardType, setBoardType] = useState(undefined); const [objective, setObjective] = useState(undefined); + const [requireApprovalForDone, setRequireApprovalForDone] = useState< + boolean | undefined + >(undefined); + const [requireReviewBeforeDone, setRequireReviewBeforeDone] = useState< + boolean | undefined + >(undefined); + const [ + blockStatusChangesWithPendingApproval, + setBlockStatusChangesWithPendingApproval, + ] = useState(undefined); const [successMetrics, setSuccessMetrics] = useState( undefined, ); @@ -189,6 +199,14 @@ export default function EditBoardPage() { boardGroupId ?? baseBoard?.board_group_id ?? "none"; const resolvedBoardType = boardType ?? baseBoard?.board_type ?? "goal"; const resolvedObjective = objective ?? baseBoard?.objective ?? ""; + const resolvedRequireApprovalForDone = + requireApprovalForDone ?? baseBoard?.require_approval_for_done ?? true; + const resolvedRequireReviewBeforeDone = + requireReviewBeforeDone ?? baseBoard?.require_review_before_done ?? false; + const resolvedBlockStatusChangesWithPendingApproval = + blockStatusChangesWithPendingApproval ?? + baseBoard?.block_status_changes_with_pending_approval ?? + false; const resolvedSuccessMetrics = successMetrics ?? (baseBoard?.success_metrics @@ -238,6 +256,11 @@ export default function EditBoardPage() { setDescription(updated.description ?? ""); setBoardType(updated.board_type ?? "goal"); setObjective(updated.objective ?? ""); + setRequireApprovalForDone(updated.require_approval_for_done ?? true); + setRequireReviewBeforeDone(updated.require_review_before_done ?? false); + setBlockStatusChangesWithPendingApproval( + updated.block_status_changes_with_pending_approval ?? false, + ); setSuccessMetrics( updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) @@ -271,7 +294,10 @@ export default function EditBoardPage() { setMetricsError(null); let parsedMetrics: Record | null = null; - if (resolvedSuccessMetrics.trim()) { + if ( + resolvedBoardType !== "general" && + resolvedSuccessMetrics.trim() + ) { try { parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record< string, @@ -291,9 +317,19 @@ export default function EditBoardPage() { board_group_id: resolvedBoardGroupId === "none" ? null : resolvedBoardGroupId, board_type: resolvedBoardType, - objective: resolvedObjective.trim() || null, - success_metrics: parsedMetrics, - target_date: localDateInputToUtcIso(resolvedTargetDate), + objective: + resolvedBoardType === "general" + ? null + : resolvedObjective.trim() || null, + require_approval_for_done: resolvedRequireApprovalForDone, + require_review_before_done: resolvedRequireReviewBeforeDone, + block_status_changes_with_pending_approval: + resolvedBlockStatusChangesWithPendingApproval, + success_metrics: resolvedBoardType === "general" ? null : parsedMetrics, + target_date: + resolvedBoardType === "general" + ? null + : localDateInputToUtcIso(resolvedTargetDate), }; updateBoardMutation.mutate({ boardId, data: payload }); @@ -408,17 +444,19 @@ export default function EditBoardPage() { agents.

-
- - setTargetDate(event.target.value)} - disabled={isLoading} - /> -
+ {resolvedBoardType !== "general" ? ( +
+ + setTargetDate(event.target.value)} + disabled={isLoading} + /> +
+ ) : null}
@@ -434,37 +472,155 @@ export default function EditBoardPage() { />
-
- -