"""Board CRUD and snapshot endpoints.""" from __future__ import annotations from typing import TYPE_CHECKING from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func from sqlmodel import col, select from app.api.deps import ( get_board_for_actor_read, get_board_for_user_read, get_board_for_user_write, require_org_admin, require_org_member, ) from app.core.time import utcnow from app.db import crud from app.db.pagination import paginate from app.db.session import get_session from app.models.activity_events import ActivityEvent from app.models.agents import Agent from app.models.approvals import Approval from app.models.board_groups import BoardGroup from app.models.board_memory import BoardMemory from app.models.board_onboarding import BoardOnboardingSession from app.models.boards import Board from app.models.gateways import Gateway from app.models.organization_board_access import OrganizationBoardAccess from app.models.organization_invite_board_access import OrganizationInviteBoardAccess from app.models.task_dependencies import TaskDependency from app.models.task_fingerprints import TaskFingerprint from app.models.tasks import Task from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate from app.schemas.common import OkResponse from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot from app.services.board_group_snapshot import build_board_group_snapshot from app.services.board_lifecycle import delete_board as delete_board_service from app.services.board_snapshot import build_board_snapshot from app.services.organizations import OrganizationContext, board_access_filter if TYPE_CHECKING: from fastapi_pagination.limit_offset import LimitOffsetPage from sqlmodel.ext.asyncio.session import AsyncSession router = APIRouter(prefix="/boards", tags=["boards"]) SESSION_DEP = Depends(get_session) ORG_ADMIN_DEP = Depends(require_org_admin) ORG_MEMBER_DEP = Depends(require_org_member) BOARD_USER_READ_DEP = Depends(get_board_for_user_read) BOARD_USER_WRITE_DEP = Depends(get_board_for_user_write) BOARD_ACTOR_READ_DEP = Depends(get_board_for_actor_read) GATEWAY_ID_QUERY = Query(default=None) BOARD_GROUP_ID_QUERY = Query(default=None) INCLUDE_SELF_QUERY = Query(default=False) INCLUDE_DONE_QUERY = Query(default=False) PER_BOARD_TASK_LIMIT_QUERY = Query(default=5, ge=0, le=100) async def _require_gateway( session: AsyncSession, gateway_id: object, *, organization_id: UUID | None = None, ) -> Gateway: gateway = await crud.get_by_id(session, Gateway, gateway_id) if gateway is None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="gateway_id is invalid", ) if organization_id is not None and gateway.organization_id != organization_id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="gateway_id is invalid", ) return gateway async def _require_gateway_for_create( payload: BoardCreate, ctx: OrganizationContext = ORG_ADMIN_DEP, session: AsyncSession = SESSION_DEP, ) -> Gateway: return await _require_gateway( session, payload.gateway_id, organization_id=ctx.organization.id, ) async def _require_board_group( session: AsyncSession, board_group_id: object, *, organization_id: UUID | None = None, ) -> BoardGroup: group = await crud.get_by_id(session, BoardGroup, board_group_id) if group is None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="board_group_id is invalid", ) if organization_id is not None and group.organization_id != organization_id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="board_group_id is invalid", ) return group async def _require_board_group_for_create( payload: BoardCreate, ctx: OrganizationContext = ORG_ADMIN_DEP, session: AsyncSession = SESSION_DEP, ) -> BoardGroup | None: if payload.board_group_id is None: return None return await _require_board_group( session, payload.board_group_id, organization_id=ctx.organization.id, ) GATEWAY_CREATE_DEP = Depends(_require_gateway_for_create) BOARD_GROUP_CREATE_DEP = Depends(_require_board_group_for_create) async def _apply_board_update( *, payload: BoardUpdate, session: AsyncSession, board: Board, ) -> Board: updates = payload.model_dump(exclude_unset=True) if "gateway_id" in updates: await _require_gateway( session, updates["gateway_id"], organization_id=board.organization_id, ) if "board_group_id" in updates and updates["board_group_id"] is not None: await _require_board_group( session, updates["board_group_id"], organization_id=board.organization_id, ) crud.apply_updates(board, updates) if updates.get("board_type") == "goal" and (not board.objective or not board.success_metrics): # Validate only when explicitly switching to goal boards. raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Goal boards require objective and success_metrics", ) if not board.gateway_id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="gateway_id is required", ) board.updated_at = utcnow() return await crud.save(session, board) @router.get("", response_model=DefaultLimitOffsetPage[BoardRead]) async def list_boards( gateway_id: UUID | None = GATEWAY_ID_QUERY, board_group_id: UUID | None = BOARD_GROUP_ID_QUERY, session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> LimitOffsetPage[BoardRead]: """List boards visible to the current organization member.""" statement = select(Board).where(board_access_filter(ctx.member, write=False)) if gateway_id is not None: statement = statement.where(col(Board.gateway_id) == gateway_id) if board_group_id is not None: statement = statement.where(col(Board.board_group_id) == board_group_id) statement = statement.order_by( func.lower(col(Board.name)).asc(), col(Board.created_at).desc(), ) return await paginate(session, statement) @router.post("", response_model=BoardRead) async def create_board( payload: BoardCreate, _gateway: Gateway = GATEWAY_CREATE_DEP, _board_group: BoardGroup | None = BOARD_GROUP_CREATE_DEP, session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_ADMIN_DEP, ) -> Board: """Create a board in the active organization.""" data = payload.model_dump() data["organization_id"] = ctx.organization.id return await crud.create(session, Board, **data) @router.get("/{board_id}", response_model=BoardRead) def get_board( board: Board = BOARD_USER_READ_DEP, ) -> Board: """Get a board by id.""" return board @router.get("/{board_id}/snapshot", response_model=BoardSnapshot) async def get_board_snapshot( board: Board = BOARD_ACTOR_READ_DEP, session: AsyncSession = SESSION_DEP, ) -> BoardSnapshot: """Get a board snapshot view model.""" return await build_board_snapshot(session, board) @router.get("/{board_id}/group-snapshot", response_model=BoardGroupSnapshot) async def get_board_group_snapshot( *, include_self: bool = INCLUDE_SELF_QUERY, include_done: bool = INCLUDE_DONE_QUERY, per_board_task_limit: int = PER_BOARD_TASK_LIMIT_QUERY, board: Board = BOARD_ACTOR_READ_DEP, session: AsyncSession = SESSION_DEP, ) -> BoardGroupSnapshot: """Get a grouped snapshot across related boards.""" return await build_board_group_snapshot( session, board=board, include_self=include_self, include_done=include_done, per_board_task_limit=per_board_task_limit, ) @router.patch("/{board_id}", response_model=BoardRead) async def update_board( payload: BoardUpdate, session: AsyncSession = SESSION_DEP, board: Board = BOARD_USER_WRITE_DEP, ) -> Board: """Update mutable board properties.""" return await _apply_board_update(payload=payload, session=session, board=board) @router.delete("/{board_id}", response_model=OkResponse) async def delete_board( session: AsyncSession = SESSION_DEP, board: Board = BOARD_USER_WRITE_DEP, ) -> OkResponse: """Delete a board and all dependent records.""" return await delete_board_service(session, board=board)