From e2cdfac1c3deb4f5379f5b5c0d7ed7d9b6f72a1e Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 02:55:49 +0000 Subject: [PATCH] feat(psa): update search endpoint for pagination, add create/status/resource/priority endpoints Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/endpoints/integrations.py | 147 +++++++++++++++++++--- 1 file changed, 130 insertions(+), 17 deletions(-) diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index 48fa9f57..88e9e91d 100644 --- a/backend/app/api/endpoints/integrations.py +++ b/backend/app/api/endpoints/integrations.py @@ -30,6 +30,14 @@ from app.schemas.psa_connection import ( PSABoardResponse, ) from app.core.config import settings +from app.schemas.psa_tickets import ( + PSAResourceSchema, + PSATicketCreatedSchema, + PSATicketStatusUpdateSchema, + TicketCreatePayloadSchema, + PSAPrioritySchema, +) +import app.services.ticket_service as ticket_svc from app.services.psa.encryption import ( decrypt_credentials, encrypt_credentials, @@ -367,7 +375,7 @@ async def list_boards( return [] -@router.get("/tickets/search", response_model=list[PSATicketSearchResult]) +@router.get("/tickets/search") async def search_tickets( current_user: Annotated[User, Depends(require_engineer_or_admin)], db: Annotated[AsyncSession, Depends(get_db)], @@ -378,17 +386,18 @@ async def search_tickets( assigned_to_me: bool = False, unassigned: bool = False, board_ids: str = "", + priority: str | None = None, + company_id: int | None = None, page: int = 1, - page_size: int = 10, + page_size: int = 25, ): - """Search ConnectWise tickets.""" + """Search ConnectWise tickets — returns paginated TicketListResponse.""" 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_identifier (CW login name for resources contains filter) member_identifier: str | None = None if assigned_to_me: conn_result = await db.execute( @@ -407,23 +416,18 @@ async def search_tickets( ) mapping = mapping_result.scalar_one_or_none() if not mapping: - # No mapping for this user — return empty list - return [] - - from app.services.psa.registry import get_provider_for_account as _get_provider - from app.services.psa.exceptions import PSAError as _PSAError + return {"items": [], "total": 0, "page": page, "page_size": page_size} try: - _provider = await _get_provider(current_user.account_id, db) + _provider = await get_provider_for_account(current_user.account_id, db) cw_members = await _provider.list_members() matched = next((m for m in cw_members if m.id == mapping.external_member_id), None) if matched: member_identifier = matched.identifier else: - return [] - except _PSAError: - return [] + return {"items": [], "total": 0, "page": page, "page_size": page_size} + except PSAError: + return {"items": [], "total": 0, "page": page, "page_size": page_size} - # Parse comma-separated board_ids parsed_board_ids: list[int] = [] if board_ids: try: @@ -433,7 +437,7 @@ async def search_tickets( try: provider = await get_provider_for_account(current_user.account_id, db) - tickets = await provider.search_tickets( + result = await provider.search_tickets( query, board_id=board_id, status_id=status_id, @@ -441,25 +445,134 @@ async def search_tickets( member_identifier=member_identifier, unassigned=unassigned, board_ids=parsed_board_ids, + company_id=company_id, page=page, page_size=page_size, ) - return [ + items = [ PSATicketSearchResult( id=t.id, summary=t.summary, company_name=t.company_name, + company_id=t.company_id, board_name=t.board_name, + board_id=t.board_id, status_name=t.status_name, + status_id=t.status_id, priority_name=t.priority_name, + priority_id=t.priority_id, closed=t.closed, ) - for t in tickets + for t in result.items ] + return {"items": items, "total": result.total, "page": result.page, "page_size": result.page_size} except PSAError as e: raise HTTPException(status_code=502, detail=str(e)) +@router.post("/tickets", response_model=PSATicketCreatedSchema, status_code=201) +async def create_ticket( + data: TicketCreatePayloadSchema, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Create a new PSA ticket.""" + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + from app.services.psa.exceptions import PSAError + from app.services.psa.types import TicketCreatePayload + try: + return await ticket_svc.create_ticket( + current_user.account_id, + TicketCreatePayload(**data.model_dump()), + db, + ) + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema) +async def update_ticket_status_endpoint( + ticket_id: int, + status_id: int, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Update a ticket's status.""" + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + from app.services.psa.exceptions import PSAError + try: + return await ticket_svc.update_status(current_user.account_id, ticket_id, status_id, db) + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/tickets/{ticket_id}/resources", response_model=list[PSAResourceSchema]) +async def list_ticket_resources( + ticket_id: int, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + from app.services.psa.exceptions import PSAError + try: + return await ticket_svc.list_resources(current_user.account_id, ticket_id, db) + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/tickets/{ticket_id}/resources", response_model=PSAResourceSchema, status_code=201) +async def add_ticket_resource( + ticket_id: int, + member_id: int, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + from app.services.psa.exceptions import PSAError + try: + return await ticket_svc.add_resource(current_user.account_id, ticket_id, member_id, db) + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.delete("/tickets/{ticket_id}/resources/{member_id}", status_code=204) +async def remove_ticket_resource( + ticket_id: int, + member_id: int, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + from app.services.psa.exceptions import PSAError + try: + await ticket_svc.remove_resource(current_user.account_id, ticket_id, member_id, db) + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/priorities", response_model=list[PSAPrioritySchema]) +async def list_priorities( + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """List PSA priority levels for ticket creation form.""" + 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) + raw = await provider.list_priorities() + return [PSAPrioritySchema(id=p["id"], name=p["name"]) for p in raw if p.get("id")] + except PSAError: + return [] + + @router.get("/tickets/{ticket_id}/context") async def get_ticket_context( ticket_id: int,