From c1da853d01d02b202bde7a66e0755e6671c8bc38 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 23:07:03 -0400 Subject: [PATCH] feat(psa): add ticket search and status API endpoints Add three new endpoints under /integrations/psa: - GET /tickets/search: search CW tickets with query, board_id, status_id filters - GET /tickets/{ticket_id}: fetch a single ticket by ID - GET /tickets/{ticket_id}/statuses: get available statuses for a ticket's board Add PSATicketSearchResult and PSATicketStatusItem schemas. All endpoints require engineer_or_admin auth. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/integrations.py | 96 ++++++++++++++++++++++- backend/app/schemas/__init__.py | 2 + backend/app/schemas/psa_connection.py | 19 +++++ 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index d1a579e7..525ac035 100644 --- a/backend/app/api/endpoints/integrations.py +++ b/backend/app/api/endpoints/integrations.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps import get_current_active_user, require_account_owner +from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin from app.core.database import get_db from app.models.psa_connection import PsaConnection from app.models.user import User @@ -18,6 +18,8 @@ from app.schemas.psa_connection import ( PsaConnectionResponse, PsaConnectionTestResponse, PsaConnectionUpdate, + PSATicketSearchResult, + PSATicketStatusItem, ) from app.services.psa.encryption import ( decrypt_credentials, @@ -260,10 +262,102 @@ async def test_connection( if result.success: conn.last_validated_at = datetime.now(timezone.utc) await db.commit() + # Invalidate cached PSA data when connection is re-validated + from app.services.psa.cache import psa_cache + psa_cache.clear() return result +# ── ticket / status / company endpoints ────────────────────────── + + +@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, +): + """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 + + 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 + ) + return [ + PSATicketSearchResult( + id=t.id, + summary=t.summary, + company_name=t.company_name, + board_name=t.board_name, + status_name=t.status_name, + priority_name=t.priority_name, + closed=t.closed, + ) + for t in tickets + ] + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/tickets/{ticket_id}") +async def get_ticket( + ticket_id: str, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Get a single CW ticket by ID.""" + 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, PSANotFoundError + + try: + provider = await get_provider_for_account(current_user.account_id, db) + ticket = await provider.get_ticket(ticket_id) + return ticket + except PSANotFoundError: + raise HTTPException(status_code=404, detail="Ticket not found") + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/tickets/{ticket_id}/statuses", response_model=list[PSATicketStatusItem]) +async def get_ticket_statuses( + ticket_id: str, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Get available statuses for a ticket's board.""" + 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, PSANotFoundError + + try: + provider = await get_provider_for_account(current_user.account_id, db) + ticket = await provider.get_ticket(ticket_id) + if not ticket.board_id: + raise HTTPException(status_code=400, detail="Ticket has no board") + statuses = await provider.get_ticket_statuses(ticket.board_id) + return [PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed) for s in statuses] + except PSANotFoundError: + raise HTTPException(status_code=404, detail="Ticket not found") + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + # ── internal helpers ───────────────────────────────────────────────── async def _get_connection_or_404( diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index b02d0cc6..ba5ea284 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -17,6 +17,7 @@ from .script_template import ( ) from .psa_connection import ( PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionResponse, PsaConnectionTestResponse, + PSATicketSearchResult, PSATicketStatusItem, ) __all__ = [ @@ -44,4 +45,5 @@ __all__ = [ "ScriptGenerateRequest", "ScriptGenerateResponse", "ScriptGenerationRecord", # PSA Connection "PsaConnectionCreate", "PsaConnectionUpdate", "PsaConnectionResponse", "PsaConnectionTestResponse", + "PSATicketSearchResult", "PSATicketStatusItem", ] diff --git a/backend/app/schemas/psa_connection.py b/backend/app/schemas/psa_connection.py index b9591e9a..10ccf172 100644 --- a/backend/app/schemas/psa_connection.py +++ b/backend/app/schemas/psa_connection.py @@ -45,3 +45,22 @@ class PsaConnectionTestResponse(BaseModel): success: bool message: str server_version: str | None = None + + +# ── Ticket search & status schemas ──────────────────────────────── + + +class PSATicketSearchResult(BaseModel): + id: str + summary: str + company_name: str | None = None + board_name: str | None = None + status_name: str | None = None + priority_name: str | None = None + closed: bool = False + + +class PSATicketStatusItem(BaseModel): + id: int + name: str + is_closed: bool = False