"""PSA writeback for FlowPilot Phase 4 — Resolve + Escalate round-trip. Three primitives: - `post_resolution_note` — post the engineer-edited resolution markdown to the PSA ticket, store `{external_id, posted_at}` on the session. - `post_escalation_package` — same pattern for the Escalate flow. - `transition_ticket_status` — patch the ticket status, then re-fetch and verify the change actually took. Failed verification raises loudly so the UI never reports silent success (per the existing ConnectWise integration principle called out in FLOWPILOT-MIGRATION.md Section 6.5 and CLAUDE.md). The target status IDs live in `account_settings.preferences` (`cw_resolved_status_id`, `cw_escalated_status_id`). When unset, the status transition is a no-op and the endpoint response says so — we do not guess a default because CW status IDs are board-specific. Local-only path: callers handle sessions without `psa_ticket_id` before calling this service. Nothing here tries to "post locally" — the service's job ends at the PSA boundary. """ from __future__ import annotations import logging from datetime import datetime, timezone from typing import Any from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.account_settings import AccountSettings from app.models.ai_session import AISession from app.services.psa.exceptions import PSAConnectionError from app.services.psa.registry import get_provider_for_connection from app.services.psa.types import NoteType logger = logging.getLogger(__name__) class PSAStatusVerificationError(RuntimeError): """Raised when a ticket status transition didn't stick on re-fetch. The `update_ticket_status` call returned OK but the subsequent `get_ticket` still shows the prior status (or some unrelated one). This is the exact failure mode CLAUDE.md flags as a ConnectWise anti-pattern: reporting success when nothing changed. """ def __init__(self, ticket_id: str, expected_status_id: int, observed_status: Any) -> None: super().__init__( f"Ticket {ticket_id} status transition to {expected_status_id} " f"did not verify — observed {observed_status!r} after re-fetch." ) self.ticket_id = ticket_id self.expected_status_id = expected_status_id self.observed_status = observed_status class PSAWritebackService: """Thin orchestration over the PSA provider for FlowPilot writebacks. Instances are per-request — the AsyncSession is the one handling the current HTTP call, and the provider is resolved lazily from the session's `psa_connection_id`. """ def __init__(self, db: AsyncSession) -> None: self.db = db # ── Public API ──────────────────────────────────────────────────────── async def post_resolution_note( self, session: AISession, markdown: str ) -> dict[str, Any]: """Post `markdown` as a resolution note on the linked CW ticket. On success, persists `resolution_note_markdown`, `_posted_at`, `_external_id` on the session and returns the same triple. Caller is responsible for committing the transaction. """ return await self._post_note( session=session, markdown=markdown, note_type=NoteType.RESOLUTION, markdown_col="resolution_note_markdown", posted_at_col="resolution_note_posted_at", external_id_col="resolution_note_external_id", kind="resolution", ) async def post_escalation_package( self, session: AISession, markdown: str ) -> dict[str, Any]: """Post `markdown` as an escalation handoff note on the CW ticket.""" return await self._post_note( session=session, markdown=markdown, # Internal-analysis visibility: the handoff is for the next engineer, # not the customer. CW fires no notifications, keeps the note internal. note_type=NoteType.INTERNAL_ANALYSIS, markdown_col="escalation_package_markdown", posted_at_col="escalation_package_posted_at", external_id_col="escalation_package_external_id", kind="escalation", ) async def transition_ticket_status( self, session: AISession, target_status_id: int, ) -> dict[str, Any]: """PATCH ticket status, then re-fetch and verify. Returns `{"success": True, "verified_status_id": , "verified_status_name": }` when the observed status matches. Raises `PSAStatusVerificationError` when the transition didn't take (most common real-world failure: CW requires certain fields before allowing a status change to Resolved — the PATCH returns 200 but the status silently stays put). """ if not session.psa_ticket_id or not session.psa_connection_id: raise ValueError("Session has no linked PSA ticket for status transition") provider = await get_provider_for_connection(session.psa_connection_id, self.db) await provider.update_ticket_status( ticket_id=session.psa_ticket_id, status_id=target_status_id, ) # Verify by re-fetch — this is the load-bearing step. verification = await provider.get_ticket(session.psa_ticket_id) observed_id = getattr(verification, "status_id", None) observed_name = getattr(verification, "status_name", None) if observed_id != target_status_id: raise PSAStatusVerificationError( ticket_id=session.psa_ticket_id, expected_status_id=target_status_id, observed_status={"id": observed_id, "name": observed_name}, ) return { "success": True, "verified_status_id": observed_id, "verified_status_name": observed_name, } async def resolved_status_id_for_account( self, account_id: UUID ) -> int | None: """Return the configured CW "Resolved" status ID for the account. None means "no transition configured" — callers should skip the transition (posting the note is still meaningful). This lives in account_settings.preferences per the Phase 1 JSONB grab-bag design. """ raw = await AccountSettings.get_setting(self.db, account_id, "cw_resolved_status_id", None) return self._coerce_status_id(raw) async def escalated_status_id_for_account( self, account_id: UUID ) -> int | None: raw = await AccountSettings.get_setting(self.db, account_id, "cw_escalated_status_id", None) return self._coerce_status_id(raw) # ── Internals ───────────────────────────────────────────────────────── async def _post_note( self, *, session: AISession, markdown: str, note_type: str, markdown_col: str, posted_at_col: str, external_id_col: str, kind: str, ) -> dict[str, Any]: if not session.psa_ticket_id or not session.psa_connection_id: raise ValueError(f"Session has no linked PSA ticket for {kind} post") markdown = (markdown or "").strip() if not markdown: raise ValueError(f"{kind} markdown is empty") try: provider = await get_provider_for_connection(session.psa_connection_id, self.db) except PSAConnectionError: # Connection could have been deleted or deactivated since session # creation — propagate as a clear error for the endpoint to surface. logger.exception( "PSA connection %s is no longer available for session %s", session.psa_connection_id, session.id, ) raise posted = await provider.post_note( ticket_id=session.psa_ticket_id, text=markdown, note_type=note_type, ) posted_at = datetime.now(timezone.utc) setattr(session, markdown_col, markdown) setattr(session, posted_at_col, posted_at) setattr(session, external_id_col, str(posted.id) if posted.id else None) return { "external_id": str(posted.id) if posted.id else None, "posted_at": posted_at, "kind": kind, } @staticmethod def _coerce_status_id(raw: Any) -> int | None: if raw is None: return None try: return int(raw) except (TypeError, ValueError): logger.warning( "Non-integer CW status ID in account_settings.preferences: %r", raw, ) return None