feat: ConnectWise PSA integration (#106)

PSA abstraction layer with provider pattern, ConnectWise integration (connection management, ticket linking, note posting, status updates, member mapping), Integrations page UI, Fernet credential encryption, in-memory TTL cache, 6 DB migrations, ConnectWise API reference docs.
This commit was merged in pull request #106.
This commit is contained in:
chihlasm
2026-03-15 01:45:35 -04:00
committed by GitHub
parent 80e094215f
commit 46865882c6
60 changed files with 726716 additions and 11 deletions

View File

@@ -0,0 +1,565 @@
"""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 sqlalchemy import delete
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.psa_member_mapping import PsaMemberMapping
from app.models.user import User
from app.schemas.psa_connection import (
PsaConnectionCreate,
PsaConnectionResponse,
PsaConnectionTestResponse,
PsaConnectionUpdate,
PSATicketSearchResult,
PSATicketStatusItem,
PsaMemberMappingResponse,
PsaMemberMappingSaveRequest,
PsaMemberResponse,
AutoMatchResult,
)
from app.core.config import settings
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")
if not settings.CW_CLIENT_ID:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "ConnectWise integration is not configured on this server")
# 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=settings.CW_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,
}
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"}
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=settings.CW_CLIENT_ID or "",
)
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=settings.CW_CLIENT_ID or "",
)
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))
# ── member mapping endpoints ─────────────────────────────────────────
@router.get("/members", response_model=list[PsaMemberResponse])
async def list_members(
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List CW members (from CW API)."""
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)
members = await provider.list_members()
return [
PsaMemberResponse(id=m.id, identifier=m.identifier, name=m.name, email=m.email)
for m in members
]
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])
async def get_member_mappings(
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get all member mappings for the account."""
conn = await _get_account_connection(current_user.account_id, db)
if not conn:
return []
result = await db.execute(
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
)
mappings = result.scalars().all()
response = []
for m in mappings:
user_result = await db.execute(select(User).where(User.id == m.user_id))
user = user_result.scalar_one_or_none()
if user:
response.append(PsaMemberMappingResponse(
id=str(m.id),
user_id=str(m.user_id),
user_email=user.email,
user_name=user.name,
external_member_id=m.external_member_id,
external_member_name=m.external_member_name,
matched_by=m.matched_by,
))
return response
@router.post("/member-mappings", response_model=list[PsaMemberMappingResponse])
async def save_member_mappings(
mappings: list[PsaMemberMappingSaveRequest],
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Save/update member mappings (batch). Replaces all existing mappings."""
conn = await _get_account_connection(current_user.account_id, db)
if not conn:
raise HTTPException(status_code=400, detail="No PSA connection configured")
# Delete existing mappings
await db.execute(
delete(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
)
# Insert new mappings
for m in mappings:
mapping = PsaMemberMapping(
psa_connection_id=conn.id,
user_id=UUID(m.user_id),
external_member_id=m.external_member_id,
external_member_name=m.external_member_name,
matched_by="manual_admin",
)
db.add(mapping)
await db.commit()
# Return the saved mappings
return await get_member_mappings(current_user, db)
@router.post("/member-mappings/auto-match", response_model=AutoMatchResult)
async def auto_match_members(
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Auto-match RF users to CW members by email."""
conn = await _get_account_connection(current_user.account_id, db)
if not conn:
raise HTTPException(status_code=400, detail="No PSA connection configured")
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)
cw_members = await provider.list_members()
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
# Build email → member lookup
email_to_member: dict = {}
for m in cw_members:
if m.email:
email_to_member[m.email.lower()] = m
# Get account users
users_result = await db.execute(
select(User).where(User.account_id == current_user.account_id, User.is_active.is_(True))
)
users = users_result.scalars().all()
matched = []
unmatched_count = 0
for user in users:
cw_member = email_to_member.get(user.email.lower())
if cw_member:
# Check if mapping already exists
existing = await db.execute(
select(PsaMemberMapping).where(
PsaMemberMapping.psa_connection_id == conn.id,
PsaMemberMapping.user_id == user.id,
)
)
if not existing.scalar_one_or_none():
mapping = PsaMemberMapping(
psa_connection_id=conn.id,
user_id=user.id,
external_member_id=cw_member.id,
external_member_name=cw_member.name,
matched_by="auto_email",
)
db.add(mapping)
matched.append((mapping, user))
else:
unmatched_count += 1
await db.commit()
# Build response
matched_response = [
PsaMemberMappingResponse(
id=str(m.id),
user_id=str(m.user_id),
user_email=u.email,
user_name=u.name,
external_member_id=m.external_member_id,
external_member_name=m.external_member_name,
matched_by=m.matched_by,
)
for m, u in matched
]
return AutoMatchResult(matched=matched_response, unmatched_users=unmatched_count)
# ── internal helpers ─────────────────────────────────────────────────
async def _get_account_connection(
account_id: UUID | None, db: AsyncSession
) -> PsaConnection | None:
"""Get the PSA connection for an account."""
if not account_id:
return None
result = await db.execute(
select(PsaConnection).where(PsaConnection.account_id == account_id)
)
return result.scalar_one_or_none()
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

View File

@@ -23,8 +23,12 @@ from app.schemas.session import (
SessionComplete,
SessionVariablesUpdate,
PrepareSessionRequest,
TicketLinkRequest,
TicketLinkResponse,
PSATicketResponse,
)
from app.api.deps import get_current_active_user
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
@@ -738,3 +742,382 @@ 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,
),
)
# ── 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()
# Look up member mapping for attribution
from app.models.psa_member_mapping import PsaMemberMapping
member_id = None
if psa_connection:
mapping_result = await db.execute(
select(PsaMemberMapping).where(
PsaMemberMapping.psa_connection_id == psa_connection.id,
PsaMemberMapping.user_id == current_user.id,
)
)
mapping = mapping_result.scalar_one_or_none()
if mapping:
member_id = mapping.external_member_id
# 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,
member_id=member_id,
)
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
]

View File

@@ -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)