From 1e3b6c0784c39b235153e712949a17a939715059 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 21:21:56 -0400 Subject: [PATCH 01/29] docs: add ConnectWise PSA integration implementation plan 5-slice plan covering foundation (abstraction layer, encryption, connection CRUD, frontend), ticket linking, ticket search, update ticket modal, and member mapping. Slice 1 has full task-level detail; slices 2-5 are outlined for iterative planning. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-03-14-connectwise-psa-integration-plan.md | 1482 +++++++++++++++++ 1 file changed, 1482 insertions(+) create mode 100644 docs/plans/2026-03-14-connectwise-psa-integration-plan.md diff --git a/docs/plans/2026-03-14-connectwise-psa-integration-plan.md b/docs/plans/2026-03-14-connectwise-psa-integration-plan.md new file mode 100644 index 00000000..289d129e --- /dev/null +++ b/docs/plans/2026-03-14-connectwise-psa-integration-plan.md @@ -0,0 +1,1482 @@ +# ConnectWise PSA Integration — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Integrate ResolutionFlow with ConnectWise PSA so engineers can post session documentation directly to CW tickets, with credential management, ticket linking, search, note posting, and member mapping. + +**Architecture:** PSA abstraction layer (`services/psa/`) with abstract `PSAProvider` interface + ConnectWise implementation. Fernet-encrypted credentials stored per-account. 5 incremental slices: Foundation → Ticket Linking → Ticket Search → Update Ticket Modal → Member Mapping. + +**Tech Stack:** FastAPI, SQLAlchemy 2.0, httpx (async HTTP client for CW API), cryptography (Fernet), React, TypeScript, Tailwind CSS + +**Design doc:** `docs/superpowers/specs/2026-03-14-connectwise-psa-integration-design.md` + +**Reference docs:** `docs/connectwise/` — read `CONNECTWISE-API-REFERENCE.md` first, then best-practices files before implementing CW client. + +--- + +## Slice 1: Foundation + +### Task 1: PSA abstraction layer — base types and abstract interface + +**Files:** +- Create: `backend/app/services/psa/__init__.py` +- Create: `backend/app/services/psa/base.py` +- Create: `backend/app/services/psa/types.py` +- Create: `backend/app/services/psa/exceptions.py` + +**Step 1: Create the package** + +Create `backend/app/services/psa/__init__.py`: + +```python +"""PSA integration abstraction layer.""" +``` + +**Step 2: Create PSA exception types** + +Create `backend/app/services/psa/exceptions.py`: + +```python +"""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 +``` + +**Step 3: Create normalized PSA types** + +Create `backend/app/services/psa/types.py`: + +```python +"""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" +``` + +**Step 4: Create abstract PSAProvider interface** + +Create `backend/app/services/psa/base.py`: + +```python +"""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]: + ... +``` + +**Step 5: Run tests to verify no import errors** + +Run: `cd backend && python -c "from app.services.psa.base import PSAProvider; print('OK')"` +Expected: `OK` + +**Step 6: Commit** + +```bash +git add backend/app/services/psa/ +git commit -m "feat(psa): add PSA abstraction layer — base types, exceptions, abstract interface" +``` + +--- + +### Task 2: Credential encryption utilities + +**Files:** +- Create: `backend/app/services/psa/encryption.py` +- Create: `backend/tests/test_psa_encryption.py` + +**Step 1: Write the failing test** + +Create `backend/tests/test_psa_encryption.py`: + +```python +"""Tests for PSA credential encryption/decryption.""" +import pytest +from app.services.psa.encryption import encrypt_credentials, decrypt_credentials + + +class TestCredentialEncryption: + def test_round_trip(self): + """Encrypt then decrypt returns original credentials.""" + creds = { + "public_key": "abc123", + "private_key": "secret456", + "client_id": "my-client-id", + } + encrypted = encrypt_credentials(creds) + + # Encrypted should be a non-empty string, different from input + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + assert "secret456" not in encrypted + + decrypted = decrypt_credentials(encrypted) + assert decrypted == creds + + def test_different_inputs_produce_different_outputs(self): + creds1 = {"public_key": "key1", "private_key": "priv1", "client_id": "cid1"} + creds2 = {"public_key": "key2", "private_key": "priv2", "client_id": "cid2"} + + enc1 = encrypt_credentials(creds1) + enc2 = encrypt_credentials(creds2) + assert enc1 != enc2 + + def test_tampered_ciphertext_raises(self): + creds = {"public_key": "k", "private_key": "p", "client_id": "c"} + encrypted = encrypt_credentials(creds) + tampered = encrypted[:-5] + "XXXXX" + with pytest.raises(Exception): + decrypt_credentials(tampered) + + def test_mask_private_key(self): + from app.services.psa.encryption import mask_credential + assert mask_credential("abcdefghij") == "••••••ghij" + assert mask_credential("abc") == "••••••abc" + assert mask_credential("") == "••••••" + assert mask_credential(None) == "••••••" +``` + +**Step 2: Run test to verify it fails** + +Run: `cd backend && python -m pytest tests/test_psa_encryption.py -v` +Expected: FAIL (module not found) + +**Step 3: Implement encryption utilities** + +Create `backend/app/services/psa/encryption.py`: + +```python +"""Fernet-based credential encryption for PSA connections. + +Uses the application SECRET_KEY to derive a Fernet encryption key via HKDF. +Credentials are stored as a single encrypted JSON blob. +""" +from __future__ import annotations + +import json +import base64 + +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from app.core.config import settings + + +def _get_fernet() -> Fernet: + """Derive a Fernet key from the application SECRET_KEY.""" + hkdf = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=b"resolutionflow-psa-credentials", + info=b"psa-credential-encryption", + ) + key = hkdf.derive(settings.SECRET_KEY.encode()) + fernet_key = base64.urlsafe_b64encode(key) + return Fernet(fernet_key) + + +def encrypt_credentials(credentials: dict) -> str: + """Encrypt a credentials dict to a Fernet token string.""" + f = _get_fernet() + plaintext = json.dumps(credentials).encode() + return f.encrypt(plaintext).decode() + + +def decrypt_credentials(encrypted: str) -> dict: + """Decrypt a Fernet token string back to a credentials dict.""" + f = _get_fernet() + plaintext = f.decrypt(encrypted.encode()) + return json.loads(plaintext) + + +def mask_credential(value: str | None, visible_suffix: int = 4) -> str: + """Return a masked version of a credential for display. + e.g., 'abcdefghij' → '••••••ghij' + """ + if not value: + return "••••••" + if len(value) <= visible_suffix: + return "••••••" + value + return "••••••" + value[-visible_suffix:] +``` + +**Step 4: Run test to verify it passes** + +Run: `cd backend && python -m pytest tests/test_psa_encryption.py -v` +Expected: PASS (4 tests) + +**Step 5: Commit** + +```bash +git add backend/app/services/psa/encryption.py backend/tests/test_psa_encryption.py +git commit -m "feat(psa): add Fernet credential encryption with HKDF key derivation" +``` + +--- + +### Task 3: PsaConnection model and migration + +**Files:** +- Create: `backend/app/models/psa_connection.py` +- Modify: `backend/app/models/__init__.py` +- Modify: `backend/alembic/env.py` +- Create: `backend/alembic/versions/058_add_psa_connections.py` + +**Step 1: Create the model** + +Create `backend/app/models/psa_connection.py`: + +```python +"""PSA connection model — one per account.""" +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID + +from app.core.database import Base + + +class PsaConnection(Base): + __tablename__ = "psa_connections" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("accounts.id", ondelete="CASCADE"), + nullable=False, + unique=True, + index=True, + ) + provider: Mapped[str] = mapped_column(String(50), nullable=False) + display_name: Mapped[str] = mapped_column(String(100), nullable=False) + site_url: Mapped[str] = mapped_column(String(255), nullable=False) + company_id: Mapped[str] = mapped_column(String(100), nullable=False) + credentials_encrypted: Mapped[str] = mapped_column(Text, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + last_validated_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # Relationships + account = relationship("Account", back_populates="psa_connection") +``` + +**Step 2: Register in `__init__.py`** + +Add to `backend/app/models/__init__.py`: + +```python +from app.models.psa_connection import PsaConnection +``` + +**Step 3: Import in `alembic/env.py`** + +Add to `backend/alembic/env.py` after the existing model imports: + +```python +from app.models.psa_connection import PsaConnection # noqa: F401 +``` + +**Step 4: Add back_populates to Account model** + +Find the Account model and add: + +```python +psa_connection = relationship("PsaConnection", back_populates="account", uselist=False) +``` + +**Step 5: Write migration manually** + +Create `backend/alembic/versions/058_add_psa_connections.py`: + +```python +"""Add psa_connections table. + +Revision ID: 058 +Revises: 057 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "058" +down_revision = "057" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "psa_connections", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("account_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("provider", sa.String(50), nullable=False), + sa.Column("display_name", sa.String(100), nullable=False), + sa.Column("site_url", sa.String(255), nullable=False), + sa.Column("company_id", sa.String(100), nullable=False), + sa.Column("credentials_encrypted", sa.Text(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("last_validated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="CASCADE"), + sa.UniqueConstraint("account_id"), + ) + op.create_index("ix_psa_connections_account_id", "psa_connections", ["account_id"]) + + +def downgrade() -> None: + op.drop_index("ix_psa_connections_account_id") + op.drop_table("psa_connections") +``` + +**Step 6: Verify migration chain** + +IMPORTANT: Check that migration 057's `revision` and `down_revision` values match. Open `backend/alembic/versions/057_add_script_templates.py` and verify the `revision` value — the 058 migration's `down_revision` must match it exactly. The 057 migration may use a hash-style revision ID instead of `"057"`. + +Run: `cd backend && python -c "from alembic.config import Config; from alembic import command; command.check(Config('alembic.ini'))" 2>&1 || echo "Check alembic heads"` + +If the revision IDs don't match, update `058`'s `down_revision` accordingly. + +**Step 7: Run migration** + +Run: `docker exec resolutionflow_backend alembic upgrade head` +Expected: SUCCESS + +**Step 8: Commit** + +```bash +git add backend/app/models/psa_connection.py backend/app/models/__init__.py backend/alembic/env.py backend/alembic/versions/058_add_psa_connections.py +git commit -m "feat(psa): add PsaConnection model and migration 058" +``` + +--- + +### Task 4: ConnectWise HTTP client + +**Files:** +- Create: `backend/app/services/psa/connectwise/__init__.py` +- Create: `backend/app/services/psa/connectwise/client.py` + +**IMPORTANT:** Before implementing, read these reference docs: +- `docs/connectwise/best-practices/PSA-API-Requests.md` — HTTP methods, response codes, condition query syntax +- `docs/connectwise/best-practices/PSA-Cloud-URL-Formatting.md` — Dynamic base URL construction +- `docs/connectwise/best-practices/PSA-Pagination.md` — Pagination patterns +- `docs/connectwise/best-practices/PSA-Versioning.md` — Pin API version via Accept header +- `docs/connectwise/CONNECTWISE-API-REFERENCE.md` — Auth patterns, endpoint map + +**Step 1: Create package** + +Create `backend/app/services/psa/connectwise/__init__.py`: + +```python +"""ConnectWise PSA provider implementation.""" +``` + +**Step 2: Create the HTTP client** + +Create `backend/app/services/psa/connectwise/client.py`: + +```python +"""Low-level HTTP client for ConnectWise PSA REST API. + +Handles auth headers, base URL resolution (cloud vs on-premise), +pagination, retry with backoff, and error mapping. +""" +from __future__ import annotations + +import base64 +import logging +from typing import Any + +import httpx + +from app.services.psa.exceptions import ( + PSAAuthError, + PSANotFoundError, + PSAPermissionError, + PSARateLimitError, + PSAServerError, + PSATimeoutError, + PSAConnectionError, +) + +logger = logging.getLogger(__name__) + +# Pinned CW API version per best-practices/PSA-Versioning.md +CW_API_VERSION = "2025.16" +CW_ACCEPT_HEADER = f"application/vnd.connectwise.com+json; version={CW_API_VERSION}" + +# Known CW cloud domains (for SSRF prevention) +CW_ALLOWED_DOMAINS = { + "myconnectwise.net", + "connectwisedev.com", +} + +REQUEST_TIMEOUT = 30.0 +MAX_RETRIES = 2 +MAX_PAGE_SIZE = 1000 + + +def _validate_site_url(site_url: str) -> None: + """Validate site_url is a known CW domain (SSRF prevention).""" + import ipaddress + import socket + from urllib.parse import urlparse + + # Ensure scheme + url = site_url if "://" in site_url else f"https://{site_url}" + parsed = urlparse(url) + hostname = parsed.hostname or "" + + # Check against allowed domains + if not any(hostname.endswith(f".{domain}") or hostname == domain for domain in CW_ALLOWED_DOMAINS): + raise PSAConnectionError( + f"Invalid ConnectWise site URL: {hostname}. Must be a *.myconnectwise.net or *.connectwisedev.com domain.", + provider="connectwise", + ) + + # Resolve and check for private IPs + try: + addrs = socket.getaddrinfo(hostname, None) + for _, _, _, _, sockaddr in addrs: + ip = ipaddress.ip_address(sockaddr[0]) + if ip.is_private or ip.is_loopback or ip.is_link_local: + raise PSAConnectionError( + f"Site URL resolves to a private/internal address: {sockaddr[0]}", + provider="connectwise", + ) + except socket.gaierror: + raise PSAConnectionError( + f"Cannot resolve hostname: {hostname}", + provider="connectwise", + ) + + +class ConnectWiseClient: + """Async HTTP client for the ConnectWise PSA API.""" + + def __init__( + self, + site_url: str, + company_id: str, + public_key: str, + private_key: str, + client_id: str, + ): + self.site_url = site_url.rstrip("/") + self.company_id = company_id + self.client_id = client_id + + # Auth: Base64(companyId+publicKey:privateKey) + auth_string = f"{company_id}+{public_key}:{private_key}" + self._auth_b64 = base64.b64encode(auth_string.encode()).decode() + + # Base URL resolved lazily on first request + self._base_url: str | None = None + + async def _resolve_base_url(self) -> str: + """Resolve the CW API base URL using /login/companyinfo/{companyId}. + + Cloud environments return a versioned codebase (e.g., v2025_3/) requiring + an 'api-' prefix on the hostname. On-premise returns v4_6_release/ with + no prefix needed. + """ + if self._base_url: + return self._base_url + + _validate_site_url(self.site_url) + + info_url = f"https://{self.site_url}/login/companyinfo/{self.company_id}" + + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + try: + resp = await client.get(info_url) + resp.raise_for_status() + except httpx.TimeoutException: + raise PSATimeoutError("Timed out resolving CW base URL", provider="connectwise") + except httpx.HTTPError as e: + raise PSAConnectionError(f"Failed to resolve CW base URL: {e}", provider="connectwise") + + data = resp.json() + codebase = data.get("Codebase", "v4_6_release/") + site_url = data.get("SiteUrl", self.site_url) + + # Cloud codebase (e.g., v2025_3/) requires api- prefix + if codebase != "v4_6_release/": + if not site_url.startswith("api-"): + site_url = f"api-{site_url}" + + self._base_url = f"https://{site_url}/{codebase}apis/3.0" + logger.info("Resolved CW base URL: %s", self._base_url) + return self._base_url + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Basic {self._auth_b64}", + "clientId": self.client_id, + "Accept": CW_ACCEPT_HEADER, + "Content-Type": "application/json", + } + + async def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json_body: Any = None, + retries: int = MAX_RETRIES, + ) -> Any: + """Make an authenticated request to the CW API with retry and error mapping.""" + base_url = await self._resolve_base_url() + url = f"{base_url}/{path.lstrip('/')}" + + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + for attempt in range(retries + 1): + try: + resp = await client.request( + method, + url, + headers=self._headers(), + params=params, + json=json_body, + ) + except httpx.TimeoutException: + if attempt < retries: + continue + raise PSATimeoutError("ConnectWise request timed out", provider="connectwise") + except httpx.ConnectError: + raise PSAConnectionError("Cannot reach ConnectWise server", provider="connectwise") + + # Rate limit — retry with backoff + if resp.status_code == 429: + if attempt < retries: + import asyncio + retry_after = int(resp.headers.get("Retry-After", "5")) + await asyncio.sleep(retry_after) + continue + raise PSARateLimitError( + "ConnectWise rate limit exceeded", + retry_after_seconds=int(resp.headers.get("Retry-After", "60")), + provider="connectwise", + ) + + # Map error codes + if resp.status_code == 401: + raise PSAAuthError("Invalid credentials. Check your API keys.", provider="connectwise") + if resp.status_code == 403: + raise PSAPermissionError( + "Insufficient permissions. Check the API member's security role.", + provider="connectwise", + ) + if resp.status_code == 404: + raise PSANotFoundError("Resource not found.", provider="connectwise") + if resp.status_code >= 500: + if attempt < retries: + continue + raise PSAServerError("ConnectWise is experiencing issues. Try again.", provider="connectwise") + + resp.raise_for_status() + if resp.status_code == 204: + return None + return resp.json() + + async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: + return await self._request("GET", path, params=params) + + async def post(self, path: str, json_body: Any = None) -> Any: + return await self._request("POST", path, json_body=json_body) + + async def patch(self, path: str, json_body: Any = None) -> Any: + """CW PATCH uses JSON Patch array format: [{"op": "replace", "path": "field", "value": ...}]""" + return await self._request("PATCH", path, json_body=json_body) + + async def delete(self, path: str) -> Any: + return await self._request("DELETE", path) + + async def get_paginated( + self, + path: str, + params: dict[str, Any] | None = None, + max_pages: int = 10, + ) -> list[Any]: + """Fetch all pages of a paginated CW endpoint.""" + params = dict(params or {}) + params.setdefault("pageSize", MAX_PAGE_SIZE) + all_results: list[Any] = [] + + for page in range(1, max_pages + 1): + params["page"] = page + results = await self.get(path, params=params) + if not results: + break + all_results.extend(results) + if len(results) < params["pageSize"]: + break + + return all_results +``` + +**Step 3: Run build verification** + +Run: `cd backend && python -c "from app.services.psa.connectwise.client import ConnectWiseClient; print('OK')"` +Expected: `OK` + +**Step 4: Commit** + +```bash +git add backend/app/services/psa/connectwise/ +git commit -m "feat(psa): add ConnectWise HTTP client with auth, URL resolution, pagination, retry" +``` + +--- + +### Task 5: ConnectWise provider — test_connection + +**Files:** +- Create: `backend/app/services/psa/connectwise/provider.py` +- Create: `backend/app/services/psa/registry.py` +- Create: `backend/tests/test_psa_connection.py` + +**Step 1: Create the provider (test_connection only)** + +Create `backend/app/services/psa/connectwise/provider.py`: + +```python +"""ConnectWise implementation of PSAProvider.""" +from __future__ import annotations + +from app.services.psa.base import PSAProvider +from app.services.psa.types import ( + ConnectionTestResult, + PSATicket, + PSANote, + PSAStatus, + PSACompany, + PSAMember, + PSAConfiguration, +) +from .client import ConnectWiseClient + + +class ConnectWiseProvider(PSAProvider): + """ConnectWise PSA provider implementation.""" + + def __init__(self, client: ConnectWiseClient): + self.client = client + + async def test_connection(self) -> ConnectionTestResult: + """Test the CW connection by fetching system info.""" + try: + info = await self.client.get("/system/info") + return ConnectionTestResult( + success=True, + message="Connected successfully.", + server_version=info.get("version", None), + ) + except Exception as e: + return ConnectionTestResult( + success=False, + message=str(e), + server_version=None, + ) + + # ── Stubs for Phase A slices 2-5 ───────────────────────────────── + + async def get_ticket(self, ticket_id: str) -> PSATicket: + raise NotImplementedError("Implemented in Slice 2") + + async def search_tickets(self, query: str, **filters) -> list[PSATicket]: + raise NotImplementedError("Implemented in Slice 3") + + async def post_note(self, ticket_id: str, text: str, note_type: str, member_id: str | None = None) -> PSANote: + raise NotImplementedError("Implemented in Slice 4") + + async def update_ticket_status(self, ticket_id: str, status_id: int) -> PSATicket: + raise NotImplementedError("Implemented in Slice 4") + + async def get_ticket_statuses(self, board_id: int) -> list[PSAStatus]: + raise NotImplementedError("Implemented in Slice 3") + + async def list_companies(self, **filters) -> list[PSACompany]: + raise NotImplementedError("Implemented in Slice 3") + + async def get_company(self, company_id: str) -> PSACompany: + raise NotImplementedError("Implemented in Slice 3") + + async def list_members(self) -> list[PSAMember]: + raise NotImplementedError("Implemented in Slice 5") + + async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]: + raise NotImplementedError("Implemented in Slice 3") +``` + +**Step 2: Create the provider registry** + +Create `backend/app/services/psa/registry.py`: + +```python +"""Factory for instantiating PSA providers from stored connection data.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.psa_connection import PsaConnection +from app.services.psa.base import PSAProvider +from app.services.psa.encryption import decrypt_credentials +from app.services.psa.exceptions import PSAConnectionError + + +async def get_provider_for_account(account_id: UUID, db: AsyncSession) -> PSAProvider: + """Look up account's PSA connection, decrypt credentials, instantiate provider.""" + result = await db.execute( + select(PsaConnection).where( + PsaConnection.account_id == account_id, + PsaConnection.is_active.is_(True), + ) + ) + connection = result.scalar_one_or_none() + + if not connection: + raise PSAConnectionError( + "No active PSA connection configured for this account.", + provider="unknown", + ) + + if connection.provider == "connectwise": + from app.services.psa.connectwise.client import ConnectWiseClient + from app.services.psa.connectwise.provider import ConnectWiseProvider + + creds = decrypt_credentials(connection.credentials_encrypted) + client = ConnectWiseClient( + site_url=connection.site_url, + company_id=connection.company_id, + public_key=creds["public_key"], + private_key=creds["private_key"], + client_id=creds["client_id"], + ) + return ConnectWiseProvider(client) + + raise PSAConnectionError( + f"Unsupported PSA provider: {connection.provider}", + provider=connection.provider, + ) +``` + +**Step 3: Commit** + +```bash +git add backend/app/services/psa/connectwise/provider.py backend/app/services/psa/registry.py +git commit -m "feat(psa): add ConnectWiseProvider with test_connection + provider registry" +``` + +--- + +### Task 6: Pydantic schemas for PSA connections + +**Files:** +- Create: `backend/app/schemas/psa_connection.py` +- Modify: `backend/app/schemas/__init__.py` + +**Step 1: Create schemas** + +Create `backend/app/schemas/psa_connection.py`: + +```python +"""Pydantic schemas for PSA connection management.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + + +class PsaConnectionCreate(BaseModel): + provider: str = Field(default="connectwise", pattern="^(connectwise|autotask)$") + display_name: str = Field(min_length=1, max_length=100) + site_url: str = Field(min_length=1, max_length=255) + company_id: str = Field(min_length=1, max_length=100) + public_key: str = Field(min_length=1) + private_key: str = Field(min_length=1) + client_id: str = Field(min_length=1) + + +class PsaConnectionUpdate(BaseModel): + display_name: str | None = None + site_url: str | None = None + company_id: str | None = None + public_key: str | None = None + private_key: str | None = None + client_id: str | None = None + + +class PsaConnectionResponse(BaseModel): + id: UUID + account_id: UUID + provider: str + display_name: str + site_url: str + company_id: str + is_active: bool + last_validated_at: datetime | None + created_at: datetime + updated_at: datetime + # Redacted credential hints (never expose full keys) + public_key_hint: str # e.g., "••••••abc1" + private_key_hint: str + + model_config = {"from_attributes": True} + + +class PsaConnectionTestResponse(BaseModel): + success: bool + message: str + server_version: str | None = None +``` + +**Step 2: Register in schemas __init__** + +Add to `backend/app/schemas/__init__.py`: + +```python +from app.schemas.psa_connection import ( + PsaConnectionCreate, + PsaConnectionUpdate, + PsaConnectionResponse, + PsaConnectionTestResponse, +) +``` + +**Step 3: Commit** + +```bash +git add backend/app/schemas/psa_connection.py backend/app/schemas/__init__.py +git commit -m "feat(psa): add Pydantic schemas for PSA connection CRUD" +``` + +--- + +### Task 7: PSA connection API endpoints + +**Files:** +- Create: `backend/app/api/endpoints/integrations.py` +- Modify: `backend/app/api/router.py` +- Create: `backend/tests/test_psa_connections.py` + +**Step 1: Write tests** + +Create `backend/tests/test_psa_connections.py`: + +```python +"""Integration tests for PSA connection CRUD endpoints.""" +import pytest +from httpx import AsyncClient + +pytestmark = pytest.mark.anyio + + +class TestPsaConnectionCRUD: + """Tests for /integrations/psa/connections endpoints.""" + + async def test_get_connection_empty(self, client: AsyncClient, auth_headers: dict): + """GET returns null when no connection exists.""" + resp = await client.get("/api/v1/integrations/psa/connections", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json() is None + + async def test_create_connection_requires_owner(self, client: AsyncClient, auth_headers: dict): + """Non-owner users cannot create connections.""" + # Default test user is an engineer, not owner + resp = await client.post( + "/api/v1/integrations/psa/connections", + headers=auth_headers, + json={ + "provider": "connectwise", + "display_name": "Test CW", + "site_url": "na.myconnectwise.net", + "company_id": "testco", + "public_key": "pub123", + "private_key": "priv456", + "client_id": "cid789", + }, + ) + # Should be 403 because test user is not an owner + assert resp.status_code == 403 + + async def test_delete_nonexistent_returns_404(self, client: AsyncClient, auth_headers: dict): + """DELETE on nonexistent connection returns 404.""" + import uuid + resp = await client.delete( + f"/api/v1/integrations/psa/connections/{uuid.uuid4()}", + headers=auth_headers, + ) + # Either 403 (not owner) or 404 — both are acceptable + assert resp.status_code in (403, 404) +``` + +Note: Full CRUD tests with owner-role fixtures will be added when we have a test helper for creating owner-role users. These initial tests verify the endpoints exist and RBAC is enforced. + +**Step 2: Create the endpoints** + +Create `backend/app/api/endpoints/integrations.py`: + +```python +"""PSA integration management endpoints.""" +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.api.deps import require_account_owner, get_current_active_user +from app.models.user import User +from app.models.psa_connection import PsaConnection +from app.schemas.psa_connection import ( + PsaConnectionCreate, + PsaConnectionUpdate, + PsaConnectionResponse, + PsaConnectionTestResponse, +) +from app.services.psa.encryption import encrypt_credentials, decrypt_credentials, mask_credential + +router = APIRouter(prefix="/integrations/psa", tags=["integrations"]) + + +def _to_response(conn: PsaConnection) -> PsaConnectionResponse: + """Convert a PsaConnection model to a response with redacted credentials.""" + creds = decrypt_credentials(conn.credentials_encrypted) + return PsaConnectionResponse( + id=conn.id, + account_id=conn.account_id, + provider=conn.provider, + display_name=conn.display_name, + site_url=conn.site_url, + company_id=conn.company_id, + is_active=conn.is_active, + last_validated_at=conn.last_validated_at, + created_at=conn.created_at, + updated_at=conn.updated_at, + public_key_hint=mask_credential(creds.get("public_key")), + private_key_hint=mask_credential(creds.get("private_key")), + ) + + +@router.get("/connections", response_model=PsaConnectionResponse | None) +async def get_connection( + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Get the account's PSA connection (redacted credentials). Any authenticated user can view.""" + if not current_user.account_id: + return None + result = await db.execute( + select(PsaConnection).where(PsaConnection.account_id == current_user.account_id) + ) + conn = result.scalar_one_or_none() + if not conn: + return None + return _to_response(conn) + + +@router.post("/connections", response_model=PsaConnectionResponse, status_code=status.HTTP_201_CREATED) +async def create_connection( + data: PsaConnectionCreate, + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Create a new PSA connection for the account. Tests connection before saving.""" + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + + # Check for existing connection + existing = await db.execute( + select(PsaConnection).where(PsaConnection.account_id == current_user.account_id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Account already has a PSA connection") + + # Test connection before saving + from app.services.psa.connectwise.client import ConnectWiseClient + from app.services.psa.connectwise.provider import ConnectWiseProvider + + client = ConnectWiseClient( + site_url=data.site_url, + company_id=data.company_id, + public_key=data.public_key, + private_key=data.private_key, + client_id=data.client_id, + ) + provider = ConnectWiseProvider(client) + test_result = await provider.test_connection() + + if not test_result.success: + raise HTTPException(status_code=422, detail=f"Connection test failed: {test_result.message}") + + # Encrypt and save + from datetime import datetime, timezone + + encrypted = encrypt_credentials({ + "public_key": data.public_key, + "private_key": data.private_key, + "client_id": data.client_id, + }) + + conn = PsaConnection( + account_id=current_user.account_id, + provider=data.provider, + display_name=data.display_name, + site_url=data.site_url, + company_id=data.company_id, + credentials_encrypted=encrypted, + last_validated_at=datetime.now(timezone.utc), + ) + db.add(conn) + await db.commit() + await db.refresh(conn) + + return _to_response(conn) + + +@router.put("/connections/{connection_id}", response_model=PsaConnectionResponse) +async def update_connection( + connection_id: UUID, + data: PsaConnectionUpdate, + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Update PSA connection. Re-tests if credentials change.""" + result = await db.execute( + select(PsaConnection).where( + PsaConnection.id == connection_id, + PsaConnection.account_id == current_user.account_id, + ) + ) + conn = result.scalar_one_or_none() + if not conn: + raise HTTPException(status_code=404, detail="Connection not found") + + # Decrypt existing creds to merge with updates + creds = decrypt_credentials(conn.credentials_encrypted) + credentials_changed = False + + if data.display_name is not None: + conn.display_name = data.display_name + if data.site_url is not None: + conn.site_url = data.site_url + credentials_changed = True + if data.company_id is not None: + conn.company_id = data.company_id + credentials_changed = True + if data.public_key is not None: + creds["public_key"] = data.public_key + credentials_changed = True + if data.private_key is not None: + creds["private_key"] = data.private_key + credentials_changed = True + if data.client_id is not None: + creds["client_id"] = data.client_id + credentials_changed = True + + # Re-test if credentials changed + if credentials_changed: + from app.services.psa.connectwise.client import ConnectWiseClient + from app.services.psa.connectwise.provider import ConnectWiseProvider + + client = ConnectWiseClient( + site_url=conn.site_url, + company_id=conn.company_id, + public_key=creds["public_key"], + private_key=creds["private_key"], + client_id=creds["client_id"], + ) + provider = ConnectWiseProvider(client) + test_result = await provider.test_connection() + + if not test_result.success: + raise HTTPException(status_code=422, detail=f"Connection test failed: {test_result.message}") + + from datetime import datetime, timezone + conn.last_validated_at = datetime.now(timezone.utc) + conn.credentials_encrypted = encrypt_credentials(creds) + + await db.commit() + await db.refresh(conn) + return _to_response(conn) + + +@router.delete("/connections/{connection_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_connection( + connection_id: UUID, + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Delete a PSA connection.""" + result = await db.execute( + select(PsaConnection).where( + PsaConnection.id == connection_id, + PsaConnection.account_id == current_user.account_id, + ) + ) + conn = result.scalar_one_or_none() + if not conn: + raise HTTPException(status_code=404, detail="Connection not found") + + await db.delete(conn) + await db.commit() + + +@router.post("/connections/{connection_id}/test", response_model=PsaConnectionTestResponse) +async def test_connection( + connection_id: UUID, + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Test an existing PSA connection.""" + result = await db.execute( + select(PsaConnection).where( + PsaConnection.id == connection_id, + PsaConnection.account_id == current_user.account_id, + ) + ) + conn = result.scalar_one_or_none() + if not conn: + raise HTTPException(status_code=404, detail="Connection not found") + + from app.services.psa.registry import get_provider_for_account + provider = await get_provider_for_account(current_user.account_id, db) + test_result = await provider.test_connection() + + if test_result.success: + from datetime import datetime, timezone + conn.last_validated_at = datetime.now(timezone.utc) + await db.commit() + + return PsaConnectionTestResponse( + success=test_result.success, + message=test_result.message, + server_version=test_result.server_version, + ) +``` + +**Step 3: Register in router** + +Add to `backend/app/api/router.py`: + +```python +from app.api.endpoints import integrations +# ... +api_router.include_router(integrations.router) +``` + +**Step 4: Run tests** + +Run: `cd backend && python -m pytest tests/test_psa_connections.py -v` +Expected: Tests should pass (the RBAC test may need adjustment based on test user role) + +**Step 5: Commit** + +```bash +git add backend/app/api/endpoints/integrations.py backend/app/api/router.py backend/tests/test_psa_connections.py +git commit -m "feat(psa): add PSA connection CRUD endpoints with encryption and connection testing" +``` + +--- + +### Task 8: Frontend — Integrations page (Connection tab) + +This task creates the frontend for Slice 1. Due to the size, it's broken into sub-steps but committed as a single unit. + +**Files:** +- Create: `frontend/src/types/integrations.ts` +- Modify: `frontend/src/types/index.ts` +- Create: `frontend/src/api/integrations.ts` +- Modify: `frontend/src/api/index.ts` +- Create: `frontend/src/pages/account/IntegrationsPage.tsx` +- Modify: `frontend/src/router.tsx` +- Modify: `frontend/src/pages/account/AccountSettingsPage.tsx` (add link card) + +**Sub-step A: Types** + +Create `frontend/src/types/integrations.ts` with `PsaConnectionResponse`, `PsaConnectionCreate`, `PsaConnectionUpdate`, `PsaConnectionTestResponse` interfaces matching the backend schemas. + +**Sub-step B: API client** + +Create `frontend/src/api/integrations.ts` with `integrationsApi` object (connection CRUD + test). + +**Sub-step C: IntegrationsPage** + +Create `frontend/src/pages/account/IntegrationsPage.tsx`: +- Connection tab (only tab for now — Member Mapping and Post History added in Slices 4-5) +- When no connection: setup form with fields for display name, site URL, company ID, public key, private key, client ID +- When connection exists: status card showing provider, site URL, company ID, redacted keys, last validated timestamp. Actions: Edit, Test, Disconnect +- Connection test shows success/failure inline +- Uses local React state (per lesson #41) +- Glass card styling per design system + +**Sub-step D: Routing** + +Add route in `frontend/src/router.tsx` under `account` children: +```tsx +{ + path: 'integrations', + element: ( + + {page(IntegrationsPage)} + + ), +}, +``` + +Add link card in `AccountSettingsPage.tsx` for "Integrations" pointing to `/account/integrations`. + +**Sub-step E: Build and commit** + +Run: `cd frontend && npm run build` +Expected: SUCCESS + +```bash +git add frontend/src/types/integrations.ts frontend/src/types/index.ts frontend/src/api/integrations.ts frontend/src/api/index.ts frontend/src/pages/account/IntegrationsPage.tsx frontend/src/router.tsx frontend/src/pages/account/AccountSettingsPage.tsx +git commit -m "feat(psa): add Integrations page with connection management UI" +``` + +--- + +## Slice 2: Ticket Linking (Outline) + +### Task 9: get_ticket in ConnectWise provider +- Implement `get_ticket()` in provider — calls `GET /service/tickets/{id}` with partial response fields +- Map CW response to `PSATicket` model + +### Task 10: Session table migration +- Add `psa_ticket_id` (VARCHAR 100, nullable) and `psa_connection_id` (UUID, FK → psa_connections, ON DELETE SET NULL) to sessions +- Migration 059 +- Update Session model and SessionResponse schema + +### Task 11: Ticket link endpoint +- `PATCH /sessions/{id}/ticket-link` — validates ticket exists in CW before saving +- Add to `sessions.py` endpoints + +### Task 12: Frontend — Ticket picker modal + session header indicator +- TicketPickerModal component (manual ID entry + Look Up) +- Session header ticket link indicator (Lucide `Ticket` icon) +- Link/unlink UI + +--- + +## Slice 3: Ticket Search (Outline) + +### Task 13: search_tickets, get_ticket_statuses, list_companies in provider +### Task 14: Search and status endpoints +### Task 15: In-memory cache for board statuses and companies +### Task 16: Frontend — Full ticket search in picker modal + +--- + +## Slice 4: Update Ticket Modal (Outline) + +### Task 17: PsaPostLog model + migration 060 +### Task 18: post_note, update_ticket_status in provider +### Task 19: Preview, post, and post history endpoints +### Task 20: Frontend — Update Ticket modal (split panel, note type, status dropdown) +### Task 21: Frontend — Post history tab in Integrations page + +--- + +## Slice 5: Member Mapping (Outline) + +### Task 22: PsaMemberMapping model + migration 061 +### Task 23: list_members in provider +### Task 24: Member mapping CRUD + auto-match endpoints +### Task 25: Member attribution on note posts +### Task 26: Frontend — Member Mapping tab in Integrations page -- 2.49.1 From d2edb9e3ce2a3b7dc3970df9e1464379d93d5f5f Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 21:46:12 -0400 Subject: [PATCH 02/29] =?UTF-8?q?feat(psa):=20add=20PSA=20abstraction=20la?= =?UTF-8?q?yer=20=E2=80=94=20base=20types,=20exceptions,=20abstract=20inte?= =?UTF-8?q?rface?= 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" -- 2.49.1 From 086e4c6d597ee4fa68d78da57e26bbab354cc3b3 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 21:48:09 -0400 Subject: [PATCH 03/29] feat(psa): add Fernet credential encryption with HKDF key derivation Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/services/psa/encryption.py | 53 ++++++++++++++++++++++++++ backend/tests/test_psa_encryption.py | 44 +++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 backend/app/services/psa/encryption.py create mode 100644 backend/tests/test_psa_encryption.py diff --git a/backend/app/services/psa/encryption.py b/backend/app/services/psa/encryption.py new file mode 100644 index 00000000..b537e3fa --- /dev/null +++ b/backend/app/services/psa/encryption.py @@ -0,0 +1,53 @@ +"""Fernet-based credential encryption for PSA connections. + +Uses the application SECRET_KEY to derive a Fernet encryption key via HKDF. +Credentials are stored as a single encrypted JSON blob. +""" +from __future__ import annotations + +import json +import base64 + +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from app.core.config import settings + + +def _get_fernet() -> Fernet: + """Derive a Fernet key from the application SECRET_KEY.""" + hkdf = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=b"resolutionflow-psa-credentials", + info=b"psa-credential-encryption", + ) + key = hkdf.derive(settings.SECRET_KEY.encode()) + fernet_key = base64.urlsafe_b64encode(key) + return Fernet(fernet_key) + + +def encrypt_credentials(credentials: dict) -> str: + """Encrypt a credentials dict to a Fernet token string.""" + f = _get_fernet() + plaintext = json.dumps(credentials).encode() + return f.encrypt(plaintext).decode() + + +def decrypt_credentials(encrypted: str) -> dict: + """Decrypt a Fernet token string back to a credentials dict.""" + f = _get_fernet() + plaintext = f.decrypt(encrypted.encode()) + return json.loads(plaintext) + + +def mask_credential(value: str | None, visible_suffix: int = 4) -> str: + """Return a masked version of a credential for display. + e.g., 'abcdefghij' -> '......ghij' + """ + if not value: + return "\u2022\u2022\u2022\u2022\u2022\u2022" + if len(value) <= visible_suffix: + return "\u2022\u2022\u2022\u2022\u2022\u2022" + value + return "\u2022\u2022\u2022\u2022\u2022\u2022" + value[-visible_suffix:] diff --git a/backend/tests/test_psa_encryption.py b/backend/tests/test_psa_encryption.py new file mode 100644 index 00000000..9860b120 --- /dev/null +++ b/backend/tests/test_psa_encryption.py @@ -0,0 +1,44 @@ +"""Tests for PSA credential encryption/decryption.""" +import pytest +from app.services.psa.encryption import encrypt_credentials, decrypt_credentials + + +class TestCredentialEncryption: + def test_round_trip(self): + """Encrypt then decrypt returns original credentials.""" + creds = { + "public_key": "abc123", + "private_key": "secret456", + "client_id": "my-client-id", + } + encrypted = encrypt_credentials(creds) + + # Encrypted should be a non-empty string, different from input + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + assert "secret456" not in encrypted + + decrypted = decrypt_credentials(encrypted) + assert decrypted == creds + + def test_different_inputs_produce_different_outputs(self): + creds1 = {"public_key": "key1", "private_key": "priv1", "client_id": "cid1"} + creds2 = {"public_key": "key2", "private_key": "priv2", "client_id": "cid2"} + + enc1 = encrypt_credentials(creds1) + enc2 = encrypt_credentials(creds2) + assert enc1 != enc2 + + def test_tampered_ciphertext_raises(self): + creds = {"public_key": "k", "private_key": "p", "client_id": "c"} + encrypted = encrypt_credentials(creds) + tampered = encrypted[:-5] + "XXXXX" + with pytest.raises(Exception): + decrypt_credentials(tampered) + + def test_mask_private_key(self): + from app.services.psa.encryption import mask_credential + assert mask_credential("abcdefghij") == "\u2022\u2022\u2022\u2022\u2022\u2022ghij" + assert mask_credential("abc") == "\u2022\u2022\u2022\u2022\u2022\u2022abc" + assert mask_credential("") == "\u2022\u2022\u2022\u2022\u2022\u2022" + assert mask_credential(None) == "\u2022\u2022\u2022\u2022\u2022\u2022" -- 2.49.1 From 5323768de6d5701f21106e8cd1188db468516924 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 21:49:19 -0400 Subject: [PATCH 04/29] feat(psa): add PsaConnection model and migration 058 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/alembic/env.py | 1 + .../versions/058_add_psa_connections.py | 39 +++++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/account.py | 2 + backend/app/models/psa_connection.py | 48 +++++++++++++++++++ 5 files changed, 92 insertions(+) create mode 100644 backend/alembic/versions/058_add_psa_connections.py create mode 100644 backend/app/models/psa_connection.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index fbc41435..4256625f 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -19,6 +19,7 @@ from app.models.survey_invite import SurveyInvite from app.models.ai_suggestion import AISuggestion # noqa: F401 from app.models.kb_import import KBImport, KBImportNode # noqa: F401 from app.models.script_template import ScriptCategory, ScriptTemplate, ScriptGeneration # noqa: F401 +from app.models.psa_connection import PsaConnection # noqa: F401 from app.core.config import settings # this is the Alembic Config object diff --git a/backend/alembic/versions/058_add_psa_connections.py b/backend/alembic/versions/058_add_psa_connections.py new file mode 100644 index 00000000..83a489bc --- /dev/null +++ b/backend/alembic/versions/058_add_psa_connections.py @@ -0,0 +1,39 @@ +"""Add psa_connections table. + +Revision ID: 058 +Revises: 057 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "058" +down_revision = "057" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "psa_connections", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("account_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("provider", sa.String(50), nullable=False), + sa.Column("display_name", sa.String(100), nullable=False), + sa.Column("site_url", sa.String(255), nullable=False), + sa.Column("company_id", sa.String(100), nullable=False), + sa.Column("credentials_encrypted", sa.Text(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("last_validated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="CASCADE"), + sa.UniqueConstraint("account_id"), + ) + op.create_index("ix_psa_connections_account_id", "psa_connections", ["account_id"]) + + +def downgrade() -> None: + op.drop_index("ix_psa_connections_account_id") + op.drop_table("psa_connections") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 06003af7..afdaeb27 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -36,6 +36,7 @@ from .survey_response import SurveyResponse from .survey_invite import SurveyInvite from .kb_import import KBImport, KBImportNode from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration +from .psa_connection import PsaConnection __all__ = [ "User", @@ -86,4 +87,5 @@ __all__ = [ "ScriptCategory", "ScriptTemplate", "ScriptGeneration", + "PsaConnection", ] diff --git a/backend/app/models/account.py b/backend/app/models/account.py index 351a48b7..7792e9b8 100644 --- a/backend/app/models/account.py +++ b/backend/app/models/account.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from app.models.step_category import StepCategory from app.models.step_library import StepLibrary from app.models.account_limit_override import AccountLimitOverride + from app.models.psa_connection import PsaConnection class Account(Base): @@ -53,3 +54,4 @@ class Account(Base): step_categories: Mapped[list["StepCategory"]] = relationship("StepCategory", foreign_keys="[StepCategory.account_id]", back_populates="account") step_library: Mapped[list["StepLibrary"]] = relationship("StepLibrary", foreign_keys="[StepLibrary.account_id]", back_populates="account") limit_override: Mapped[Optional["AccountLimitOverride"]] = relationship("AccountLimitOverride", back_populates="account", uselist=False) + psa_connection: Mapped[Optional["PsaConnection"]] = relationship("PsaConnection", back_populates="account", uselist=False) diff --git a/backend/app/models/psa_connection.py b/backend/app/models/psa_connection.py new file mode 100644 index 00000000..8cdd609e --- /dev/null +++ b/backend/app/models/psa_connection.py @@ -0,0 +1,48 @@ +"""PSA connection model — one per account.""" +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID + +from app.core.database import Base + + +class PsaConnection(Base): + __tablename__ = "psa_connections" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("accounts.id", ondelete="CASCADE"), + nullable=False, + unique=True, + index=True, + ) + provider: Mapped[str] = mapped_column(String(50), nullable=False) + display_name: Mapped[str] = mapped_column(String(100), nullable=False) + site_url: Mapped[str] = mapped_column(String(255), nullable=False) + company_id: Mapped[str] = mapped_column(String(100), nullable=False) + credentials_encrypted: Mapped[str] = mapped_column(Text, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + last_validated_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # Relationships + account = relationship("Account", back_populates="psa_connection") -- 2.49.1 From fdaea49d3b31fc0b57da44773d9aea191211d826 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 21:51:49 -0400 Subject: [PATCH 05/29] feat(psa): add ConnectWise HTTP client with auth, URL resolution, pagination, retry Implements ConnectWiseClient in services/psa/connectwise/client.py with: - API key auth (Base64 companyId+publicKey:privateKey) + clientId header - Accept header pinned to CW API version 2025.16 - Dynamic base URL resolution via /login/companyinfo (cloud vs on-premise) - SSRF prevention: validates against known CW domains, rejects private IPs - Retry with exponential backoff for timeouts and 5xx errors - 429 rate limit handling with Retry-After header respect - Error mapping: 401/403/404/429/5xx to typed PSA exceptions - Paginated GET with while-loop pattern (max 1000 per page) - JSON Patch array format for PATCH requests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/services/psa/connectwise/__init__.py | 1 + .../app/services/psa/connectwise/client.py | 288 ++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 backend/app/services/psa/connectwise/__init__.py create mode 100644 backend/app/services/psa/connectwise/client.py diff --git a/backend/app/services/psa/connectwise/__init__.py b/backend/app/services/psa/connectwise/__init__.py new file mode 100644 index 00000000..fe4f48c4 --- /dev/null +++ b/backend/app/services/psa/connectwise/__init__.py @@ -0,0 +1 @@ +"""ConnectWise PSA provider implementation.""" diff --git a/backend/app/services/psa/connectwise/client.py b/backend/app/services/psa/connectwise/client.py new file mode 100644 index 00000000..4faaf176 --- /dev/null +++ b/backend/app/services/psa/connectwise/client.py @@ -0,0 +1,288 @@ +"""Low-level HTTP client for ConnectWise PSA REST API. + +Handles auth headers, base URL resolution (cloud vs on-premise), +pagination, retry with backoff, and error mapping. +""" +from __future__ import annotations + +import asyncio +import base64 +import ipaddress +import logging +import socket +from typing import Any +from urllib.parse import urlparse + +import httpx + +from app.services.psa.exceptions import ( + PSAAuthError, + PSAConnectionError, + PSANotFoundError, + PSAPermissionError, + PSARateLimitError, + PSAServerError, + PSATimeoutError, +) + +logger = logging.getLogger(__name__) + +# Pinned CW API version per best-practices/PSA-Versioning.md +CW_API_VERSION = "2025.16" +CW_ACCEPT_HEADER = f"application/vnd.connectwise.com+json; version={CW_API_VERSION}" + +# Known CW cloud domains (for SSRF prevention) +CW_ALLOWED_DOMAINS = { + "myconnectwise.net", + "connectwisedev.com", +} + +REQUEST_TIMEOUT = 30.0 +MAX_RETRIES = 2 +MAX_PAGE_SIZE = 1000 + + +def _validate_site_url(site_url: str) -> None: + """Validate site_url is a known CW domain (SSRF prevention). + + Rejects any hostname that is not a recognized ConnectWise domain + and any hostname that resolves to a private/loopback/link-local IP. + """ + # Ensure scheme for parsing + url = site_url if "://" in site_url else f"https://{site_url}" + parsed = urlparse(url) + hostname = parsed.hostname or "" + + # Check against allowed domains + if not any( + hostname.endswith(f".{domain}") or hostname == domain + for domain in CW_ALLOWED_DOMAINS + ): + raise PSAConnectionError( + f"Invalid ConnectWise site URL: {hostname}. " + "Must be a *.myconnectwise.net or *.connectwisedev.com domain.", + provider="connectwise", + ) + + # Resolve and check for private IPs + try: + addrs = socket.getaddrinfo(hostname, None) + for _, _, _, _, sockaddr in addrs: + ip = ipaddress.ip_address(sockaddr[0]) + if ip.is_private or ip.is_loopback or ip.is_link_local: + raise PSAConnectionError( + f"Site URL resolves to a private/internal address: {sockaddr[0]}", + provider="connectwise", + ) + except socket.gaierror: + raise PSAConnectionError( + f"Cannot resolve hostname: {hostname}", + provider="connectwise", + ) + + +class ConnectWiseClient: + """Async HTTP client for the ConnectWise PSA API. + + Auth: Authorization: Basic {base64(companyId+publicKey:privateKey)} + clientId header + Accept: application/vnd.connectwise.com+json; version=2025.16 + Base URL: resolved dynamically via /login/companyinfo/{companyId} + Pagination: page/pageSize params, max 1000 per page, while-loop pattern + Retry: respects 429 Retry-After, max 2 retries with exponential backoff for 5xx + Timeout: 30 seconds per request + """ + + def __init__( + self, + site_url: str, + company_id: str, + public_key: str, + private_key: str, + client_id: str, + ): + self.site_url = site_url.rstrip("/") + self.company_id = company_id + self.client_id = client_id + + # Auth: Base64(companyId+publicKey:privateKey) + auth_string = f"{company_id}+{public_key}:{private_key}" + self._auth_b64 = base64.b64encode(auth_string.encode()).decode() + + # Base URL resolved lazily on first request + self._base_url: str | None = None + + async def _resolve_base_url(self) -> str: + """Resolve the CW API base URL using /login/companyinfo/{companyId}. + + Cloud environments return a versioned codebase (e.g., v2025_3/) requiring + an 'api-' prefix on the hostname. On-premise returns v4_6_release/ with + no prefix needed. + """ + if self._base_url: + return self._base_url + + _validate_site_url(self.site_url) + + info_url = f"https://{self.site_url}/login/companyinfo/{self.company_id}" + + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + try: + resp = await client.get(info_url) + resp.raise_for_status() + except httpx.TimeoutException: + raise PSATimeoutError( + "Timed out resolving CW base URL", provider="connectwise" + ) + except httpx.HTTPError as e: + raise PSAConnectionError( + f"Failed to resolve CW base URL: {e}", provider="connectwise" + ) + + data = resp.json() + codebase = data.get("Codebase", "v4_6_release/") + site_url = data.get("SiteUrl", self.site_url) + + # Cloud codebase (e.g., v2025_3/) requires api- prefix + if codebase != "v4_6_release/": + if not site_url.startswith("api-"): + site_url = f"api-{site_url}" + + self._base_url = f"https://{site_url}/{codebase}apis/3.0" + logger.info("Resolved CW base URL: %s", self._base_url) + return self._base_url + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Basic {self._auth_b64}", + "clientId": self.client_id, + "Accept": CW_ACCEPT_HEADER, + "Content-Type": "application/json", + } + + async def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json_body: Any = None, + retries: int = MAX_RETRIES, + ) -> Any: + """Make an authenticated request to the CW API with retry and error mapping.""" + base_url = await self._resolve_base_url() + url = f"{base_url}/{path.lstrip('/')}" + + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + for attempt in range(retries + 1): + try: + resp = await client.request( + method, + url, + headers=self._headers(), + params=params, + json=json_body, + ) + except httpx.TimeoutException: + if attempt < retries: + await asyncio.sleep(2 ** attempt) + continue + raise PSATimeoutError( + "ConnectWise request timed out", provider="connectwise" + ) + except httpx.ConnectError: + raise PSAConnectionError( + "Cannot reach ConnectWise server", provider="connectwise" + ) + + # Rate limit — retry with Retry-After backoff + if resp.status_code == 429: + if attempt < retries: + retry_after = int(resp.headers.get("Retry-After", "5")) + await asyncio.sleep(retry_after) + continue + raise PSARateLimitError( + "ConnectWise rate limit exceeded", + retry_after_seconds=int( + resp.headers.get("Retry-After", "60") + ), + provider="connectwise", + ) + + # Map error status codes to typed exceptions + if resp.status_code == 401: + raise PSAAuthError( + "Invalid credentials. Check your API keys.", + provider="connectwise", + ) + if resp.status_code == 403: + raise PSAPermissionError( + "Insufficient permissions. Check the API member's security role.", + provider="connectwise", + ) + if resp.status_code == 404: + raise PSANotFoundError( + "Resource not found.", provider="connectwise" + ) + if resp.status_code >= 500: + if attempt < retries: + await asyncio.sleep(2 ** attempt) + continue + raise PSAServerError( + "ConnectWise is experiencing issues. Try again.", + provider="connectwise", + ) + + resp.raise_for_status() + if resp.status_code == 204: + return None + return resp.json() + + # Should not reach here, but satisfy type checker + raise PSAConnectionError( + "Request failed after all retries", provider="connectwise" + ) + + async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: + """GET request to CW API.""" + return await self._request("GET", path, params=params) + + async def post(self, path: str, json_body: Any = None) -> Any: + """POST request to CW API.""" + return await self._request("POST", path, json_body=json_body) + + async def patch(self, path: str, json_body: Any = None) -> Any: + """PATCH request to CW API (JSON Patch array format). + + CW uses JSON Patch syntax: [{"op": "replace", "path": "field", "value": ...}] + """ + return await self._request("PATCH", path, json_body=json_body) + + async def delete(self, path: str) -> Any: + """DELETE request to CW API.""" + return await self._request("DELETE", path) + + async def get_paginated( + self, + path: str, + params: dict[str, Any] | None = None, + max_pages: int = 10, + ) -> list[Any]: + """Fetch all pages of a paginated CW endpoint. + + Uses navigable pagination with page/pageSize params. + Stops when a page returns fewer results than pageSize or max_pages is reached. + """ + params = dict(params or {}) + params.setdefault("pageSize", MAX_PAGE_SIZE) + all_results: list[Any] = [] + + for page in range(1, max_pages + 1): + params["page"] = page + results = await self.get(path, params=params) + if not results: + break + all_results.extend(results) + if len(results) < params["pageSize"]: + break + + return all_results -- 2.49.1 From 2086ecb5ea6b515346b680ad35a23358b95ef096 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 21:52:23 -0400 Subject: [PATCH 06/29] feat(psa): add ConnectWiseProvider with test_connection + provider registry ConnectWiseProvider implements PSAProvider with test_connection() that calls GET /system/info to verify credentials and connectivity. All other methods raise NotImplementedError with slice references for future work. Provider registry (get_provider_for_account) looks up the account's PsaConnection, decrypts stored credentials, and instantiates the correct provider. Currently supports connectwise only. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/services/psa/connectwise/provider.py | 76 +++++++++++++++++++ backend/app/services/psa/registry.py | 50 ++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 backend/app/services/psa/connectwise/provider.py create mode 100644 backend/app/services/psa/registry.py diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py new file mode 100644 index 00000000..6a9f211d --- /dev/null +++ b/backend/app/services/psa/connectwise/provider.py @@ -0,0 +1,76 @@ +"""ConnectWise implementation of PSAProvider.""" +from __future__ import annotations + +from app.services.psa.base import PSAProvider +from app.services.psa.types import ( + ConnectionTestResult, + PSATicket, + PSANote, + PSAStatus, + PSACompany, + PSAMember, + PSAConfiguration, +) +from .client import ConnectWiseClient + + +class ConnectWiseProvider(PSAProvider): + """ConnectWise PSA provider implementation.""" + + def __init__(self, client: ConnectWiseClient): + self.client = client + + async def test_connection(self) -> ConnectionTestResult: + """Test the CW connection by fetching system info.""" + try: + info = await self.client.get("/system/info") + return ConnectionTestResult( + success=True, + message="Connected successfully.", + server_version=info.get("version", None), + ) + except Exception as e: + return ConnectionTestResult( + success=False, + message=str(e), + server_version=None, + ) + + # ── Stubs for Phase A slices 2-5 ───────────────────────────────── + + async def get_ticket(self, ticket_id: str) -> PSATicket: + raise NotImplementedError("Implemented in Slice 2") + + async def search_tickets(self, query: str, **filters) -> list[PSATicket]: + raise NotImplementedError("Implemented in Slice 3") + + async def post_note( + self, + ticket_id: str, + text: str, + note_type: str, + member_id: str | None = None, + ) -> PSANote: + raise NotImplementedError("Implemented in Slice 4") + + async def update_ticket_status( + self, ticket_id: str, status_id: int + ) -> PSATicket: + raise NotImplementedError("Implemented in Slice 4") + + async def get_ticket_statuses(self, board_id: int) -> list[PSAStatus]: + raise NotImplementedError("Implemented in Slice 3") + + async def list_companies(self, **filters) -> list[PSACompany]: + raise NotImplementedError("Implemented in Slice 3") + + async def get_company(self, company_id: str) -> PSACompany: + raise NotImplementedError("Implemented in Slice 3") + + async def list_members(self) -> list[PSAMember]: + raise NotImplementedError("Implemented in Slice 5") + + async def get_ticket_configurations( + self, ticket_id: str + ) -> list[PSAConfiguration]: + raise NotImplementedError("Implemented in Slice 3") diff --git a/backend/app/services/psa/registry.py b/backend/app/services/psa/registry.py new file mode 100644 index 00000000..7f4428ac --- /dev/null +++ b/backend/app/services/psa/registry.py @@ -0,0 +1,50 @@ +"""Factory for instantiating PSA providers from stored connection data.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.psa_connection import PsaConnection +from app.services.psa.base import PSAProvider +from app.services.psa.encryption import decrypt_credentials +from app.services.psa.exceptions import PSAConnectionError + + +async def get_provider_for_account( + account_id: UUID, db: AsyncSession +) -> PSAProvider: + """Look up account's PSA connection, decrypt credentials, instantiate provider.""" + result = await db.execute( + select(PsaConnection).where( + PsaConnection.account_id == account_id, + PsaConnection.is_active.is_(True), + ) + ) + connection = result.scalar_one_or_none() + + if not connection: + raise PSAConnectionError( + "No active PSA connection configured for this account.", + provider="unknown", + ) + + if connection.provider == "connectwise": + from app.services.psa.connectwise.client import ConnectWiseClient + from app.services.psa.connectwise.provider import ConnectWiseProvider + + creds = decrypt_credentials(connection.credentials_encrypted) + client = ConnectWiseClient( + site_url=connection.site_url, + company_id=connection.company_id, + public_key=creds["public_key"], + private_key=creds["private_key"], + client_id=creds["client_id"], + ) + return ConnectWiseProvider(client) + + raise PSAConnectionError( + f"Unsupported PSA provider: {connection.provider}", + provider=connection.provider, + ) -- 2.49.1 From 910b3c4aefd77cbff239085ff184a59aeef7ce24 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 21:53:57 -0400 Subject: [PATCH 07/29] feat: add Pydantic schemas for PSA connection CRUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionResponse, and PsaConnectionTestResponse — registered in schemas __init__. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/schemas/__init__.py | 5 +++ backend/app/schemas/psa_connection.py | 47 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 backend/app/schemas/psa_connection.py diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index c761c02f..b02d0cc6 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -15,6 +15,9 @@ from .script_template import ( ScriptTemplateCreate, ScriptTemplateUpdate, ScriptTemplateListItem, ScriptTemplateDetail, ScriptGenerateRequest, ScriptGenerateResponse, ScriptGenerationRecord, ) +from .psa_connection import ( + PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionResponse, PsaConnectionTestResponse, +) __all__ = [ # User @@ -39,4 +42,6 @@ __all__ = [ "ScriptCategoryResponse", "ScriptTemplateCreate", "ScriptTemplateUpdate", "ScriptTemplateListItem", "ScriptTemplateDetail", "ScriptGenerateRequest", "ScriptGenerateResponse", "ScriptGenerationRecord", + # PSA Connection + "PsaConnectionCreate", "PsaConnectionUpdate", "PsaConnectionResponse", "PsaConnectionTestResponse", ] diff --git a/backend/app/schemas/psa_connection.py b/backend/app/schemas/psa_connection.py new file mode 100644 index 00000000..b9591e9a --- /dev/null +++ b/backend/app/schemas/psa_connection.py @@ -0,0 +1,47 @@ +"""Pydantic schemas for PSA connection management.""" +from __future__ import annotations +from datetime import datetime +from uuid import UUID +from pydantic import BaseModel, Field + + +class PsaConnectionCreate(BaseModel): + provider: str = Field(default="connectwise", pattern="^(connectwise|autotask)$") + display_name: str = Field(min_length=1, max_length=100) + site_url: str = Field(min_length=1, max_length=255) + company_id: str = Field(min_length=1, max_length=100) + public_key: str = Field(min_length=1) + private_key: str = Field(min_length=1) + client_id: str = Field(min_length=1) + + +class PsaConnectionUpdate(BaseModel): + display_name: str | None = None + site_url: str | None = None + company_id: str | None = None + public_key: str | None = None + private_key: str | None = None + client_id: str | None = None + + +class PsaConnectionResponse(BaseModel): + id: UUID + account_id: UUID + provider: str + display_name: str + site_url: str + company_id: str + is_active: bool + last_validated_at: datetime | None + created_at: datetime + updated_at: datetime + public_key_hint: str + private_key_hint: str + + model_config = {"from_attributes": True} + + +class PsaConnectionTestResponse(BaseModel): + success: bool + message: str + server_version: str | None = None -- 2.49.1 From 08e1b4bf3860c075329c9ac0998d12ec36171bd2 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 22:06:48 -0400 Subject: [PATCH 08/29] feat: add PSA connection API endpoints with RBAC and tests Five endpoints under /integrations/psa/connections: - GET (any auth user), POST/PUT/DELETE/test (owner+ only) - Create tests CW connection before saving; update re-tests on cred change - Credentials decrypted only for masked hints in responses - Three CI-safe tests: empty GET, engineer 403, delete 404 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/integrations.py | 281 ++++++++++++++++++++++ backend/app/api/router.py | 2 + backend/tests/test_psa_connections.py | 59 +++++ 3 files changed, 342 insertions(+) create mode 100644 backend/app/api/endpoints/integrations.py create mode 100644 backend/tests/test_psa_connections.py diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py new file mode 100644 index 00000000..d1a579e7 --- /dev/null +++ b/backend/app/api/endpoints/integrations.py @@ -0,0 +1,281 @@ +"""PSA integration endpoints — connection CRUD and test.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Annotated +from uuid import UUID + +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.core.database import get_db +from app.models.psa_connection import PsaConnection +from app.models.user import User +from app.schemas.psa_connection import ( + PsaConnectionCreate, + PsaConnectionResponse, + PsaConnectionTestResponse, + PsaConnectionUpdate, +) +from app.services.psa.encryption import ( + decrypt_credentials, + encrypt_credentials, + mask_credential, +) + +router = APIRouter(prefix="/integrations/psa", tags=["integrations"]) + + +# ── helpers ────────────────────────────────────────────────────────── + +def _to_response(conn: PsaConnection) -> PsaConnectionResponse: + """Build a response DTO with masked credential hints.""" + creds = decrypt_credentials(conn.credentials_encrypted) + return PsaConnectionResponse( + id=conn.id, + account_id=conn.account_id, + provider=conn.provider, + display_name=conn.display_name, + site_url=conn.site_url, + company_id=conn.company_id, + is_active=conn.is_active, + last_validated_at=conn.last_validated_at, + created_at=conn.created_at, + updated_at=conn.updated_at, + public_key_hint=mask_credential(creds.get("public_key")), + private_key_hint=mask_credential(creds.get("private_key")), + ) + + +async def _get_connection( + account_id: UUID, db: AsyncSession +) -> PsaConnection | None: + result = await db.execute( + select(PsaConnection).where(PsaConnection.account_id == account_id) + ) + return result.scalar_one_or_none() + + +async def _test_credentials( + provider: str, + site_url: str, + company_id: str, + public_key: str, + private_key: str, + client_id: str, +) -> PsaConnectionTestResponse: + """Instantiate a provider and run test_connection.""" + if provider == "connectwise": + from app.services.psa.connectwise.client import ConnectWiseClient + from app.services.psa.connectwise.provider import ConnectWiseProvider + + client = ConnectWiseClient( + site_url=site_url, + company_id=company_id, + public_key=public_key, + private_key=private_key, + client_id=client_id, + ) + result = await ConnectWiseProvider(client).test_connection() + return PsaConnectionTestResponse( + success=result.success, + message=result.message, + server_version=result.server_version, + ) + + return PsaConnectionTestResponse( + success=False, + message=f"Unsupported provider: {provider}", + ) + + +# ── endpoints ──────────────────────────────────────────────────────── + +@router.get("/connections", response_model=PsaConnectionResponse | None) +async def get_connection( + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Return the account's PSA connection (redacted credentials) or null.""" + if not current_user.account_id: + return None + conn = await _get_connection(current_user.account_id, db) + if not conn: + return None + return _to_response(conn) + + +@router.post( + "/connections", + response_model=PsaConnectionResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_connection( + data: PsaConnectionCreate, + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Create a new PSA connection. Tests credentials before saving.""" + if not current_user.account_id: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "No account associated with user") + + # Check for existing connection + existing = await _get_connection(current_user.account_id, db) + if existing: + raise HTTPException( + status.HTTP_409_CONFLICT, + "A PSA connection already exists for this account. Update or delete the existing one.", + ) + + # Test connection before saving + test_result = await _test_credentials( + provider=data.provider, + site_url=data.site_url, + company_id=data.company_id, + public_key=data.public_key, + private_key=data.private_key, + client_id=data.client_id, + ) + if not test_result.success: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + f"Connection test failed: {test_result.message}", + ) + + credentials = { + "public_key": data.public_key, + "private_key": data.private_key, + "client_id": data.client_id, + } + + conn = PsaConnection( + account_id=current_user.account_id, + provider=data.provider, + display_name=data.display_name, + site_url=data.site_url, + company_id=data.company_id, + credentials_encrypted=encrypt_credentials(credentials), + is_active=True, + last_validated_at=datetime.now(timezone.utc), + ) + db.add(conn) + await db.commit() + await db.refresh(conn) + return _to_response(conn) + + +@router.put("/connections/{connection_id}", response_model=PsaConnectionResponse) +async def update_connection( + connection_id: UUID, + data: PsaConnectionUpdate, + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Update an existing PSA connection. Re-tests if credentials change.""" + conn = await _get_connection_or_404(connection_id, current_user, db) + + # Decrypt existing credentials + creds = decrypt_credentials(conn.credentials_encrypted) + + # Track whether credential fields changed + cred_fields = {"public_key", "private_key", "client_id"} + cred_changed = False + + # Apply updates + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if field in cred_fields: + if value is not None and value != creds.get(field): + creds[field] = value + cred_changed = True + else: + setattr(conn, field, value) + + # Re-test if credentials changed + if cred_changed: + site_url = update_data.get("site_url", conn.site_url) + company_id_val = update_data.get("company_id", conn.company_id) + + test_result = await _test_credentials( + provider=conn.provider, + site_url=site_url, + company_id=company_id_val, + public_key=creds["public_key"], + private_key=creds["private_key"], + client_id=creds["client_id"], + ) + if not test_result.success: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + f"Connection test failed: {test_result.message}", + ) + conn.credentials_encrypted = encrypt_credentials(creds) + conn.last_validated_at = datetime.now(timezone.utc) + + conn.updated_at = datetime.now(timezone.utc) + await db.commit() + await db.refresh(conn) + return _to_response(conn) + + +@router.delete( + "/connections/{connection_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_connection( + connection_id: UUID, + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Delete a PSA connection.""" + conn = await _get_connection_or_404(connection_id, current_user, db) + await db.delete(conn) + await db.commit() + + +@router.post( + "/connections/{connection_id}/test", + response_model=PsaConnectionTestResponse, +) +async def test_connection( + connection_id: UUID, + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Test an existing PSA connection.""" + conn = await _get_connection_or_404(connection_id, current_user, db) + creds = decrypt_credentials(conn.credentials_encrypted) + + result = await _test_credentials( + provider=conn.provider, + site_url=conn.site_url, + company_id=conn.company_id, + public_key=creds["public_key"], + private_key=creds["private_key"], + client_id=creds["client_id"], + ) + + if result.success: + conn.last_validated_at = datetime.now(timezone.utc) + await db.commit() + + return result + + +# ── internal helpers ───────────────────────────────────────────────── + +async def _get_connection_or_404( + connection_id: UUID, user: User, db: AsyncSession +) -> PsaConnection: + """Fetch a connection by ID, ensuring it belongs to the user's account.""" + result = await db.execute( + select(PsaConnection).where(PsaConnection.id == connection_id) + ) + conn = result.scalar_one_or_none() + if not conn: + raise HTTPException(status.HTTP_404_NOT_FOUND, "PSA connection not found") + if conn.account_id != user.account_id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "PSA connection not found") + return conn diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 13293cca..0e656178 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -17,6 +17,7 @@ from app.api.endpoints import ai_suggestions from app.api.endpoints import kb_accelerator from app.api.endpoints import beta_signup from app.api.endpoints import scripts +from app.api.endpoints import integrations api_router = APIRouter() @@ -58,3 +59,4 @@ api_router.include_router(ai_suggestions.router) api_router.include_router(kb_accelerator.router) api_router.include_router(beta_signup.router) api_router.include_router(scripts.router) +api_router.include_router(integrations.router) diff --git a/backend/tests/test_psa_connections.py b/backend/tests/test_psa_connections.py new file mode 100644 index 00000000..661c59dd --- /dev/null +++ b/backend/tests/test_psa_connections.py @@ -0,0 +1,59 @@ +"""Tests for PSA connection endpoints — routing and RBAC only. + +We cannot fully test create/update/test endpoints in CI because they +call the ConnectWise API. These tests verify routing and authorization. +""" +import pytest +from sqlalchemy import select, update +from app.models.user import User + + +@pytest.mark.asyncio +async def test_get_connection_empty(client, admin_auth_headers): + """GET returns null when no connection exists.""" + response = await client.get( + "/api/v1/integrations/psa/connections", + headers=admin_auth_headers, + ) + assert response.status_code == 200 + assert response.json() is None + + +@pytest.mark.asyncio +async def test_create_connection_requires_owner(client, test_user, auth_headers, test_db): + """Engineer (non-owner) should get 403 on create.""" + # Downgrade the test user from owner to engineer so require_account_owner rejects + user_id = test_user["user_data"]["id"] + await test_db.execute( + update(User).where(User.id == user_id).values(account_role="engineer") + ) + await test_db.commit() + + payload = { + "provider": "connectwise", + "display_name": "Test CW", + "site_url": "https://na.myconnectwise.net", + "company_id": "testmsp", + "public_key": "pub123", + "private_key": "priv456", + "client_id": "client789", + } + response = await client.post( + "/api/v1/integrations/psa/connections", + json=payload, + headers=auth_headers, + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_delete_nonexistent_returns_404(client, admin_auth_headers): + """DELETE with a nonexistent ID returns 404.""" + import uuid + + fake_id = uuid.uuid4() + response = await client.delete( + f"/api/v1/integrations/psa/connections/{fake_id}", + headers=admin_auth_headers, + ) + assert response.status_code == 404 -- 2.49.1 From ad9d4271d61dac4e7e8dd0da307227d2093d735c Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 22:14:50 -0400 Subject: [PATCH 09/29] feat(psa): add Integrations page with connection management UI Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/api/index.ts | 1 + frontend/src/api/integrations.ts | 15 + frontend/src/pages/AccountSettingsPage.tsx | 19 +- .../src/pages/account/IntegrationsPage.tsx | 505 ++++++++++++++++++ frontend/src/router.tsx | 9 + frontend/src/types/index.ts | 1 + frontend/src/types/integrations.ts | 39 ++ 7 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/integrations.ts create mode 100644 frontend/src/pages/account/IntegrationsPage.tsx create mode 100644 frontend/src/types/integrations.ts diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 13ce8733..e957c82f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -22,3 +22,4 @@ export { assistantChatApi } from './assistantChat' export { flowTransferApi } from './flowTransfer' export { kbAcceleratorApi } from './kbAccelerator' export { scriptsApi } from './scripts' +export { integrationsApi } from './integrations' diff --git a/frontend/src/api/integrations.ts b/frontend/src/api/integrations.ts new file mode 100644 index 00000000..f290979c --- /dev/null +++ b/frontend/src/api/integrations.ts @@ -0,0 +1,15 @@ +import { apiClient } from './client' +import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types' + +export const integrationsApi = { + getConnection: () => + apiClient.get('/integrations/psa/connections').then(r => r.data), + createConnection: (data: PsaConnectionCreate) => + apiClient.post('/integrations/psa/connections', data).then(r => r.data), + updateConnection: (id: string, data: PsaConnectionUpdate) => + apiClient.put(`/integrations/psa/connections/${id}`, data).then(r => r.data), + deleteConnection: (id: string) => + apiClient.delete(`/integrations/psa/connections/${id}`), + testConnection: (id: string) => + apiClient.post(`/integrations/psa/connections/${id}/test`).then(r => r.data), +} diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index cbdd444f..3da03ff3 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' -import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock } from 'lucide-react' +import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug } from 'lucide-react' import { PageMeta } from '@/components/common/PageMeta' import { accountsApi } from '@/api/accounts' import type { Account, AccountMember, AccountInvite } from '@/types' @@ -555,6 +555,23 @@ export function AccountSettingsPage() { )} + {/* Integrations Link (owners only) */} + {isAccountOwner && ( + +
+ +
+

Integrations

+

Connect your PSA to sync session documentation to tickets

+
+
+ + + )} + {/* Feedback Link (all users) */} (null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + // Form state + const [mode, setMode] = useState<'view' | 'setup' | 'edit'>('setup') + const [form, setForm] = useState(emptyForm) + const [isSaving, setIsSaving] = useState(false) + const [formError, setFormError] = useState(null) + + // Test state + const [isTesting, setIsTesting] = useState(false) + const [testResult, setTestResult] = useState(null) + + // Delete state + const [isDeleting, setIsDeleting] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + useEffect(() => { + loadConnection() + }, []) + + const loadConnection = async () => { + setIsLoading(true) + setError(null) + try { + const data = await integrationsApi.getConnection() + setConnection(data) + setMode(data ? 'view' : 'setup') + } catch (err) { + // 404 means no connection exists — that's fine + const axiosErr = err as { response?: { status?: number } } + if (axiosErr.response?.status === 404) { + setConnection(null) + setMode('setup') + } else { + setError('Failed to load integration settings') + console.error(err) + } + } finally { + setIsLoading(false) + } + } + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault() + setIsSaving(true) + setFormError(null) + try { + const payload: PsaConnectionCreate = { + provider: 'connectwise', + ...form, + } + const created = await integrationsApi.createConnection(payload) + setConnection(created) + setMode('view') + setForm(emptyForm) + } catch (err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + setFormError(axiosErr.response?.data?.detail || 'Failed to create connection. Please check your credentials and try again.') + console.error(err) + } finally { + setIsSaving(false) + } + } + + const handleUpdate = async (e: React.FormEvent) => { + e.preventDefault() + if (!connection) return + setIsSaving(true) + setFormError(null) + try { + const update: PsaConnectionUpdate = {} + if (form.display_name && form.display_name !== connection.display_name) update.display_name = form.display_name + if (form.site_url && form.site_url !== connection.site_url) update.site_url = form.site_url + if (form.company_id && form.company_id !== connection.company_id) update.company_id = form.company_id + if (form.public_key) update.public_key = form.public_key + if (form.private_key) update.private_key = form.private_key + if (form.client_id) update.client_id = form.client_id + + const updated = await integrationsApi.updateConnection(connection.id, update) + setConnection(updated) + setMode('view') + setForm(emptyForm) + } catch (err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + setFormError(axiosErr.response?.data?.detail || 'Failed to update connection.') + console.error(err) + } finally { + setIsSaving(false) + } + } + + const handleTest = async () => { + if (!connection) return + setIsTesting(true) + setTestResult(null) + try { + const result = await integrationsApi.testConnection(connection.id) + setTestResult(result) + } catch (err) { + setTestResult({ success: false, message: 'Connection test failed. Check your credentials.', server_version: null }) + console.error(err) + } finally { + setIsTesting(false) + } + } + + const handleDelete = async () => { + if (!connection) return + setIsDeleting(true) + try { + await integrationsApi.deleteConnection(connection.id) + setConnection(null) + setMode('setup') + setForm(emptyForm) + setShowDeleteConfirm(false) + setTestResult(null) + } catch (err) { + console.error('Failed to delete connection:', err) + } finally { + setIsDeleting(false) + } + } + + const startEdit = () => { + if (!connection) return + setForm({ + display_name: connection.display_name, + site_url: connection.site_url, + company_id: connection.company_id, + public_key: '', + private_key: '', + client_id: '', + }) + setFormError(null) + setTestResult(null) + setMode('edit') + } + + const cancelEdit = () => { + setMode('view') + setForm(emptyForm) + setFormError(null) + } + + if (isLoading) { + return ( + <> + +
+ +
+ + ) + } + + if (error) { + return ( + <> + +
+
+ + {error} +
+
+ + ) + } + + return ( + <> + +
+
+
+ +

Integrations

+
+

+ Connect your PSA to post session documentation directly to tickets. +

+
+ +
+ {/* Setup / Edit Form */} + {(mode === 'setup' || mode === 'edit') && ( +
+
+ +

+ {mode === 'setup' ? 'Connect to ConnectWise PSA' : 'Edit Connection'} +

+
+ +
+
+ + setForm({ ...form, display_name: e.target.value })} + placeholder="My ConnectWise Instance" + required={mode === 'setup'} + className="mt-1" + /> +
+ +
+ + setForm({ ...form, site_url: e.target.value })} + placeholder="na.myconnectwise.net" + required={mode === 'setup'} + className="mt-1" + /> +
+ +
+ + setForm({ ...form, company_id: e.target.value })} + placeholder="mycompany" + required={mode === 'setup'} + className="mt-1" + /> +
+ +
+ + setForm({ ...form, public_key: e.target.value })} + placeholder={mode === 'edit' && connection ? `Current: ${connection.public_key_hint}` : 'Enter public key'} + required={mode === 'setup'} + className="mt-1" + /> +
+ +
+ + setForm({ ...form, private_key: e.target.value })} + placeholder={mode === 'edit' && connection ? `Current: ${connection.private_key_hint}` : 'Enter private key'} + required={mode === 'setup'} + className="mt-1" + /> +
+ +
+ + setForm({ ...form, client_id: e.target.value })} + placeholder="ConnectWise Developer Client ID" + required={mode === 'setup'} + className="mt-1" + /> +
+ + {formError && ( +
+ + {formError} +
+ )} + +
+ + + {mode === 'edit' && ( + + )} +
+
+
+ )} + + {/* Connected View */} + {mode === 'view' && connection && ( +
+ {/* Status Card */} +
+
+
+ + ConnectWise + +

{connection.display_name}

+
+
+ + + {connection.is_active ? 'Connected' : 'Not validated'} + +
+
+ +
+
+

Site URL

+

{connection.site_url}

+
+
+

Company ID

+

{connection.company_id}

+
+
+

Public Key

+

{connection.public_key_hint}

+
+
+

Private Key

+

{connection.private_key_hint}

+
+
+

Last Validated

+

+ {connection.last_validated_at ? formatRelativeTime(connection.last_validated_at) : 'Never'} +

+
+
+
+ + {/* Test Result */} + {testResult && ( +
+ {testResult.success ? ( + + ) : ( + + )} + {testResult.message} + {testResult.server_version && ( + + v{testResult.server_version} + + )} +
+ )} + + {/* Action Buttons */} +
+ + + + + {showDeleteConfirm ? ( +
+ Disconnect? + + +
+ ) : ( + + )} +
+
+ )} +
+
+ + ) +} + +export default IntegrationsPage diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 6bc9b177..b43743bf 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -68,6 +68,7 @@ const ProfileSettingsPage = lazy(() => import('@/pages/account/ProfileSettingsPa const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage')) const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage')) const ChatRetentionSettingsPage = lazy(() => import('@/pages/account/ChatRetentionSettingsPage')) +const IntegrationsPage = lazy(() => import('@/pages/account/IntegrationsPage')) /** Wraps a lazy-loaded page with Suspense + ErrorBoundary */ function page(Component: React.LazyExoticComponent) { @@ -224,6 +225,14 @@ export const router = sentryCreateBrowserRouter([ ), }, { path: 'target-lists', element: page(TargetListsPage) }, + { + path: 'integrations', + element: ( + + {page(IntegrationsPage)} + + ), + }, ], }, ], diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6ea816d7..7628007f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -89,3 +89,4 @@ export type { } from './kbAccelerator' export * from './scripts' +export * from './integrations' diff --git a/frontend/src/types/integrations.ts b/frontend/src/types/integrations.ts new file mode 100644 index 00000000..95b7c03b --- /dev/null +++ b/frontend/src/types/integrations.ts @@ -0,0 +1,39 @@ +export interface PsaConnectionResponse { + id: string + account_id: string + provider: string + display_name: string + site_url: string + company_id: string + is_active: boolean + last_validated_at: string | null + created_at: string + updated_at: string + public_key_hint: string + private_key_hint: string +} + +export interface PsaConnectionCreate { + provider: string + display_name: string + site_url: string + company_id: string + public_key: string + private_key: string + client_id: string +} + +export interface PsaConnectionUpdate { + display_name?: string + site_url?: string + company_id?: string + public_key?: string + private_key?: string + client_id?: string +} + +export interface PsaConnectionTestResponse { + success: boolean + message: string + server_version: string | null +} -- 2.49.1 From 2a53f48d69977ea0d9c871819da75e8643881034 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 22:44:58 -0400 Subject: [PATCH 10/29] feat(psa): implement get_ticket in ConnectWise provider Replace NotImplementedError stub with real implementation that fetches a ticket by ID via CW REST API and maps it to PSATicket. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/services/psa/connectwise/provider.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py index 6a9f211d..b7527395 100644 --- a/backend/app/services/psa/connectwise/provider.py +++ b/backend/app/services/psa/connectwise/provider.py @@ -39,7 +39,24 @@ class ConnectWiseProvider(PSAProvider): # ── Stubs for Phase A slices 2-5 ───────────────────────────────── async def get_ticket(self, ticket_id: str) -> PSATicket: - raise NotImplementedError("Implemented in Slice 2") + """Fetch a single ticket by ID from ConnectWise.""" + data = await self.client.get( + f"/service/tickets/{ticket_id}", + params={"fields": "id,summary,company,board,status,priority,closedFlag"}, + ) + return PSATicket( + id=str(data["id"]), + summary=data.get("summary", ""), + company_name=data.get("company", {}).get("name"), + company_id=str(data["company"]["id"]) if data.get("company") else None, + board_name=data.get("board", {}).get("name"), + board_id=data.get("board", {}).get("id"), + status_name=data.get("status", {}).get("name"), + status_id=data.get("status", {}).get("id"), + priority_name=data.get("priority", {}).get("name"), + priority_id=data.get("priority", {}).get("id"), + closed=data.get("closedFlag", False), + ) async def search_tickets(self, query: str, **filters) -> list[PSATicket]: raise NotImplementedError("Implemented in Slice 3") -- 2.49.1 From 5bcaf6a9d4cb0fdfb51a27557c77f33b77c46942 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 22:45:04 -0400 Subject: [PATCH 11/29] feat(psa): add psa_ticket_id and psa_connection_id to sessions Add columns to link sessions to PSA tickets and connections. Includes migration 059, model relationship, and response schema fields. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../059_add_psa_ticket_link_to_sessions.py | 31 +++++++++++++++++++ backend/app/models/session.py | 9 ++++++ backend/app/schemas/session.py | 29 +++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 backend/alembic/versions/059_add_psa_ticket_link_to_sessions.py diff --git a/backend/alembic/versions/059_add_psa_ticket_link_to_sessions.py b/backend/alembic/versions/059_add_psa_ticket_link_to_sessions.py new file mode 100644 index 00000000..cb7dc14c --- /dev/null +++ b/backend/alembic/versions/059_add_psa_ticket_link_to_sessions.py @@ -0,0 +1,31 @@ +"""Add psa_ticket_id and psa_connection_id to sessions. + +Revision ID: 059 +Revises: 058 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "059" +down_revision = "058" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("sessions", sa.Column("psa_ticket_id", sa.String(100), nullable=True)) + op.add_column( + "sessions", + sa.Column( + "psa_connection_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("psa_connections.id", ondelete="SET NULL"), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_column("sessions", "psa_connection_id") + op.drop_column("sessions", "psa_ticket_id") diff --git a/backend/app/models/session.py b/backend/app/models/session.py index 1e41c534..bbab74cf 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -83,6 +83,15 @@ class Session(Base): attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session") shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan") + # PSA ticket link + psa_ticket_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("psa_connections.id", ondelete="SET NULL"), + nullable=True, + ) + psa_connection = relationship("PsaConnection", foreign_keys=[psa_connection_id]) + # Batch tracking (maintenance flows) batch_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), nullable=True, index=True diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index 61119b46..b8e10f9b 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -94,6 +94,10 @@ class SessionResponse(BaseModel): batch_id: Optional[UUID] = None target_label: Optional[str] = None + # PSA ticket link + psa_ticket_id: Optional[str] = None + psa_connection_id: Optional[UUID] = None + class Config: from_attributes = True @@ -140,3 +144,28 @@ class SaveAsTreeResponse(BaseModel): tree_id: UUID tree_name: str message: str + + +# ── PSA ticket link ────────────────────────────────────────────────── + + +class TicketLinkRequest(BaseModel): + """Link or unlink a PSA ticket to a session.""" + psa_ticket_id: Optional[str] = None # null to unlink + + +class PSATicketResponse(BaseModel): + """PSA ticket details returned when linking.""" + id: str + summary: str + company_name: Optional[str] = None + board_name: Optional[str] = None + status_name: Optional[str] = None + priority_name: Optional[str] = None + + +class TicketLinkResponse(BaseModel): + """Response after linking/unlinking a ticket.""" + session_id: str + psa_ticket_id: Optional[str] = None + ticket: Optional[PSATicketResponse] = None -- 2.49.1 From 7eaab77daa3487aacc0540f5d28c0114ea788379 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 22:45:10 -0400 Subject: [PATCH 12/29] feat(psa): add ticket link/unlink endpoint for sessions PATCH /sessions/{id}/ticket-link validates ticket exists in ConnectWise before linking, supports unlinking by sending null, and returns ticket details on success. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/sessions.py | 103 +++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 951489a7..c560dba2 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -23,8 +23,11 @@ from app.schemas.session import ( SessionComplete, SessionVariablesUpdate, PrepareSessionRequest, + TicketLinkRequest, + TicketLinkResponse, + PSATicketResponse, ) -from app.api.deps import get_current_active_user +from app.api.deps import get_current_active_user, require_engineer_or_admin from app.core.permissions import can_access_tree from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export @@ -738,3 +741,101 @@ async def batch_launch_sessions( for s in created_sessions ], ) + + +# ── PSA Ticket Link ───────────────────────────────────────────────── + + +@router.patch("/{session_id}/ticket-link", response_model=TicketLinkResponse) +async def link_ticket( + session_id: UUID, + data: TicketLinkRequest, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Link or unlink a PSA ticket to/from a session.""" + from app.models.psa_connection import PsaConnection + from app.services.psa.registry import get_provider_for_account + from app.services.psa.exceptions import PSANotFoundError, PSAError + + # Look up session + result = await db.execute(select(Session).where(Session.id == session_id)) + session = result.scalar_one_or_none() + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found", + ) + + # Verify ownership or admin + if session.user_id != current_user.id and session.assigned_to_id != current_user.id: + if not current_user.is_super_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have access to this session", + ) + + # Unlink + if data.psa_ticket_id is None: + session.psa_ticket_id = None + session.psa_connection_id = None + await db.commit() + return TicketLinkResponse( + session_id=str(session.id), + psa_ticket_id=None, + ticket=None, + ) + + # Link — validate ticket exists in CW + if not current_user.account_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No account associated with your user", + ) + + try: + provider = await get_provider_for_account(current_user.account_id, db) + except PSAError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + + # Fetch the connection to store its ID + conn_result = await db.execute( + select(PsaConnection).where( + PsaConnection.account_id == current_user.account_id, + PsaConnection.is_active.is_(True), + ) + ) + psa_connection = conn_result.scalar_one_or_none() + + try: + ticket = await provider.get_ticket(data.psa_ticket_id) + except PSANotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found in ConnectWise", + ) + except PSAError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"PSA error: {exc}", + ) + + session.psa_ticket_id = ticket.id + session.psa_connection_id = psa_connection.id if psa_connection else None + await db.commit() + + return TicketLinkResponse( + session_id=str(session.id), + psa_ticket_id=ticket.id, + ticket=PSATicketResponse( + id=ticket.id, + summary=ticket.summary, + company_name=ticket.company_name, + board_name=ticket.board_name, + status_name=ticket.status_name, + priority_name=ticket.priority_name, + ), + ) -- 2.49.1 From b76864a892e7c8439879a9b71b7b9954fff61c2d Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 22:56:21 -0400 Subject: [PATCH 13/29] feat(psa): add ticket picker modal and session header ticket link indicator Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/api/index.ts | 2 +- frontend/src/api/integrations.ts | 6 + .../session/TicketLinkIndicator.tsx | 74 +++++++ .../components/session/TicketPickerModal.tsx | 185 ++++++++++++++++++ .../src/pages/ProceduralNavigationPage.tsx | 56 ++++++ frontend/src/pages/TreeNavigationPage.tsx | 54 +++++ frontend/src/types/integrations.ts | 15 ++ frontend/src/types/session.ts | 2 + 8 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/session/TicketLinkIndicator.tsx create mode 100644 frontend/src/components/session/TicketPickerModal.tsx diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e957c82f..dbdc5c16 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -22,4 +22,4 @@ export { assistantChatApi } from './assistantChat' export { flowTransferApi } from './flowTransfer' export { kbAcceleratorApi } from './kbAccelerator' export { scriptsApi } from './scripts' -export { integrationsApi } from './integrations' +export { integrationsApi, sessionPsaApi } from './integrations' diff --git a/frontend/src/api/integrations.ts b/frontend/src/api/integrations.ts index f290979c..4e17029b 100644 --- a/frontend/src/api/integrations.ts +++ b/frontend/src/api/integrations.ts @@ -1,5 +1,6 @@ import { apiClient } from './client' import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types' +import type { TicketLinkResponse } from '@/types/integrations' export const integrationsApi = { getConnection: () => @@ -13,3 +14,8 @@ export const integrationsApi = { testConnection: (id: string) => apiClient.post(`/integrations/psa/connections/${id}/test`).then(r => r.data), } + +export const sessionPsaApi = { + linkTicket: (sessionId: string, psaTicketId: string | null) => + apiClient.patch(`/sessions/${sessionId}/ticket-link`, { psa_ticket_id: psaTicketId }).then(r => r.data), +} diff --git a/frontend/src/components/session/TicketLinkIndicator.tsx b/frontend/src/components/session/TicketLinkIndicator.tsx new file mode 100644 index 00000000..862a587e --- /dev/null +++ b/frontend/src/components/session/TicketLinkIndicator.tsx @@ -0,0 +1,74 @@ +import { Ticket, Unlink, Link2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { Session } from '@/types' +import type { PSATicketInfo } from '@/types/integrations' + +interface Props { + session: Session + hasConnection: boolean + onLinkClick: () => void + onUnlink: () => void + ticketInfo?: PSATicketInfo | null +} + +export function TicketLinkIndicator({ session, hasConnection, onLinkClick, onUnlink, ticketInfo }: Props) { + // No connection — show nothing + if (!hasConnection) return null + + // No ticket linked — show subtle "Link Ticket" button + if (!session.psa_ticket_id) { + return ( + + ) + } + + // Ticket linked + return ( +
+ +
+

+ CW #{session.psa_ticket_id} + {ticketInfo?.summary && ( + — {ticketInfo.summary} + )} +

+ {ticketInfo && ( +
+ {ticketInfo.company_name && {ticketInfo.company_name}} + {ticketInfo.board_name && ( + <> + + {ticketInfo.board_name} + + )} + {ticketInfo.status_name && ( + <> + + {ticketInfo.status_name} + + )} +
+ )} +
+ +
+ ) +} diff --git a/frontend/src/components/session/TicketPickerModal.tsx b/frontend/src/components/session/TicketPickerModal.tsx new file mode 100644 index 00000000..fc9f9c8e --- /dev/null +++ b/frontend/src/components/session/TicketPickerModal.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react' +import { Ticket, Search, AlertCircle, CheckCircle2 } from 'lucide-react' +import { Modal } from '@/components/common/Modal' +import { Input } from '@/components/ui/Input' +import { Button } from '@/components/ui/Button' +import { cn } from '@/lib/utils' +import { sessionPsaApi } from '@/api/integrations' +import type { PSATicketInfo } from '@/types/integrations' + +interface Props { + open: boolean + onClose: () => void + sessionId: string + onLinked: (ticketId: string, ticket: PSATicketInfo) => void +} + +export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props) { + const [ticketId, setTicketId] = useState('') + const [isLooking, setIsLooking] = useState(false) + const [isLinking, setIsLinking] = useState(false) + const [ticketInfo, setTicketInfo] = useState(null) + const [error, setError] = useState(null) + + const handleLookup = async () => { + const trimmed = ticketId.trim() + if (!trimmed) return + + setIsLooking(true) + setError(null) + setTicketInfo(null) + + try { + const result = await sessionPsaApi.linkTicket(sessionId, trimmed) + if (result.ticket) { + setTicketInfo(result.ticket) + } else { + setError('Ticket not found in ConnectWise') + } + } catch (err: unknown) { + const message = + err && typeof err === 'object' && 'response' in err + ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail + : null + setError(message || 'Failed to look up ticket. Please check the ticket number and try again.') + } finally { + setIsLooking(false) + } + } + + const handleLink = () => { + if (!ticketInfo) return + onLinked(ticketId.trim(), ticketInfo) + handleReset() + } + + const handleReset = () => { + setTicketId('') + setTicketInfo(null) + setError(null) + setIsLooking(false) + setIsLinking(false) + } + + const handleClose = () => { + handleReset() + onClose() + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && ticketId.trim() && !isLooking && !ticketInfo) { + handleLookup() + } + } + + return ( + +
+ {/* Ticket ID input */} +
+ +
+ { + setTicketId(e.target.value) + if (ticketInfo) { + setTicketInfo(null) + } + if (error) { + setError(null) + } + }} + onKeyDown={handleKeyDown} + disabled={isLooking || isLinking} + className="flex-1" + /> + +
+
+ + {/* Error */} + {error && ( +
+ +

{error}

+
+ )} + + {/* Ticket info card */} + {ticketInfo && ( +
+
+ +
+

+ CW #{ticketId.trim()} — {ticketInfo.summary} +

+
+ {ticketInfo.company_name && ( + {ticketInfo.company_name} + )} + {ticketInfo.board_name && ( + <> + + {ticketInfo.board_name} + + )} + {ticketInfo.status_name && ( + <> + + {ticketInfo.status_name} + + )} + {ticketInfo.priority_name && ( + <> + + {ticketInfo.priority_name} + + )} +
+
+
+ + +
+ )} + + {/* Skip link */} +
+ +
+
+
+ ) +} diff --git a/frontend/src/pages/ProceduralNavigationPage.tsx b/frontend/src/pages/ProceduralNavigationPage.tsx index 9184e177..90226fbd 100644 --- a/frontend/src/pages/ProceduralNavigationPage.tsx +++ b/frontend/src/pages/ProceduralNavigationPage.tsx @@ -24,6 +24,10 @@ import type { CustomStepDraft } from '@/components/step-library/CustomStepModal' import { PostStepActionModal } from '@/components/session/PostStepActionModal' import { CopilotPanel } from '@/components/copilot/CopilotPanel' import { CopilotToggle } from '@/components/copilot/CopilotToggle' +import { integrationsApi, sessionPsaApi } from '@/api/integrations' +import { TicketPickerModal } from '@/components/session/TicketPickerModal' +import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator' +import type { PSATicketInfo } from '@/types/integrations' interface StepState { notes: string @@ -86,6 +90,11 @@ export function ProceduralNavigationPage() { const [isSavingStep, setIsSavingStep] = useState(false) const [copilotOpen, setCopilotOpen] = useState(false) + // PSA ticket link state + const [hasConnection, setHasConnection] = useState(false) + const [showTicketPicker, setShowTicketPicker] = useState(false) + const [psaTicketInfo, setPsaTicketInfo] = useState(null) + // Editable variables panel state const [editingVarName, setEditingVarName] = useState(null) const [editingVarValue, setEditingVarValue] = useState('') @@ -131,6 +140,32 @@ export function ProceduralNavigationPage() { } }, [treeId]) + // Check for PSA connection on mount + useEffect(() => { + integrationsApi.getConnection() + .then((conn) => setHasConnection(!!conn)) + .catch(() => setHasConnection(false)) + }, []) + + const handleTicketLinked = (linkedTicketId: string, ticket: PSATicketInfo) => { + setPsaTicketInfo(ticket) + setSession((prev) => prev ? { ...prev, psa_ticket_id: linkedTicketId } : prev) + setShowTicketPicker(false) + toast.success(`Linked to CW #${linkedTicketId}`) + } + + const handleTicketUnlink = async () => { + if (!session) return + try { + await sessionPsaApi.linkTicket(session.id, null) + setSession((prev) => prev ? { ...prev, psa_ticket_id: null } : prev) + setPsaTicketInfo(null) + toast.success('Ticket unlinked') + } catch { + toast.error('Failed to unlink ticket') + } + } + // Parse backend timestamp — ensure UTC if no timezone info const parseTimestamp = (ts: string) => { if (!ts.endsWith('Z') && !ts.includes('+') && !/\d{2}:\d{2}$/.test(ts.slice(-5))) { @@ -584,6 +619,17 @@ export function ProceduralNavigationPage() { Exit + {session && ( +
+ setShowTicketPicker(true)} + onUnlink={handleTicketUnlink} + ticketInfo={psaTicketInfo} + /> +
+ )}
setCopilotOpen(true)} /> )} + + {/* Ticket Picker Modal */} + {session && ( + setShowTicketPicker(false)} + sessionId={session.id} + onLinked={handleTicketLinked} + /> + )}
) } diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx index ff389130..2511f0d2 100644 --- a/frontend/src/pages/TreeNavigationPage.tsx +++ b/frontend/src/pages/TreeNavigationPage.tsx @@ -22,6 +22,10 @@ import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sess import { CopilotPanel } from '@/components/copilot/CopilotPanel' import { CopilotToggle } from '@/components/copilot/CopilotToggle' import { Button } from '@/components/ui/Button' +import { integrationsApi, sessionPsaApi } from '@/api/integrations' +import { TicketPickerModal } from '@/components/session/TicketPickerModal' +import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator' +import type { PSATicketInfo } from '@/types/integrations' interface LocationState { sessionId?: string @@ -65,6 +69,11 @@ export function TreeNavigationPage() { const sharePopoverRef = useRef(null) const [copilotOpen, setCopilotOpen] = useState(false) + // PSA ticket link state + const [hasConnection, setHasConnection] = useState(false) + const [showTicketPicker, setShowTicketPicker] = useState(false) + const [ticketInfo, setTicketInfo] = useState(null) + const handleCopyCommand = (text: string) => { navigator.clipboard.writeText(text) setCopiedCommand(text) @@ -272,6 +281,32 @@ export function TreeNavigationPage() { } }, [treeId]) + // Check for PSA connection on mount + useEffect(() => { + integrationsApi.getConnection() + .then((conn) => setHasConnection(!!conn)) + .catch(() => setHasConnection(false)) + }, []) + + const handleTicketLinked = (linkedTicketId: string, ticket: PSATicketInfo) => { + setTicketInfo(ticket) + setSession((prev) => prev ? { ...prev, psa_ticket_id: linkedTicketId } : prev) + setShowTicketPicker(false) + toast.success(`Linked to CW #${linkedTicketId}`) + } + + const handleTicketUnlink = async () => { + if (!session) return + try { + await sessionPsaApi.linkTicket(session.id, null) + setSession((prev) => prev ? { ...prev, psa_ticket_id: null } : prev) + setTicketInfo(null) + toast.success('Ticket unlinked') + } catch { + toast.error('Failed to unlink ticket') + } + } + const loadTreeAndSession = async () => { setIsLoading(true) setError(null) @@ -656,6 +691,15 @@ export function TreeNavigationPage() { {clientName && `Client: ${clientName}`}

)} + {session && ( + setShowTicketPicker(true)} + onUnlink={handleTicketUnlink} + ticketInfo={ticketInfo} + /> + )}
{/* Share Progress Popover */} @@ -1251,6 +1295,16 @@ export function TreeNavigationPage() { onClose={() => setShowShareModal(false)} /> )} + + {/* Ticket Picker Modal */} + {session && ( + setShowTicketPicker(false)} + sessionId={session.id} + onLinked={handleTicketLinked} + /> + )}
diff --git a/frontend/src/types/integrations.ts b/frontend/src/types/integrations.ts index 95b7c03b..ef5542da 100644 --- a/frontend/src/types/integrations.ts +++ b/frontend/src/types/integrations.ts @@ -37,3 +37,18 @@ export interface PsaConnectionTestResponse { message: string server_version: string | null } + +export interface PSATicketInfo { + id: string + summary: string + company_name: string | null + board_name: string | null + status_name: string | null + priority_name: string | null +} + +export interface TicketLinkResponse { + session_id: string + psa_ticket_id: string | null + ticket: PSATicketInfo | null +} diff --git a/frontend/src/types/session.ts b/frontend/src/types/session.ts index 8089d781..9f87b191 100644 --- a/frontend/src/types/session.ts +++ b/frontend/src/types/session.ts @@ -64,6 +64,8 @@ export interface Session { assigned_to_id?: string | null batch_id?: string target_label?: string + psa_ticket_id?: string | null + psa_connection_id?: string | null } export interface SessionCreate { -- 2.49.1 From 5a35c933e0d29bc65b6b1941a0e03bb328417e2a Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 23:06:57 -0400 Subject: [PATCH 14/29] feat(psa): implement search_tickets, get_ticket_statuses, list_companies in CW provider Implement all remaining NotImplementedError stubs in ConnectWiseProvider: - search_tickets: query by summary with board_id, status_id, include_closed filters - get_ticket_statuses: fetch statuses for a service board - list_companies: list companies with optional status filter - get_company: fetch a single company by ID - get_ticket_configurations: fetch configs attached to a ticket - Extract shared _map_ticket helper to reduce duplication Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/services/psa/connectwise/provider.py | 157 ++++++++++++++---- 1 file changed, 129 insertions(+), 28 deletions(-) diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py index b7527395..615d2c89 100644 --- a/backend/app/services/psa/connectwise/provider.py +++ b/backend/app/services/psa/connectwise/provider.py @@ -2,6 +2,7 @@ from __future__ import annotations from app.services.psa.base import PSAProvider +from app.services.psa.cache import psa_cache from app.services.psa.types import ( ConnectionTestResult, PSATicket, @@ -36,7 +37,7 @@ class ConnectWiseProvider(PSAProvider): server_version=None, ) - # ── Stubs for Phase A slices 2-5 ───────────────────────────────── + # ── Tickets ─────────────────────────────────────────────────────── async def get_ticket(self, ticket_id: str) -> PSATicket: """Fetch a single ticket by ID from ConnectWise.""" @@ -44,22 +45,117 @@ class ConnectWiseProvider(PSAProvider): f"/service/tickets/{ticket_id}", params={"fields": "id,summary,company,board,status,priority,closedFlag"}, ) - return PSATicket( - id=str(data["id"]), - summary=data.get("summary", ""), - company_name=data.get("company", {}).get("name"), - company_id=str(data["company"]["id"]) if data.get("company") else None, - board_name=data.get("board", {}).get("name"), - board_id=data.get("board", {}).get("id"), - status_name=data.get("status", {}).get("name"), - status_id=data.get("status", {}).get("id"), - priority_name=data.get("priority", {}).get("name"), - priority_id=data.get("priority", {}).get("id"), - closed=data.get("closedFlag", False), - ) + return self._map_ticket(data) async def search_tickets(self, query: str, **filters) -> list[PSATicket]: - raise NotImplementedError("Implemented in Slice 3") + """Search CW tickets by summary. Supports board_id and status_id filters.""" + params: dict = { + "fields": "id,summary,company,board,status,priority,closedFlag", + "orderBy": "id desc", + "pageSize": 25, + } + + # Build CW condition query + conditions: list[str] = [] + if query: + conditions.append(f"summary contains '{query}'") + if filters.get("board_id"): + conditions.append(f"board/id = {filters['board_id']}") + if filters.get("status_id"): + conditions.append(f"status/id = {filters['status_id']}") + if not filters.get("include_closed", False): + conditions.append("closedFlag = false") + + if conditions: + params["conditions"] = " and ".join(conditions) + + data = await self.client.get("/service/tickets", params=params) + + return [ + self._map_ticket(t) + for t in (data if isinstance(data, list) else []) + ] + + async def get_ticket_configurations( + self, ticket_id: str + ) -> list[PSAConfiguration]: + """Get configurations (assets) attached to a ticket.""" + data = await self.client.get( + f"/service/tickets/{ticket_id}/configurations", + params={"fields": "id,deviceIdentifier,type,company"}, + ) + return [ + PSAConfiguration( + id=str(c["id"]), + name=c.get("deviceIdentifier", ""), + type=c.get("type", {}).get("name") if c.get("type") else None, + company_name=c.get("company", {}).get("name") if c.get("company") else None, + ) + for c in (data if isinstance(data, list) else []) + ] + + # ── Board statuses (cached) ─────────────────────────────────────── + + async def get_ticket_statuses(self, board_id: int) -> list[PSAStatus]: + """Get available statuses for a CW service board (cached 1 hour).""" + cache_key = f"board_statuses:{board_id}" + cached = psa_cache.get(cache_key) + if cached is not None: + return cached + + data = await self.client.get( + f"/service/boards/{board_id}/statuses", + params={"fields": "id,name,closedStatus", "pageSize": 100}, + ) + result = [ + PSAStatus( + id=s["id"], + name=s["name"], + is_closed=s.get("closedStatus", False), + ) + for s in (data if isinstance(data, list) else []) + ] + psa_cache.set(cache_key, result, ttl_seconds=3600) + return result + + # ── Companies ───────────────────────────────────────────────────── + + async def list_companies(self, **filters) -> list[PSACompany]: + """List companies from CW, optionally filtered by status.""" + params: dict = { + "fields": "id,name,status", + "pageSize": 100, + "orderBy": "name asc", + } + conditions: list[str] = [] + if filters.get("status"): + conditions.append(f"status/name = '{filters['status']}'") + if conditions: + params["conditions"] = " and ".join(conditions) + + data = await self.client.get("/company/companies", params=params) + return [ + PSACompany( + id=str(c["id"]), + name=c.get("name", ""), + status=c.get("status", {}).get("name") if c.get("status") else None, + ) + for c in (data if isinstance(data, list) else []) + ] + + async def get_company(self, company_id: str) -> PSACompany: + """Fetch a single company by ID.""" + data = await self.client.get( + f"/company/companies/{company_id}", + params={"fields": "id,name,status"}, + ) + return PSACompany( + id=str(data["id"]), + name=data.get("name", ""), + status=data.get("status", {}).get("name") if data.get("status") else None, + ) + + # ── Stubs for later slices ──────────────────────────────────────── async def post_note( self, @@ -75,19 +171,24 @@ class ConnectWiseProvider(PSAProvider): ) -> PSATicket: raise NotImplementedError("Implemented in Slice 4") - async def get_ticket_statuses(self, board_id: int) -> list[PSAStatus]: - raise NotImplementedError("Implemented in Slice 3") - - async def list_companies(self, **filters) -> list[PSACompany]: - raise NotImplementedError("Implemented in Slice 3") - - async def get_company(self, company_id: str) -> PSACompany: - raise NotImplementedError("Implemented in Slice 3") - async def list_members(self) -> list[PSAMember]: raise NotImplementedError("Implemented in Slice 5") - async def get_ticket_configurations( - self, ticket_id: str - ) -> list[PSAConfiguration]: - raise NotImplementedError("Implemented in Slice 3") + # ── Private helpers ─────────────────────────────────────────────── + + @staticmethod + def _map_ticket(data: dict) -> PSATicket: + """Map a CW ticket JSON dict to a PSATicket.""" + return PSATicket( + id=str(data["id"]), + summary=data.get("summary", ""), + company_name=data.get("company", {}).get("name"), + company_id=str(data["company"]["id"]) if data.get("company") else None, + board_name=data.get("board", {}).get("name"), + board_id=data.get("board", {}).get("id"), + status_name=data.get("status", {}).get("name"), + status_id=data.get("status", {}).get("id"), + priority_name=data.get("priority", {}).get("name"), + priority_id=data.get("priority", {}).get("id"), + closed=data.get("closedFlag", False), + ) -- 2.49.1 From c1da853d01d02b202bde7a66e0755e6671c8bc38 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 23:07:03 -0400 Subject: [PATCH 15/29] 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 -- 2.49.1 From 88495b10f0c94af3021a809a3a8cca7830d8036f Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 23:07:08 -0400 Subject: [PATCH 16/29] feat(psa): add in-memory TTL cache for board statuses Add PSACache class with get/set/invalidate/clear operations and TTL expiry. Board statuses are cached for 1 hour in the provider. Cache is cleared when a PSA connection is re-tested. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/services/psa/cache.py | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 backend/app/services/psa/cache.py diff --git a/backend/app/services/psa/cache.py b/backend/app/services/psa/cache.py new file mode 100644 index 00000000..148889c2 --- /dev/null +++ b/backend/app/services/psa/cache.py @@ -0,0 +1,38 @@ +"""Simple in-memory TTL cache for PSA API responses.""" +from __future__ import annotations + +import time +from typing import Any + + +class PSACache: + """Account-scoped in-memory cache with TTL expiry.""" + + def __init__(self) -> None: + self._store: dict[str, tuple[Any, float]] = {} + + def get(self, key: str) -> Any | None: + entry = self._store.get(key) + if entry is None: + return None + value, expires_at = entry + if time.time() > expires_at: + del self._store[key] + return None + return value + + def set(self, key: str, value: Any, ttl_seconds: int) -> None: + self._store[key] = (value, time.time() + ttl_seconds) + + def invalidate(self, prefix: str) -> None: + """Remove all entries matching a key prefix.""" + keys_to_remove = [k for k in self._store if k.startswith(prefix)] + for k in keys_to_remove: + del self._store[k] + + def clear(self) -> None: + self._store.clear() + + +# Global singleton — acceptable at current scale (see design doc section 6) +psa_cache = PSACache() -- 2.49.1 From dcf8bce2bf3e26e78b739fd63e8eb5b68ab349da Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 23:14:56 -0400 Subject: [PATCH 17/29] feat(psa): upgrade ticket picker with search and fix lookup/link separation Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/api/integrations.ts | 8 +- .../components/session/TicketPickerModal.tsx | 415 ++++++++++++++---- frontend/src/types/integrations.ts | 16 + 3 files changed, 356 insertions(+), 83 deletions(-) diff --git a/frontend/src/api/integrations.ts b/frontend/src/api/integrations.ts index 4e17029b..b77d511e 100644 --- a/frontend/src/api/integrations.ts +++ b/frontend/src/api/integrations.ts @@ -1,6 +1,6 @@ import { apiClient } from './client' import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types' -import type { TicketLinkResponse } from '@/types/integrations' +import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem } from '@/types/integrations' export const integrationsApi = { getConnection: () => @@ -13,6 +13,12 @@ export const integrationsApi = { apiClient.delete(`/integrations/psa/connections/${id}`), testConnection: (id: string) => apiClient.post(`/integrations/psa/connections/${id}/test`).then(r => r.data), + searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) => + apiClient.get('/integrations/psa/tickets/search', { params }).then(r => r.data), + getTicket: (id: string) => + apiClient.get(`/integrations/psa/tickets/${id}`).then(r => r.data), + getTicketStatuses: (ticketId: string) => + apiClient.get(`/integrations/psa/tickets/${ticketId}/statuses`).then(r => r.data), } export const sessionPsaApi = { diff --git a/frontend/src/components/session/TicketPickerModal.tsx b/frontend/src/components/session/TicketPickerModal.tsx index fc9f9c8e..5f01f0cf 100644 --- a/frontend/src/components/session/TicketPickerModal.tsx +++ b/frontend/src/components/session/TicketPickerModal.tsx @@ -1,11 +1,13 @@ -import { useState } from 'react' -import { Ticket, Search, AlertCircle, CheckCircle2 } from 'lucide-react' +import { useState, useEffect, useRef, useCallback } from 'react' +import { Ticket, Search, AlertCircle, CheckCircle2, Hash, Loader2 } from 'lucide-react' import { Modal } from '@/components/common/Modal' import { Input } from '@/components/ui/Input' import { Button } from '@/components/ui/Button' import { cn } from '@/lib/utils' -import { sessionPsaApi } from '@/api/integrations' -import type { PSATicketInfo } from '@/types/integrations' +import { integrationsApi, sessionPsaApi } from '@/api/integrations' +import type { PSATicketInfo, PSATicketSearchResult } from '@/types/integrations' + +type Mode = 'search' | 'manual' interface Props { open: boolean @@ -15,103 +17,341 @@ interface Props { } export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props) { - const [ticketId, setTicketId] = useState('') + const [mode, setMode] = useState('search') + + // Search mode state + const [searchQuery, setSearchQuery] = useState('') + const [includeClosed, setIncludeClosed] = useState(false) + const [searchResults, setSearchResults] = useState([]) + const [isSearching, setIsSearching] = useState(false) + const [hasSearched, setHasSearched] = useState(false) + + // Manual mode state + const [manualId, setManualId] = useState('') + + // Shared state + const [selectedTicket, setSelectedTicket] = useState(null) + const [selectedTicketId, setSelectedTicketId] = useState(null) const [isLooking, setIsLooking] = useState(false) const [isLinking, setIsLinking] = useState(false) - const [ticketInfo, setTicketInfo] = useState(null) const [error, setError] = useState(null) - const handleLookup = async () => { - const trimmed = ticketId.trim() - if (!trimmed) return + const debounceRef = useRef | null>(null) - setIsLooking(true) + // Debounced search + const performSearch = useCallback(async (query: string, closed: boolean) => { + if (!query.trim()) { + setSearchResults([]) + setHasSearched(false) + return + } + + setIsSearching(true) setError(null) - setTicketInfo(null) - try { - const result = await sessionPsaApi.linkTicket(sessionId, trimmed) - if (result.ticket) { - setTicketInfo(result.ticket) - } else { - setError('Ticket not found in ConnectWise') - } + const results = await integrationsApi.searchTickets({ + query: query.trim(), + include_closed: closed, + }) + setSearchResults(results) + setHasSearched(true) } catch (err: unknown) { const message = err && typeof err === 'object' && 'response' in err ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail : null - setError(message || 'Failed to look up ticket. Please check the ticket number and try again.') + setError(message || 'Failed to search tickets.') + setSearchResults([]) + setHasSearched(true) + } finally { + setIsSearching(false) + } + }, []) + + // Trigger debounced search when query or includeClosed changes + useEffect(() => { + if (mode !== 'search') return + + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + + if (!searchQuery.trim()) { + setSearchResults([]) + setHasSearched(false) + return + } + + debounceRef.current = setTimeout(() => { + performSearch(searchQuery, includeClosed) + }, 300) + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + } + }, [searchQuery, includeClosed, mode, performSearch]) + + const handleSelectSearchResult = async (result: PSATicketSearchResult) => { + setIsLooking(true) + setError(null) + try { + const ticket = await integrationsApi.getTicket(result.id) + setSelectedTicket(ticket) + setSelectedTicketId(result.id) + } catch (err: unknown) { + const message = + err && typeof err === 'object' && 'response' in err + ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail + : null + setError(message || 'Failed to load ticket details.') } finally { setIsLooking(false) } } - const handleLink = () => { - if (!ticketInfo) return - onLinked(ticketId.trim(), ticketInfo) - handleReset() + const handleManualLookup = async () => { + const trimmed = manualId.trim() + if (!trimmed) return + + setIsLooking(true) + setError(null) + setSelectedTicket(null) + setSelectedTicketId(null) + + try { + const ticket = await integrationsApi.getTicket(trimmed) + setSelectedTicket(ticket) + setSelectedTicketId(trimmed) + } catch (err: unknown) { + const message = + err && typeof err === 'object' && 'response' in err + ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail + : null + setError(message || 'Ticket not found. Please check the ticket number and try again.') + } finally { + setIsLooking(false) + } + } + + const handleLink = async () => { + if (!selectedTicket || !selectedTicketId) return + + setIsLinking(true) + setError(null) + try { + await sessionPsaApi.linkTicket(sessionId, selectedTicketId) + onLinked(selectedTicketId, selectedTicket) + handleReset() + } catch (err: unknown) { + const message = + err && typeof err === 'object' && 'response' in err + ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail + : null + setError(message || 'Failed to link ticket.') + } finally { + setIsLinking(false) + } } const handleReset = () => { - setTicketId('') - setTicketInfo(null) + setSearchQuery('') + setSearchResults([]) + setHasSearched(false) + setManualId('') + setSelectedTicket(null) + setSelectedTicketId(null) setError(null) setIsLooking(false) setIsLinking(false) + setIsSearching(false) + setIncludeClosed(false) } const handleClose = () => { handleReset() + setMode('search') onClose() } - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && ticketId.trim() && !isLooking && !ticketInfo) { - handleLookup() + const handleClearSelection = () => { + setSelectedTicket(null) + setSelectedTicketId(null) + setError(null) + } + + const handleManualKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && manualId.trim() && !isLooking && !selectedTicket) { + handleManualLookup() } } + const switchMode = (newMode: Mode) => { + setMode(newMode) + setSelectedTicket(null) + setSelectedTicketId(null) + setError(null) + } + return (
- {/* Ticket ID input */} -
- -
- { - setTicketId(e.target.value) - if (ticketInfo) { - setTicketInfo(null) - } - if (error) { - setError(null) - } - }} - onKeyDown={handleKeyDown} - disabled={isLooking || isLinking} - className="flex-1" - /> - -
+ {/* Mode tabs */} +
+ +
+ {/* Search mode */} + {mode === 'search' && !selectedTicket && ( +
+
+ setSearchQuery(e.target.value)} + disabled={isLooking} + className="w-full" + autoFocus + /> +
+ + {/* Include closed toggle */} + + + {/* Search results */} + {isSearching && ( +
+ +
+ )} + + {!isSearching && hasSearched && searchResults.length === 0 && ( +
+ No tickets found +
+ )} + + {!isSearching && searchResults.length > 0 && ( +
+ {searchResults.map((result) => ( + + ))} +
+ )} + + {isLooking && ( +
+ + Loading ticket details... +
+ )} +
+ )} + + {/* Manual mode */} + {mode === 'manual' && !selectedTicket && ( +
+ +
+ { + setManualId(e.target.value) + if (error) setError(null) + }} + onKeyDown={handleManualKeyDown} + disabled={isLooking} + className="flex-1" + autoFocus + /> + +
+
+ )} + {/* Error */} {error && (
@@ -120,49 +360,60 @@ export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props)
)} - {/* Ticket info card */} - {ticketInfo && ( + {/* Selected ticket confirmation card */} + {selectedTicket && selectedTicketId && (

- CW #{ticketId.trim()} — {ticketInfo.summary} + CW #{selectedTicketId} — {selectedTicket.summary}

- {ticketInfo.company_name && ( - {ticketInfo.company_name} + {selectedTicket.company_name && ( + {selectedTicket.company_name} )} - {ticketInfo.board_name && ( + {selectedTicket.board_name && ( <> - {ticketInfo.board_name} + {selectedTicket.board_name} )} - {ticketInfo.status_name && ( + {selectedTicket.status_name && ( <> - {ticketInfo.status_name} + {selectedTicket.status_name} )} - {ticketInfo.priority_name && ( + {selectedTicket.priority_name && ( <> - {ticketInfo.priority_name} + {selectedTicket.priority_name} )}
- +
+ + +
)} diff --git a/frontend/src/types/integrations.ts b/frontend/src/types/integrations.ts index ef5542da..5667a2c3 100644 --- a/frontend/src/types/integrations.ts +++ b/frontend/src/types/integrations.ts @@ -52,3 +52,19 @@ export interface TicketLinkResponse { psa_ticket_id: string | null ticket: PSATicketInfo | null } + +export interface PSATicketSearchResult { + id: string + summary: string + company_name: string | null + board_name: string | null + status_name: string | null + priority_name: string | null + closed: boolean +} + +export interface PSATicketStatusItem { + id: number + name: string + is_closed: boolean +} -- 2.49.1 From 7059969d05054e0079ad8fc07cfaef7d5a47eea9 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 23:28:21 -0400 Subject: [PATCH 18/29] feat(psa): add PsaPostLog model and migration Audit trail for notes posted to PSA systems. Tracks session ID, ticket ID, note type, content, status (success/failed), and any status changes made alongside the note post. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/alembic/env.py | 1 + .../alembic/versions/060_add_psa_post_log.py | 57 ++++++++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/psa_post_log.py | 58 +++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 backend/alembic/versions/060_add_psa_post_log.py create mode 100644 backend/app/models/psa_post_log.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 4256625f..3d3e8f80 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -20,6 +20,7 @@ from app.models.ai_suggestion import AISuggestion # noqa: F401 from app.models.kb_import import KBImport, KBImportNode # noqa: F401 from app.models.script_template import ScriptCategory, ScriptTemplate, ScriptGeneration # noqa: F401 from app.models.psa_connection import PsaConnection # noqa: F401 +from app.models.psa_post_log import PsaPostLog # noqa: F401 from app.core.config import settings # this is the Alembic Config object diff --git a/backend/alembic/versions/060_add_psa_post_log.py b/backend/alembic/versions/060_add_psa_post_log.py new file mode 100644 index 00000000..7001ffbd --- /dev/null +++ b/backend/alembic/versions/060_add_psa_post_log.py @@ -0,0 +1,57 @@ +"""Add psa_post_log table for PSA note posting audit trail. + +Revision ID: 060 +Revises: 059 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "060" +down_revision = "059" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "psa_post_log", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "session_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("sessions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column( + "psa_connection_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("psa_connections.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("ticket_id", sa.String(100), nullable=False), + sa.Column("note_type", sa.String(50), nullable=False), + sa.Column("content_posted", sa.Text(), nullable=False), + sa.Column("external_note_id", sa.String(100), nullable=True), + sa.Column("status", sa.String(20), nullable=False), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("status_changed_from", sa.String(100), nullable=True), + sa.Column("status_changed_to", sa.String(100), nullable=True), + sa.Column( + "posted_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id"), + nullable=False, + ), + sa.Column( + "posted_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + + +def downgrade() -> None: + op.drop_table("psa_post_log") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index afdaeb27..1ab589aa 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -37,6 +37,7 @@ from .survey_invite import SurveyInvite from .kb_import import KBImport, KBImportNode from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration from .psa_connection import PsaConnection +from .psa_post_log import PsaPostLog __all__ = [ "User", @@ -88,4 +89,5 @@ __all__ = [ "ScriptTemplate", "ScriptGeneration", "PsaConnection", + "PsaPostLog", ] diff --git a/backend/app/models/psa_post_log.py b/backend/app/models/psa_post_log.py new file mode 100644 index 00000000..54372fe0 --- /dev/null +++ b/backend/app/models/psa_post_log.py @@ -0,0 +1,58 @@ +"""Audit trail for notes posted to PSA systems.""" +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import String, DateTime, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID + +from app.core.database import Base + + +class PsaPostLog(Base): + __tablename__ = "psa_post_log" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sessions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("psa_connections.id", ondelete="SET NULL"), + nullable=True, + ) + ticket_id: Mapped[str] = mapped_column(String(100), nullable=False) + note_type: Mapped[str] = mapped_column(String(50), nullable=False) + content_posted: Mapped[str] = mapped_column(Text, nullable=False) + external_note_id: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False + ) # 'success' or 'failed' + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + status_changed_from: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True + ) + status_changed_to: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True + ) + posted_by: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id"), nullable=False + ) + posted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + # Relationships + session = relationship("Session", foreign_keys=[session_id]) + psa_connection = relationship("PsaConnection", foreign_keys=[psa_connection_id]) + user = relationship("User", foreign_keys=[posted_by]) -- 2.49.1 From 74ee5009c26224a39d190d3fc4c570ceb40184fc Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 23:28:46 -0400 Subject: [PATCH 19/29] feat(psa): implement post_note and update_ticket_status in CW provider Replaces NotImplementedError stubs with working implementations: - post_note maps note_type to CW flag fields (internalAnalysisFlag, resolutionFlag, detailDescriptionFlag) with appropriate visibility and notification settings - update_ticket_status uses CW JSON Patch format to update status Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/services/psa/connectwise/provider.py | 65 ++++++++++++++++++- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py index 615d2c89..cce2bacf 100644 --- a/backend/app/services/psa/connectwise/provider.py +++ b/backend/app/services/psa/connectwise/provider.py @@ -155,7 +155,7 @@ class ConnectWiseProvider(PSAProvider): status=data.get("status", {}).get("name") if data.get("status") else None, ) - # ── Stubs for later slices ──────────────────────────────────────── + # ── Notes & status updates ─────────────────────────────────────── async def post_note( self, @@ -164,12 +164,71 @@ class ConnectWiseProvider(PSAProvider): note_type: str, member_id: str | None = None, ) -> PSANote: - raise NotImplementedError("Implemented in Slice 4") + """Post a note to a CW ticket. + + Maps ResolutionFlow note types to CW flag fields: + - internal_analysis → internalAnalysisFlag (internal only) + - resolution → resolutionFlag (internal, triggers notifications) + - description → detailDescriptionFlag (external, triggers notifications) + """ + from app.services.psa.types import NoteType + + flags = { + NoteType.INTERNAL_ANALYSIS: { + "internalAnalysisFlag": True, + "resolutionFlag": False, + "detailDescriptionFlag": False, + "internalFlag": True, + "processNotifications": False, + }, + NoteType.RESOLUTION: { + "internalAnalysisFlag": False, + "resolutionFlag": True, + "detailDescriptionFlag": False, + "internalFlag": True, + "processNotifications": True, + }, + NoteType.DESCRIPTION: { + "internalAnalysisFlag": False, + "resolutionFlag": False, + "detailDescriptionFlag": True, + "internalFlag": False, + "processNotifications": True, + }, + } + + note_flags = flags.get(note_type, flags[NoteType.INTERNAL_ANALYSIS]) + + body: dict = { + "text": text, + **note_flags, + } + + if member_id: + body["member"] = {"id": int(member_id)} + + data = await self.client.post( + f"/service/tickets/{ticket_id}/notes", json_body=body + ) + + return PSANote( + id=str(data.get("id", "")), + text=data.get("text", ""), + note_type=note_type, + created_at=data.get("dateCreated"), + ) async def update_ticket_status( self, ticket_id: str, status_id: int ) -> PSATicket: - raise NotImplementedError("Implemented in Slice 4") + """Update a CW ticket's status using JSON Patch format.""" + patch_body = [ + {"op": "replace", "path": "status", "value": {"id": status_id}} + ] + data = await self.client.patch( + f"/service/tickets/{ticket_id}", json_body=patch_body + ) + return self._map_ticket(data) async def list_members(self) -> list[PSAMember]: raise NotImplementedError("Implemented in Slice 5") -- 2.49.1 From 5ade3be44eee7761f496cefaf08ab97efd9e0139 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 23:31:11 -0400 Subject: [PATCH 20/29] feat(psa): add PSA post preview, post-to-ticket, and post history endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new session-scoped endpoints for posting session documentation to linked PSA tickets: - GET /sessions/{id}/psa-post/preview — generates PSA-formatted content, fetches ticket details and available statuses, counts previous posts - POST /sessions/{id}/psa-post — posts note to CW ticket with optional status update, logs all actions in psa_post_log audit trail - GET /sessions/{id}/psa-posts — returns post history for the session New schemas: PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/sessions.py | 266 ++++++++++++++++++++++++++ backend/app/schemas/__init__.py | 2 + backend/app/schemas/psa_connection.py | 42 ++++ 3 files changed, 310 insertions(+) diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index c560dba2..610fdffd 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -27,6 +27,7 @@ from app.schemas.session import ( TicketLinkResponse, PSATicketResponse, ) +from app.schemas.psa_connection import PsaPostRequest from app.api.deps import get_current_active_user, require_engineer_or_admin from app.core.permissions import can_access_tree from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export @@ -839,3 +840,268 @@ async def link_ticket( priority_name=ticket.priority_name, ), ) + + +# ── PSA Post to Ticket ──────────────────────────────────────────── + + +@router.get("/{session_id}/psa-post/preview") +async def psa_post_preview( + session_id: UUID, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Preview the content that will be posted to the linked PSA ticket. + + Generates session documentation in PSA format, fetches current ticket + details and available statuses, and counts previous posts. + """ + from app.models.psa_post_log import PsaPostLog + from app.services.psa.registry import get_provider_for_account + from app.services.psa.exceptions import PSAError + from app.schemas.psa_connection import ( + PsaPreviewResponse, + PSATicketSearchResult, + PSATicketStatusItem, + ) + from sqlalchemy import func as sa_func + + # Load session + result = await db.execute(select(Session).where(Session.id == session_id)) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + if session.user_id != current_user.id and session.assigned_to_id != current_user.id: + if not current_user.is_super_admin: + raise HTTPException(status_code=403, detail="You don't have access to this session") + + if not session.psa_ticket_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Session has no linked PSA ticket. Link a ticket first.", + ) + + if not current_user.account_id: + raise HTTPException(status_code=400, detail="No account associated with your user") + + # Generate PSA export content + export_options = SessionExport( + format="psa", + include_timestamps=True, + include_tree_info=True, + include_outcome_notes=True, + include_next_steps=True, + include_summary=True, + ) + content = generate_psa_export(session, export_options) + + # Resolve session variables in content + session_vars = getattr(session, "session_variables", None) or {} + if session_vars: + from app.services.variable_service import resolve_variables + content = resolve_variables(content, session_vars) + + # Fetch ticket details and statuses from CW + try: + provider = await get_provider_for_account(current_user.account_id, db) + ticket = await provider.get_ticket(session.psa_ticket_id) + available_statuses: list[PSATicketStatusItem] = [] + if ticket.board_id: + statuses = await provider.get_ticket_statuses(ticket.board_id) + available_statuses = [ + PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed) + for s in statuses + ] + except PSAError as e: + raise HTTPException(status_code=502, detail=f"PSA error: {e}") + + # Count previous posts + count_result = await db.execute( + select(sa_func.count(PsaPostLog.id)).where( + PsaPostLog.session_id == session_id + ) + ) + previous_posts = count_result.scalar_one() + + return PsaPreviewResponse( + content=content, + ticket=PSATicketSearchResult( + id=ticket.id, + summary=ticket.summary, + company_name=ticket.company_name, + board_name=ticket.board_name, + status_name=ticket.status_name, + priority_name=ticket.priority_name, + closed=ticket.closed, + ), + available_statuses=available_statuses, + character_count=len(content), + previous_posts=previous_posts, + ) + + +@router.post("/{session_id}/psa-post") +async def psa_post_to_ticket( + session_id: UUID, + data: PsaPostRequest, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Post session documentation as a note to the linked PSA ticket. + + Optionally updates the ticket status if update_status_id is provided. + All actions are logged in psa_post_log for audit trail. + """ + from app.models.psa_connection import PsaConnection + from app.models.psa_post_log import PsaPostLog + from app.services.psa.registry import get_provider_for_account + from app.services.psa.exceptions import PSAError + from app.schemas.psa_connection import PsaPostResponse + + # Load session + result = await db.execute(select(Session).where(Session.id == session_id)) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + if session.user_id != current_user.id and session.assigned_to_id != current_user.id: + if not current_user.is_super_admin: + raise HTTPException(status_code=403, detail="You don't have access to this session") + + if not session.psa_ticket_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Session has no linked PSA ticket. Link a ticket first.", + ) + + if not current_user.account_id: + raise HTTPException(status_code=400, detail="No account associated with your user") + + # Get PSA connection ID for audit + conn_result = await db.execute( + select(PsaConnection).where( + PsaConnection.account_id == current_user.account_id, + PsaConnection.is_active.is_(True), + ) + ) + psa_connection = conn_result.scalar_one_or_none() + + # Post note + try: + provider = await get_provider_for_account(current_user.account_id, db) + note_result = await provider.post_note( + ticket_id=session.psa_ticket_id, + text=data.content, + note_type=data.note_type, + ) + note_status = "success" + external_note_id = note_result.id + error_message = None + except PSAError as e: + note_status = "failed" + external_note_id = None + error_message = str(e) + + # Optionally update ticket status + status_changed_from = None + status_changed_to = None + if data.update_status_id and note_status == "success": + try: + # Get current status before update + current_ticket = await provider.get_ticket(session.psa_ticket_id) + status_changed_from = current_ticket.status_name + + if current_ticket.status_id != data.update_status_id: + updated_ticket = await provider.update_ticket_status( + session.psa_ticket_id, data.update_status_id + ) + status_changed_to = updated_ticket.status_name + except PSAError as e: + # Log the status update failure but don't fail the whole request + # since the note was already posted successfully + if error_message: + error_message += f"; Status update failed: {e}" + else: + error_message = f"Note posted successfully but status update failed: {e}" + + # Log to audit trail + log_entry = PsaPostLog( + session_id=session.id, + psa_connection_id=psa_connection.id if psa_connection else None, + ticket_id=session.psa_ticket_id, + note_type=data.note_type, + content_posted=data.content, + external_note_id=external_note_id, + status=note_status, + error_message=error_message, + status_changed_from=status_changed_from, + status_changed_to=status_changed_to, + posted_by=current_user.id, + ) + db.add(log_entry) + await db.commit() + await db.refresh(log_entry) + + if note_status == "failed": + raise HTTPException( + status_code=502, + detail=error_message or "Failed to post note to PSA", + ) + + return PsaPostResponse( + id=str(log_entry.id), + session_id=str(session.id), + ticket_id=session.psa_ticket_id, + note_type=data.note_type, + status=note_status, + external_note_id=external_note_id, + error_message=error_message, + status_changed_from=status_changed_from, + status_changed_to=status_changed_to, + posted_at=log_entry.posted_at.isoformat(), + ) + + +@router.get("/{session_id}/psa-posts") +async def list_psa_posts( + session_id: UUID, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """List all PSA post history for a session, ordered by most recent first.""" + from app.models.psa_post_log import PsaPostLog + from app.schemas.psa_connection import PsaPostLogResponse + + # Verify session access + result = await db.execute(select(Session).where(Session.id == session_id)) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + if session.user_id != current_user.id and session.assigned_to_id != current_user.id: + if not current_user.is_super_admin: + raise HTTPException(status_code=403, detail="You don't have access to this session") + + # Query post log + log_result = await db.execute( + select(PsaPostLog) + .where(PsaPostLog.session_id == session_id) + .order_by(PsaPostLog.posted_at.desc()) + ) + logs = log_result.scalars().all() + + return [ + PsaPostLogResponse( + id=str(log.id), + ticket_id=log.ticket_id, + note_type=log.note_type, + status=log.status, + error_message=log.error_message, + status_changed_from=log.status_changed_from, + status_changed_to=log.status_changed_to, + posted_at=log.posted_at.isoformat(), + content_preview=log.content_posted[:200], + ) + for log in logs + ] diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index ba5ea284..61c128b5 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -18,6 +18,7 @@ from .script_template import ( from .psa_connection import ( PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionResponse, PsaConnectionTestResponse, PSATicketSearchResult, PSATicketStatusItem, + PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse, ) __all__ = [ @@ -46,4 +47,5 @@ __all__ = [ # PSA Connection "PsaConnectionCreate", "PsaConnectionUpdate", "PsaConnectionResponse", "PsaConnectionTestResponse", "PSATicketSearchResult", "PSATicketStatusItem", + "PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse", ] diff --git a/backend/app/schemas/psa_connection.py b/backend/app/schemas/psa_connection.py index 10ccf172..97081aa1 100644 --- a/backend/app/schemas/psa_connection.py +++ b/backend/app/schemas/psa_connection.py @@ -64,3 +64,45 @@ class PSATicketStatusItem(BaseModel): id: int name: str is_closed: bool = False + + +# ── PSA post (note posting) schemas ────────────────────────────── + + +class PsaPostRequest(BaseModel): + note_type: str = Field(pattern="^(internal_analysis|resolution|description)$") + content: str = Field(min_length=1) + update_status_id: int | None = None + + +class PsaPostResponse(BaseModel): + id: str + session_id: str + ticket_id: str + note_type: str + status: str + external_note_id: str | None = None + error_message: str | None = None + status_changed_from: str | None = None + status_changed_to: str | None = None + posted_at: str + + +class PsaPreviewResponse(BaseModel): + content: str + ticket: PSATicketSearchResult + available_statuses: list[PSATicketStatusItem] + character_count: int + previous_posts: int + + +class PsaPostLogResponse(BaseModel): + id: str + ticket_id: str + note_type: str + status: str + error_message: str | None = None + status_changed_from: str | None = None + status_changed_to: str | None = None + posted_at: str + content_preview: str # first 200 chars -- 2.49.1 From 74875d74e1651cafc68564365fd8c663b0908bcb Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 23:39:49 -0400 Subject: [PATCH 21/29] feat(psa): add Update Ticket modal with note posting and status update Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/api/integrations.ts | 8 +- .../session/TicketLinkIndicator.tsx | 37 ++- .../components/session/UpdateTicketModal.tsx | 313 ++++++++++++++++++ .../src/pages/ProceduralNavigationPage.tsx | 10 + frontend/src/pages/TreeNavigationPage.tsx | 10 + frontend/src/types/integrations.ts | 33 ++ 6 files changed, 400 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/session/UpdateTicketModal.tsx diff --git a/frontend/src/api/integrations.ts b/frontend/src/api/integrations.ts index b77d511e..49958403 100644 --- a/frontend/src/api/integrations.ts +++ b/frontend/src/api/integrations.ts @@ -1,6 +1,6 @@ import { apiClient } from './client' import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types' -import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem } from '@/types/integrations' +import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry } from '@/types/integrations' export const integrationsApi = { getConnection: () => @@ -24,4 +24,10 @@ export const integrationsApi = { export const sessionPsaApi = { linkTicket: (sessionId: string, psaTicketId: string | null) => apiClient.patch(`/sessions/${sessionId}/ticket-link`, { psa_ticket_id: psaTicketId }).then(r => r.data), + getPostPreview: (sessionId: string) => + apiClient.get(`/sessions/${sessionId}/psa-post/preview`).then(r => r.data), + postToTicket: (sessionId: string, data: { note_type: string; content: string; update_status_id?: number }) => + apiClient.post(`/sessions/${sessionId}/psa-post`, data).then(r => r.data), + getPostHistory: (sessionId: string) => + apiClient.get(`/sessions/${sessionId}/psa-posts`).then(r => r.data), } diff --git a/frontend/src/components/session/TicketLinkIndicator.tsx b/frontend/src/components/session/TicketLinkIndicator.tsx index 862a587e..6318830e 100644 --- a/frontend/src/components/session/TicketLinkIndicator.tsx +++ b/frontend/src/components/session/TicketLinkIndicator.tsx @@ -1,4 +1,4 @@ -import { Ticket, Unlink, Link2 } from 'lucide-react' +import { Ticket, Unlink, Link2, Send } from 'lucide-react' import { cn } from '@/lib/utils' import type { Session } from '@/types' import type { PSATicketInfo } from '@/types/integrations' @@ -8,10 +8,11 @@ interface Props { hasConnection: boolean onLinkClick: () => void onUnlink: () => void + onUpdateClick?: () => void ticketInfo?: PSATicketInfo | null } -export function TicketLinkIndicator({ session, hasConnection, onLinkClick, onUnlink, ticketInfo }: Props) { +export function TicketLinkIndicator({ session, hasConnection, onLinkClick, onUnlink, onUpdateClick, ticketInfo }: Props) { // No connection — show nothing if (!hasConnection) return null @@ -61,14 +62,30 @@ export function TicketLinkIndicator({ session, hasConnection, onLinkClick, onUnl
)}
- +
+ {onUpdateClick && ( + + )} + +
) } diff --git a/frontend/src/components/session/UpdateTicketModal.tsx b/frontend/src/components/session/UpdateTicketModal.tsx new file mode 100644 index 00000000..d198d2a2 --- /dev/null +++ b/frontend/src/components/session/UpdateTicketModal.tsx @@ -0,0 +1,313 @@ +import { useState, useEffect } from 'react' +import { Loader2, Copy, AlertTriangle, AlertCircle, RefreshCw } from 'lucide-react' +import { Modal } from '@/components/common/Modal' +import { Textarea } from '@/components/ui/Textarea' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' +import { sessionPsaApi } from '@/api/integrations' +import type { PsaPreviewResponse } from '@/types/integrations' + +interface Props { + open: boolean + onClose: () => void + sessionId: string + onPosted?: () => void +} + +type NoteType = 'internal_analysis' | 'resolution' | 'description' + +const NOTE_TYPE_OPTIONS: { value: NoteType; label: string; description: string; warning?: string }[] = [ + { value: 'internal_analysis', label: 'Internal Analysis', description: 'Internal only, no notifications' }, + { value: 'resolution', label: 'Resolution', description: 'Internal only, triggers notifications' }, + { value: 'description', label: 'Description', description: 'Visible to the customer', warning: 'This note will be visible to the customer' }, +] + +const CHAR_WARNING_THRESHOLD = 15000 + +export function UpdateTicketModal({ open, onClose, sessionId, onPosted }: Props) { + const [preview, setPreview] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [loadError, setLoadError] = useState(null) + + const [content, setContent] = useState('') + const [noteType, setNoteType] = useState('internal_analysis') + const [selectedStatusId, setSelectedStatusId] = useState(null) + + const [isPosting, setIsPosting] = useState(false) + const [postError, setPostError] = useState(null) + + useEffect(() => { + if (open) { + loadPreview() + } else { + // Reset state when closed + setPreview(null) + setContent('') + setNoteType('internal_analysis') + setSelectedStatusId(null) + setPostError(null) + setLoadError(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, sessionId]) + + const loadPreview = async () => { + setIsLoading(true) + setLoadError(null) + try { + const data = await sessionPsaApi.getPostPreview(sessionId) + setPreview(data) + setContent(data.content) + // Find current status ID from available_statuses matching ticket status_name + const currentStatus = data.available_statuses.find( + (s) => s.name === data.ticket.status_name + ) + setSelectedStatusId(currentStatus?.id ?? null) + } catch (err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + setLoadError(axiosErr.response?.data?.detail || 'Failed to load preview') + console.error(err) + } finally { + setIsLoading(false) + } + } + + const handleCopyContent = async () => { + try { + await navigator.clipboard.writeText(content) + toast.success('Content copied to clipboard') + } catch { + toast.error('Failed to copy content') + } + } + + const handlePost = async () => { + setIsPosting(true) + setPostError(null) + try { + // Determine if status should be updated + const currentStatus = preview?.available_statuses.find( + (s) => s.name === preview?.ticket.status_name + ) + const statusChanged = selectedStatusId !== null && selectedStatusId !== currentStatus?.id + + await sessionPsaApi.postToTicket(sessionId, { + note_type: noteType, + content, + ...(statusChanged ? { update_status_id: selectedStatusId } : {}), + }) + + toast.success('Note posted to ticket successfully') + onPosted?.() + onClose() + } catch (err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + setPostError(axiosErr.response?.data?.detail || 'Failed to post to ticket') + console.error(err) + } finally { + setIsPosting(false) + } + } + + const currentStatusId = preview?.available_statuses.find( + (s) => s.name === preview?.ticket.status_name + )?.id + + const footer = ( +
+ {postError && ( +
+
+ + {postError} +
+ +
+ )} +
+ + +
+
+ ) + + return ( + + {isLoading && ( +
+ +
+ )} + + {loadError && ( +
+
+ + {loadError} +
+ +
+ )} + + {preview && !isLoading && ( +
+ {/* Left panel - Content editor */} +
+
+

+ Note Content +

+ +
+ +