From d2edb9e3ce2a3b7dc3970df9e1464379d93d5f5f Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 21:46:12 -0400 Subject: [PATCH] =?UTF-8?q?feat(psa):=20add=20PSA=20abstraction=20layer=20?= =?UTF-8?q?=E2=80=94=20base=20types,=20exceptions,=20abstract=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/services/psa/__init__.py | 1 + backend/app/services/psa/base.py | 68 ++++++++++++++++++++++++++ backend/app/services/psa/exceptions.py | 45 +++++++++++++++++ backend/app/services/psa/types.py | 63 ++++++++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 backend/app/services/psa/__init__.py create mode 100644 backend/app/services/psa/base.py create mode 100644 backend/app/services/psa/exceptions.py create mode 100644 backend/app/services/psa/types.py diff --git a/backend/app/services/psa/__init__.py b/backend/app/services/psa/__init__.py new file mode 100644 index 00000000..38a2e04e --- /dev/null +++ b/backend/app/services/psa/__init__.py @@ -0,0 +1 @@ +"""PSA integration abstraction layer.""" diff --git a/backend/app/services/psa/base.py b/backend/app/services/psa/base.py new file mode 100644 index 00000000..e2230aa0 --- /dev/null +++ b/backend/app/services/psa/base.py @@ -0,0 +1,68 @@ +"""Abstract base class for PSA provider implementations.""" +from __future__ import annotations + +from abc import ABC, abstractmethod + +from .types import ( + ConnectionTestResult, + PSATicket, + PSANote, + PSAStatus, + PSACompany, + PSAMember, + PSAConfiguration, +) + + +class PSAProvider(ABC): + """Abstract base for PSA integrations (ConnectWise, Autotask, etc.).""" + + @abstractmethod + async def test_connection(self) -> ConnectionTestResult: + ... + + @abstractmethod + async def get_ticket(self, ticket_id: str) -> PSATicket: + ... + + @abstractmethod + async def search_tickets(self, query: str, **filters) -> list[PSATicket]: + ... + + @abstractmethod + async def post_note( + self, + ticket_id: str, + text: str, + note_type: str, + member_id: str | None = None, + ) -> PSANote: + ... + + @abstractmethod + async def update_ticket_status( + self, + ticket_id: str, + status_id: int, + ) -> PSATicket: + ... + + @abstractmethod + async def get_ticket_statuses(self, board_id: int) -> list[PSAStatus]: + ... + + @abstractmethod + async def list_companies(self, **filters) -> list[PSACompany]: + ... + + @abstractmethod + async def get_company(self, company_id: str) -> PSACompany: + ... + + @abstractmethod + async def list_members(self) -> list[PSAMember]: + ... + + @abstractmethod + async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]: + ... diff --git a/backend/app/services/psa/exceptions.py b/backend/app/services/psa/exceptions.py new file mode 100644 index 00000000..1a9edc6d --- /dev/null +++ b/backend/app/services/psa/exceptions.py @@ -0,0 +1,45 @@ +"""Typed exceptions for PSA integration errors.""" + + +class PSAError(Exception): + """Base exception for all PSA integration errors.""" + def __init__(self, message: str, provider: str = "unknown"): + self.provider = provider + super().__init__(message) + + +class PSAAuthError(PSAError): + """Invalid or expired credentials.""" + pass + + +class PSAPermissionError(PSAError): + """Insufficient permissions on the PSA side.""" + pass + + +class PSANotFoundError(PSAError): + """Requested resource (ticket, company, etc.) not found.""" + pass + + +class PSARateLimitError(PSAError): + """Rate limit exceeded. retry_after_seconds may be set.""" + def __init__(self, message: str, retry_after_seconds: int | None = None, provider: str = "unknown"): + self.retry_after_seconds = retry_after_seconds + super().__init__(message, provider) + + +class PSAServerError(PSAError): + """Remote PSA server error (5xx).""" + pass + + +class PSATimeoutError(PSAError): + """Request to PSA timed out.""" + pass + + +class PSAConnectionError(PSAError): + """Cannot reach the PSA server.""" + pass diff --git a/backend/app/services/psa/types.py b/backend/app/services/psa/types.py new file mode 100644 index 00000000..9515ab6c --- /dev/null +++ b/backend/app/services/psa/types.py @@ -0,0 +1,63 @@ +"""Provider-agnostic PSA data types.""" +from __future__ import annotations + +from pydantic import BaseModel + + +class ConnectionTestResult(BaseModel): + success: bool + message: str + server_version: str | None = None + + +class PSATicket(BaseModel): + id: str + summary: str + company_name: str | None = None + company_id: str | None = None + board_name: str | None = None + board_id: int | None = None + status_name: str | None = None + status_id: int | None = None + priority_name: str | None = None + priority_id: int | None = None + closed: bool = False + + +class PSANote(BaseModel): + id: str + text: str + note_type: str + created_at: str | None = None + + +class PSAStatus(BaseModel): + id: int + name: str + is_closed: bool = False + + +class PSACompany(BaseModel): + id: str + name: str + status: str | None = None + + +class PSAMember(BaseModel): + id: str + identifier: str # CW login username + name: str + email: str | None = None + + +class PSAConfiguration(BaseModel): + id: str + name: str + type: str | None = None + company_name: str | None = None + + +class NoteType: + INTERNAL_ANALYSIS = "internal_analysis" + RESOLUTION = "resolution" + DESCRIPTION = "description"