feat(psa): ticket queue dashboard with board selector and session auto-start
Some checks failed
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Successful in 2s

- Add PSABoard type + list_boards() to CW provider (cached 1h)
- Extend search_tickets with assigned_to_me, unassigned, board_ids, page, page_size
- New GET /integrations/psa/boards endpoint
- New TicketQueue dashboard component: My Tickets / Unassigned tabs,
  multi-select board filter, Load more pagination, Start Session per ticket
- Add TicketQueue to QuickStartPage after active sessions
- FlowPilotSessionPage auto-starts with ticket context when navigated
  from TicketQueue (psaTicketId + psaTicket in location.state)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 03:20:45 +00:00
parent b18072e24b
commit 346576a730
14 changed files with 595 additions and 16 deletions

View File

@@ -27,6 +27,7 @@ from app.schemas.psa_connection import (
PsaMemberMappingSaveRequest,
PsaMemberResponse,
AutoMatchResult,
PSABoardResponse,
)
from app.core.config import settings
from app.services.psa.encryption import (
@@ -345,26 +346,91 @@ async def update_flowpilot_settings(
# ── ticket / status / company endpoints ──────────────────────────
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
async def search_tickets(
@router.get("/boards", response_model=list[PSABoardResponse])
async def list_boards(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
query: str = "",
board_id: int | None = None,
status_id: int | None = None,
include_closed: bool = False,
):
"""Search ConnectWise tickets."""
"""List PSA service boards."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
try:
provider = await get_provider_for_account(current_user.account_id, db)
boards = await provider.list_boards()
return [PSABoardResponse(id=b.id, name=b.name) for b in boards]
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
async def search_tickets(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
query: str = "",
board_id: int | None = None,
status_id: int | None = None,
include_closed: bool = False,
assigned_to_me: bool = False,
unassigned: bool = False,
board_ids: str = "",
page: int = 1,
page_size: int = 10,
):
"""Search ConnectWise tickets."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
# Resolve assigned_to_me → member_id
member_id: str | None = None
if assigned_to_me:
conn_result = await db.execute(
select(PsaConnection).where(
PsaConnection.account_id == current_user.account_id,
PsaConnection.is_active.is_(True),
)
)
conn = conn_result.scalar_one_or_none()
if conn:
mapping_result = await db.execute(
select(PsaMemberMapping).where(
PsaMemberMapping.psa_connection_id == conn.id,
PsaMemberMapping.user_id == current_user.id,
)
)
mapping = mapping_result.scalar_one_or_none()
if mapping:
member_id = mapping.external_member_id
else:
# No mapping for this user — return empty list
return []
# Parse comma-separated board_ids
parsed_board_ids: list[int] = []
if board_ids:
try:
parsed_board_ids = [int(bid.strip()) for bid in board_ids.split(",") if bid.strip()]
except ValueError:
raise HTTPException(status_code=400, detail="board_ids must be comma-separated integers")
try:
provider = await get_provider_for_account(current_user.account_id, db)
tickets = await provider.search_tickets(
query, board_id=board_id, status_id=status_id, include_closed=include_closed
query,
board_id=board_id,
status_id=status_id,
include_closed=include_closed,
member_id=member_id,
unassigned=unassigned,
board_ids=parsed_board_ids,
page=page,
page_size=page_size,
)
return [
PSATicketSearchResult(

View File

@@ -20,6 +20,7 @@ from .psa_connection import (
PSATicketSearchResult, PSATicketStatusItem,
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
PSABoardResponse,
)
__all__ = [
@@ -50,4 +51,5 @@ __all__ = [
"PSATicketSearchResult", "PSATicketStatusItem",
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
"PSABoardResponse",
]

View File

@@ -136,3 +136,8 @@ class PsaMemberResponse(BaseModel):
class AutoMatchResult(BaseModel):
matched: list[PsaMemberMappingResponse]
unmatched_users: int
class PSABoardResponse(BaseModel):
id: int
name: str

View File

@@ -11,6 +11,7 @@ from app.services.psa.types import (
PSAMember,
PSAConfiguration,
PSATimeEntry,
PSABoard,
)
@@ -58,6 +59,9 @@ class AutotaskProvider(PSAProvider):
async def list_members(self) -> list[PSAMember]:
raise NotImplementedError("Autotask integration coming soon")
async def list_boards(self) -> list[PSABoard]:
raise NotImplementedError("list_boards not implemented for this provider")
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
raise NotImplementedError("Autotask integration coming soon")

View File

@@ -12,6 +12,7 @@ from .types import (
PSAMember,
PSAConfiguration,
PSATimeEntry,
PSABoard,
)
@@ -64,6 +65,10 @@ class PSAProvider(ABC):
async def list_members(self) -> list[PSAMember]:
...
@abstractmethod
async def list_boards(self) -> list[PSABoard]:
...
@abstractmethod
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
...

View File

@@ -16,6 +16,7 @@ from app.services.psa.types import (
PSAMember,
PSAConfiguration,
PSATimeEntry,
PSABoard,
)
from .client import ConnectWiseClient
@@ -55,11 +56,16 @@ class ConnectWiseProvider(PSAProvider):
return self._map_ticket(data)
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
"""Search CW tickets by summary. Supports board_id and status_id filters."""
"""Search CW tickets by summary. Supports board_id, status_id, member_id,
unassigned, board_ids, page, and page_size filters."""
page_size = filters.get("page_size", 10)
page = filters.get("page", 1)
params: dict = {
"fields": "id,summary,company,board,status,priority,closedFlag",
"orderBy": "id desc",
"pageSize": 25,
"pageSize": page_size,
"page": page,
}
# Build CW condition query
@@ -72,6 +78,14 @@ class ConnectWiseProvider(PSAProvider):
conditions.append(f"status/id = {filters['status_id']}")
if not filters.get("include_closed", False):
conditions.append("closedFlag = false")
if filters.get("member_id") is not None:
conditions.append(f"resources/member/id = {filters['member_id']}")
if filters.get("unassigned", False):
conditions.append("resources = null")
board_ids: list[int] = filters.get("board_ids") or []
if board_ids:
board_cond = " or ".join(f"board/id = {bid}" for bid in board_ids)
conditions.append(f"({board_cond})")
if conditions:
params["conditions"] = " and ".join(conditions)
@@ -270,6 +284,32 @@ class ConnectWiseProvider(PSAProvider):
psa_cache.set(cache_key, result, ttl_seconds=900)
return result
async def list_boards(self) -> list[PSABoard]:
"""List active CW service boards (cached 1 hour)."""
cache_key = "boards"
cached = psa_cache.get(cache_key)
if cached is not None:
return cached
data = await self.client.get(
"/service/boards",
params={
"fields": "id,name,inactiveFlag",
"conditions": "inactiveFlag = false",
"pageSize": 100,
},
)
result = [
PSABoard(
id=b["id"],
name=b["name"],
inactive=b.get("inactiveFlag", False),
)
for b in (data if isinstance(data, list) else [])
]
psa_cache.set(cache_key, result, ttl_seconds=3600)
return result
# ── Ticket Context ────────────────────────────────────────────────
async def get_ticket_context(

View File

@@ -11,6 +11,7 @@ from app.services.psa.types import (
PSAMember,
PSAConfiguration,
PSATimeEntry,
PSABoard,
)
@@ -58,6 +59,9 @@ class HaloPSAProvider(PSAProvider):
async def list_members(self) -> list[PSAMember]:
raise NotImplementedError("Halo PSA integration coming soon")
async def list_boards(self) -> list[PSABoard]:
raise NotImplementedError("list_boards not implemented for this provider")
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
raise NotImplementedError("Halo PSA integration coming soon")

View File

@@ -67,6 +67,12 @@ class PSATimeEntry(BaseModel):
created_at: str | None = None
class PSABoard(BaseModel):
id: int
name: str
inactive: bool = False
class NoteType:
INTERNAL_ANALYSIS = "internal_analysis"
RESOLUTION = "resolution"