Files
resolutionflow/docs/superpowers/specs/2026-03-14-connectwise-psa-integration-design.md
Michael Chihlas 80e094215f docs: add ConnectWise PSA integration design spec
Comprehensive design for ConnectWise PSA integration covering
credential management, service ticket integration, session-to-ticket
notes, and callback webhooks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:31:03 -04:00

32 KiB

ConnectWise PSA Integration — Design Spec

Date: 2026-03-14 Status: Approved Phase: Phase A (Session → Ticket Notes), with architecture for Phase B (Ticket Context → Session) and Phase C (Callback-Driven Flow Suggestions)


1. Overview

Problem

MSP engineers using ResolutionFlow generate high-quality troubleshooting documentation through sessions, but manually copy-paste that documentation into ConnectWise PSA ticket notes. This is tedious, error-prone, and discourages thorough documentation.

Solution

Integrate ResolutionFlow with ConnectWise PSA so engineers can post session documentation directly to CW tickets — with a preview/edit step, note type selection, and optional ticket status update — all from within ResolutionFlow.

Scope

Phase A (this spec): Credential management, ticket linking, session documentation posting, member mapping.

Phase B (future): Pull ticket/company/device context into sessions for FlowPilot AI.

Phase C (future): CW callback webhooks to suggest relevant Flows on ticket events.

The architecture is designed to support all three phases. Phase A builds the foundation that B and C extend.

Prerequisite

Admin account_role: Before starting Slice 1, add an admin account_role between owner and engineer. Admins can manage integrations, members, categories — but not billing, account transfer, or account deletion. This ensures 5-15 engineer MSPs can delegate integration management without giving full owner access. Role hierarchy: super_admin > owner > admin > engineer > viewer.

All "owner+" auth references in this spec should be read as "admin+" once the admin role ships.

Delivery Strategy

Incremental slices — each independently shippable and testable:

  1. Foundation — PSA abstraction, credentials, connection management UI
  2. Ticket Linking — Link sessions to CW tickets (manual ID entry)
  3. Ticket Search — Search/browse CW tickets from within ResolutionFlow
  4. Update Ticket Modal — Post session docs + update ticket status
  5. Member Mapping — Map RF users to CW members

2. Architecture

2.1 PSA Abstraction Layer

All PSA integration code lives in backend/app/services/psa/. The abstraction supports ConnectWise now and Autotask in the future.

services/psa/
├── __init__.py
├── base.py              # Abstract PSAProvider interface
├── registry.py          # Factory — resolves provider by account
├── connectwise/
│   ├── __init__.py
│   ├── client.py        # HTTP client, auth, base URL resolution, retry, pagination
│   ├── tickets.py       # Ticket ops: get, search, post notes
│   ├── companies.py     # Company/contact/configuration lookups
│   ├── members.py       # Member list for mapping
│   └── provider.py      # ConnectWiseProvider(PSAProvider)

PSAProvider Abstract Interface

class PSAProvider(ABC):
    """Abstract base for PSA integrations."""

    @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: NoteType,
    ) -> 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]: ...

Return types (PSATicket, PSANote, PSACompany, etc.) are normalized Pydantic models — provider-agnostic. The ConnectWiseProvider maps CW-specific field names and shapes to these common models.

Provider Registry

async def get_provider_for_account(account_id: UUID, db: AsyncSession) -> PSAProvider:
    """Look up account's psa_connections row, decrypt credentials, instantiate provider."""

Reads provider field from psa_connections to determine which class to instantiate.

2.2 ConnectWise Client

connectwise/client.py handles all HTTP communication with the CW API:

  • Auth: Authorization: Basic {base64(companyId+publicKey:privateKey)} + clientId header on every request
  • Accept header: application/vnd.connectwise.com+json; version=2025.16 (pinned)
  • Base URL resolution: Uses /login/companyinfo/{companyId} to dynamically resolve the codebase URL per CW best practices. Cloud environments return a versioned codebase (e.g., v2025_3/) requiring an api- prefix on the hostname (e.g., api-na.myconnectwise.net). On-premise returns v4_6_release/ with no prefix needed. The client must handle both cases.
  • Pagination: Navigable pagination with page/pageSize params, max 1000 per page, while-loop pattern
  • Retry: Respect 429 Retry-After header. Max 2 retries with exponential backoff
  • Timeout: 30 seconds per request
  • Error handling: Map CW HTTP status codes to typed exceptions (PSAAuthError, PSANotFoundError, PSARateLimitError, etc.)
  • PATCH format: CW uses JSON Patch array syntax ([{"op": "replace", "path": "status", "value": {"id": 5}}]), NOT standard REST PATCH. A malformed PATCH may return a "false 200" (per CW docs) — verify the returned state matches the requested change.
  • Newline handling: CW docs state \n in JSON bodies is "Not Supported" for some fields. Must test against a real CW sandbox during Slice 4 development to confirm that note text preserves markdown line breaks. If CW collapses newlines, use \\n literal strings or markdown double-space line breaks.
  • Partial responses: Use fields query parameter on search requests (e.g., fields=id,summary,company,board,status,priority,closedFlag) to limit response payload size. CW tickets have 120+ fields.
  • Search ordering: Default to orderBy=id desc (most recently created first) for ticket search results.

2.3 Credential Encryption

CW API credentials (public key, private key, client ID) are stored encrypted in the database using Fernet symmetric encryption:

  • Encryption key derived from settings.SECRET_KEY via HKDF
  • Stored as a single encrypted JSON blob in psa_connections.credentials_encrypted
  • Decrypted only at runtime when instantiating the provider
  • Private key never returned by any API endpoint — only a masked suffix (e.g., ••••••abc1)

3. Data Model

3.1 New Tables

psa_connections

Stores one PSA connection per account (MSP company).

Column Type Constraints Notes
id UUID PK
account_id UUID FK → accounts, UNIQUE One connection per account
provider VARCHAR(50) NOT NULL 'connectwise' (later 'autotask')
display_name VARCHAR(100) NOT NULL Admin-friendly label
site_url VARCHAR(255) NOT NULL e.g., na.myconnectwise.net
company_id VARCHAR(100) NOT NULL CW company identifier
credentials_encrypted TEXT NOT NULL Fernet-encrypted JSON: {public_key, private_key, client_id}
is_active BOOLEAN NOT NULL, DEFAULT TRUE Enable/disable without deleting
last_validated_at DATETIME(tz) NULLABLE Last successful connection test
created_at DATETIME(tz) NOT NULL
updated_at DATETIME(tz) NOT NULL

psa_member_mappings

Maps ResolutionFlow users to CW members.

Column Type Constraints Notes
id UUID PK
psa_connection_id UUID FK → psa_connections
user_id UUID FK → users
external_member_id VARCHAR(100) NOT NULL CW member identifier
external_member_name VARCHAR(200) NOT NULL Cached display name
matched_by VARCHAR(50) NOT NULL 'auto_email', 'manual_admin', 'manual_self'
created_at DATETIME(tz) NOT NULL
updated_at DATETIME(tz) NOT NULL

Unique constraint: (psa_connection_id, user_id) — one mapping per user per connection.

psa_post_log

Audit trail for every note posted to a PSA.

Column Type Constraints Notes
id UUID PK
session_id UUID FK → sessions
psa_connection_id UUID FK → psa_connections
ticket_id VARCHAR(100) NOT NULL External CW ticket number
note_type VARCHAR(50) NOT NULL 'internal_analysis', 'resolution', 'description'
content_posted TEXT NOT NULL Exact content that was sent
external_note_id VARCHAR(100) NULLABLE CW's returned note ID (null on failure)
status VARCHAR(20) NOT NULL 'success', 'failed'
error_message TEXT NULLABLE Error details on failure
status_changed_from VARCHAR(100) NULLABLE Previous ticket status (if changed)
status_changed_to VARCHAR(100) NULLABLE New ticket status (if changed)
posted_by UUID FK → users
posted_at DATETIME(tz) NOT NULL

3.2 Existing Table Changes

sessions — Add ticket linking columns

Column Type Constraints Notes
psa_ticket_id VARCHAR(100) NULLABLE Linked CW ticket number
psa_connection_id UUID NULLABLE, FK → psa_connections, ON DELETE SET NULL Which PSA connection was used

3.3 FK Cascade Behavior & Data Lifecycle

When a connection is deleted (DELETE /integrations/psa/connections/{id}):

  • sessions.psa_connection_idSET NULL. The psa_ticket_id remains as a historical reference, but the system knows no active connection exists. The frontend ticket link indicator shows "Connection removed" and hides the "Update Ticket" button.
  • psa_post_log.psa_connection_idSET NULL. Post history remains for audit purposes but is read-only.
  • psa_member_mappingsCASCADE DELETE. Mappings are connection-specific and meaningless without one.

When a user leaves the account (removed by admin or leaves voluntarily):

  • psa_member_mappings for that user are deleted (their mapping is no longer relevant).
  • psa_post_log entries from that user remain (audit trail should not be deleted).
  • The Member Mapping tab filters by active account members only — no ghost entries.

When a session is re-linked to a different ticket:

  • Previous psa_post_log entries retain the old ticket_id they were posted to. The post history for a session may show posts to multiple different tickets, which is correct.

3.4 Additional Constraints

Unique constraint on psa_member_mappings: Add UNIQUE(psa_connection_id, external_member_id) to prevent the same CW member from being mapped to two ResolutionFlow users. The admin mapping UI should enforce this in the frontend as well.


4. API Endpoints

4.1 Integration Management (Account Owner / Super Admin)

Uses a new require_account_owner dependency that checks user.account_role in ('owner', 'admin') or user.is_super_admin. This is consistent with how other account-level settings are restricted.

Method Path Purpose Auth
GET /integrations/psa/connections Get account's PSA connection (redacted credentials) owner+
POST /integrations/psa/connections Create connection + validate owner+
PUT /integrations/psa/connections/{id} Update connection + re-validate owner+
DELETE /integrations/psa/connections/{id} Remove connection owner+
POST /integrations/psa/connections/{id}/test Test existing connection owner+

4.2 Member Mapping (Account Owner / Super Admin)

Method Path Purpose Auth
GET /integrations/psa/members List CW members (from CW API) owner+
GET /integrations/psa/member-mappings Get all mappings for account owner+
POST /integrations/psa/member-mappings Save/update mappings (batch) owner+
POST /integrations/psa/member-mappings/auto-match Run email auto-match owner+

4.3 Ticket Operations (Engineer+)

Method Path Purpose Auth
GET /integrations/psa/tickets/search Search CW tickets by summary, company, board engineer+
GET /integrations/psa/tickets/{id} Get ticket details (summary, company, board, status, configs) engineer+
GET /integrations/psa/tickets/{id}/statuses Get available statuses for ticket's board engineer+

4.4 Session Ticket Linking & Posting (Engineer+)

Method Path Purpose Auth
PATCH /sessions/{id}/ticket-link Link/unlink a CW ticket to session engineer+
GET /sessions/{id}/psa-post/preview Generate preview of session doc for posting engineer+
POST /sessions/{id}/psa-post Post session doc to linked ticket engineer+
GET /sessions/{id}/psa-posts Get post history for session engineer+

When linking a ticket via PATCH /sessions/{id}/ticket-link, the backend validates the ticket exists in CW by calling get_ticket() before saving. Returns the ticket summary and context on success, 404 if the ticket doesn't exist in CW.

Post Request Body

{
  "note_type": "internal_analysis",
  "content": "**Session Analysis — DNS Resolution Failure**\n...",
  "update_status_id": 5
}
  • note_type: Required. One of internal_analysis, resolution, description.
  • content: Required. The (potentially edited) session documentation text.
  • update_status_id: Optional. If provided and different from current status, PATCH the ticket status. If omitted or null, no status change.

Note Type → CW Flag Mapping

ResolutionFlow Note Type CW internalAnalysisFlag CW resolutionFlag CW detailDescriptionFlag CW internalFlag Visibility
internal_analysis true false false true Internal only
resolution false true false true Internal only
description false false true false Customer visible

UI warning: The Update Ticket modal must display a visible indicator when "Description" is selected, noting that this note type is customer-facing. A subtle warning like "This note will be visible to the customer" below the radio button.

processNotifications Field

When posting notes, the CW processNotifications field controls whether CW triggers its internal notification workflows. Default behavior:

  • internal_analysis: processNotifications: false (avoid spamming CW notification rules)
  • resolution: processNotifications: true (resolution notes typically trigger workflows)
  • description: processNotifications: true (customer-visible notes should trigger notifications)

This can be made configurable in a future iteration if needed.

Preview Generation

GET /sessions/{id}/psa-post/preview reuses the existing session export pipeline (format=psa) to generate the documentation content. The preview response includes:

{
  "content": "**Session Analysis — DNS Resolution Failure**\n...",
  "ticket": {
    "id": "48291",
    "summary": "DNS not resolving for client workstations",
    "company": "Acme Corp",
    "board": "Help Desk",
    "current_status": { "id": 1, "name": "New" }
  },
  "available_statuses": [
    { "id": 1, "name": "New" },
    { "id": 2, "name": "In Progress" },
    { "id": 3, "name": "Completed" },
    { "id": 4, "name": "Closed" }
  ],
  "character_count": 847,
  "previous_posts": 0
}

5. Frontend Design

5.1 New Pages & Components

Account Admin Shell Page

  • Route: /account/admin
  • Purpose: Container page for account administration features
  • Content: Link cards for "Integrations" and "Team Members" (Team Members links to existing functionality)
  • Auth: Account owner or super admin only (requiredRole="owner")
  • Pattern: Same as AccountSettingsPage — link cards that navigate to sub-pages

Integrations Page (Tabbed)

  • Route: /account/admin/integrations
  • Tabs: Connection, Member Mapping, Post History
  • Connection tab: Displays connection status, redacted credentials, site URL, company ID, last validated timestamp. Edit/Test/Disconnect actions. When no connection exists, shows a setup form.
  • Member Mapping tab: Table of RF users with their mapped CW member (or "Unmapped"). "Auto-Match" button runs email matching. Dropdowns to manually assign CW members.
  • Post History tab: Filterable log of all posts made by the account. Shows session name, ticket #, note type, status, timestamp, posted by.

Ticket Picker Modal

  • Trigger: Button/link in session header area, or on session start
  • Layout: Modal with search input, direct ticket ID entry with "Look Up" button, and a results list showing recent/matching tickets
  • Each result shows: Ticket #, summary, company name, board, priority (color-coded), status
  • Actions: Select a ticket to link, or Skip to proceed without linking
  • Validation: On select, backend validates ticket exists and returns context

Update Ticket Modal

  • Trigger: "Update Ticket" button in session UI (visible when a ticket is linked)
  • Layout: Split panel — content preview/editor on left, controls on right
  • Left panel: Editable textarea pre-filled from session export (PSA format). Markdown supported. Character count. Small copy button (📋 Copy) in top-right corner.
  • Right panel:
    • Note Type — Radio buttons: Internal Analysis (default), Resolution, Description
    • Ticket Status — Dropdown populated from CW board statuses, defaults to current status. Only patches if changed.
    • Update Ticket — Primary action button (cyan gradient)
  • Character count warning: Show a warning at 15,000+ characters — CW has undocumented note text limits (typically 20-50K chars depending on deployment). Validate against sandbox during development.
  • Error state: Bottom bar with error message, retry button. Copy fallback always available.
  • Success state: Toast notification with "View in ConnectWise" link (if we can construct the CW web URL)

When a session has a linked ticket, show a compact indicator in the session header using the Lucide Ticket icon (wrapped in <span>):

[Ticket icon] CW #48291 — DNS not resolving for client workstations
              Acme Corp • Help Desk • New
              [Update Ticket] [Unlink]

If no ticket is linked and the account has a CW connection, show a subtle "Link Ticket" button.

5.2 Frontend API Client

New module: frontend/src/api/integrations.ts

export const integrationsApi = {
  // Connection management
  getConnection: () => apiClient.get('/integrations/psa/connections'),
  createConnection: (data: CreateConnectionPayload) => apiClient.post('/integrations/psa/connections', data),
  updateConnection: (id: string, data: UpdateConnectionPayload) => apiClient.put(`/integrations/psa/connections/${id}`, data),
  deleteConnection: (id: string) => apiClient.delete(`/integrations/psa/connections/${id}`),
  testConnection: (id: string) => apiClient.post(`/integrations/psa/connections/${id}/test`),

  // Members & mapping
  listMembers: () => apiClient.get('/integrations/psa/members'),
  getMemberMappings: () => apiClient.get('/integrations/psa/member-mappings'),
  saveMemberMappings: (mappings: MemberMappingPayload[]) => apiClient.post('/integrations/psa/member-mappings', mappings),
  autoMatchMembers: () => apiClient.post('/integrations/psa/member-mappings/auto-match'),

  // Tickets
  searchTickets: (params: TicketSearchParams) => apiClient.get('/integrations/psa/tickets/search', { params }),
  getTicket: (id: string) => apiClient.get(`/integrations/psa/tickets/${id}`),
  getTicketStatuses: (id: string) => apiClient.get(`/integrations/psa/tickets/${id}/statuses`),
}

export const sessionPsaApi = {
  linkTicket: (sessionId: string, data: TicketLinkPayload) => apiClient.patch(`/sessions/${sessionId}/ticket-link`, data),
  getPostPreview: (sessionId: string) => apiClient.get(`/sessions/${sessionId}/psa-post/preview`),
  postToTicket: (sessionId: string, data: PostToTicketPayload) => apiClient.post(`/sessions/${sessionId}/psa-post`, data),
  getPostHistory: (sessionId: string) => apiClient.get(`/sessions/${sessionId}/psa-posts`),
}

5.3 State Management

No new Zustand store needed. The integration settings pages use local React state (consistent with AssistantChatPage pattern — lesson #41). The ticket link and post modals also use local state since they're self-contained interactions.

The session's psa_ticket_id is part of the session data already managed by existing session state.

Note: The SessionResponse Pydantic schema must be updated to include psa_ticket_id (and optionally psa_connection_id) so the frontend can render the ticket link indicator.

5.4 Empty & Disabled States

Component No Connection Connection Exists, No Data
Session header No "Link Ticket" button shown "Link Ticket" button visible
Ticket Picker search N/A (modal won't open) "No tickets found" message with search tips
Member Mapping tab "Set up a connection first" message "No CW members found" or "All users unmapped"
Post History tab "No posts yet" "No posts yet — link a ticket and post session docs"
Update Ticket modal N/A (button hidden) N/A (only opens when ticket is linked)
Session with deleted connection "Connection removed" indicator, "Update Ticket" hidden, ticket ID shown as historical reference N/A
Preview endpoint Returns 422 if no ticket linked Normal response

6. CW API Caching Strategy

Board statuses, ticket types, and company lists change infrequently. Cache these at the account level:

Data Cache Duration Invalidation
Board statuses 1 hour On connection re-test
Board list 1 hour On connection re-test
Company list 15 minutes Manual refresh
Member list 15 minutes On auto-match trigger

Cache implementation: In-memory dict keyed by (account_id, resource_type, resource_id) with TTL expiry. Simple and sufficient for current scale.

Known limitation: In-memory cache is not shared across Uvicorn workers. In production with multiple workers, each worker maintains its own cache, leading to redundant CW API calls. Acceptable at current scale — can move to Redis if needed later.


7. Error Handling

CW API Errors

CW Status Behavior
401 PSAAuthError — "Invalid credentials. Check your API keys."
403 PSAPermissionError — "Insufficient permissions. Check the API member's security role."
404 PSANotFoundError — "Ticket not found."
429 PSARateLimitError — Respect Retry-After header, retry once
500 PSAServerError — "ConnectWise is experiencing issues. Try again."
Timeout PSATimeoutError — "Connection timed out."

Post Failure UX

  1. Error message displayed inline in the Update Ticket modal bottom bar
  2. Retry button to attempt the same post again
  3. Copy button always available as a manual fallback
  4. Failed posts logged in psa_post_log with status='failed' and error_message
  5. Session documentation is never lost — it exists in ResolutionFlow regardless of CW post success

Connection Test Failures

On credential save, the backend immediately tests the connection. If it fails:

  • Return the specific error (auth, network, permissions)
  • Don't save credentials that can't connect
  • Show the error in the setup form

8. Security

  • Credential storage: Fernet encryption using key derived from settings.SECRET_KEY. Private key never returned by API.
  • CW API permissions: Request minimal permissions — use MY access level, not ALL, per CW best practices.
  • RBAC: Connection management restricted to account owner and super_admin (via require_account_owner dependency). Ticket operations available to engineer+.
  • Credential scope: Per-account, not per-user. One API key pair per account.
  • Audit trail: Every post to CW logged with content, user, timestamp, and result.
  • SSRF prevention on site_url: The user-provided site_url is used to make outbound HTTP requests. To prevent SSRF attacks (e.g., pointing to internal IPs or cloud metadata endpoints), validate site_url against a known CW domain allowlist: *.myconnectwise.net, *.connectwisedev.com. Reject any URL that resolves to RFC 1918 (10.x, 172.16-31.x, 192.168.x), loopback (127.x), or link-local (169.254.x) addresses. Validation runs both on save and before each outbound request.
  • Rate limiting on CW proxy endpoints: All endpoints that proxy requests to the CW API (/integrations/psa/tickets/search, /integrations/psa/tickets/{id}, /integrations/psa/members) are rate-limited per account: 60 requests/minute for search, 30 requests/minute for individual ticket lookups, 10 requests/minute for posting. Prevents burning through the MSP's CW API allowance.

9. Implementation Slices

Slice 1: Foundation

Backend:

  • Create services/psa/ package with base.py (abstract interface), registry.py
  • Create services/psa/connectwise/client.py (HTTP client, auth, URL resolution including cloud api- prefix handling)
  • Create services/psa/connectwise/provider.py (implements test_connection only)
  • Create PsaConnection model + Alembic migration
  • Import new models in alembic/env.py (per lesson #30 — models not imported there won't be discovered by autogenerate)
  • Credential encryption/decryption utilities
  • Create require_account_owner dependency in deps.py
  • CRUD endpoints for /integrations/psa/connections
  • Connection test endpoint

Frontend:

  • Account Admin shell page with link cards
  • Integrations page — Connection tab only
  • Connection setup form (site URL, company ID, public key, private key, client ID)
  • Connection test + status display

Tests:

  • Connection CRUD
  • Credential encryption round-trip
  • Connection test (mocked CW API)
  • RBAC (only owner+ can manage)

Slice 2: Ticket Linking (Manual ID)

Backend:

  • Implement get_ticket() in ConnectWise provider (note: CW ticket IDs are integers — the provider converts the string ticket_id param to int internally, but we store as VARCHAR in our DB for PSA-agnostic compatibility since Autotask may use string IDs)
  • Add psa_ticket_id, psa_connection_id columns to sessions table + migration
  • Update SessionResponse Pydantic schema to include psa_ticket_id
  • PATCH /sessions/{id}/ticket-link endpoint with CW validation

Frontend:

  • Ticket picker modal — manual ID entry + "Look Up" button only (no search yet)
  • Session header ticket link indicator (Lucide Ticket icon)
  • Link/unlink functionality

Tests:

  • Ticket link/unlink
  • Ticket validation against CW
  • Invalid ticket ID handling

Backend:

  • Implement search_tickets(), get_ticket_statuses(), get_ticket_configurations() in ConnectWise provider
  • GET /integrations/psa/tickets/search endpoint with query, board, status filters
  • GET /integrations/psa/tickets/{id}/statuses endpoint
  • Board status caching

Frontend:

  • Ticket picker modal — full search functionality
  • Search results with ticket summary, company, board, priority, status
  • Recent tickets list

Tests:

  • Ticket search with filters
  • Status list retrieval
  • Cache behavior

Slice 4: Update Ticket Modal

Backend:

  • Implement post_note(), update_ticket_status() in ConnectWise provider
  • Note posting includes processNotifications field (false for internal_analysis, true for resolution/description)
  • Create PsaPostLog model + migration (import in alembic/env.py)
  • POST /sessions/{id}/psa-post endpoint
  • GET /sessions/{id}/psa-post/preview endpoint (reuses PSA export — verify PSA format produces CW-compatible markdown per PSA-Markdown.md)
  • GET /sessions/{id}/psa-posts endpoint (post history)
  • Note type → CW flag mapping

Frontend:

  • Update Ticket modal (split panel: content editor + controls)
  • Note type radio buttons with warning indicator on "Description" ("This note will be visible to the customer")
  • Ticket status dropdown (defaults to current)
  • Copy button
  • Error/retry UX
  • Success toast
  • Post history in Integrations page Post History tab

Tests:

  • Note posting with each note type
  • Status update (changed vs unchanged)
  • Post failure and retry
  • Post log creation
  • Preview generation
  • processNotifications set correctly per note type

Slice 5: Member Mapping

Backend:

  • Implement list_members() in ConnectWise provider
  • Create PsaMemberMapping model + migration (import in alembic/env.py)
  • Member mapping CRUD endpoints
  • Auto-match by email endpoint
  • Attribute posted notes to mapped CW member via the member field on the note POST ({ "id": externalMemberId })
  • Default behavior when no mapping exists: Note is attributed to the API member (the integration account). This is acceptable — the note content itself identifies the engineer.

Frontend:

  • Integrations page — Member Mapping tab
  • User-to-member mapping table
  • Auto-match button with results display
  • Manual assignment dropdowns

Tests:

  • Auto-match accuracy
  • Manual mapping CRUD
  • Member attribution on note posts (mapped vs unmapped user)

10. Future Considerations (Not In Scope)

  • Phase B — Ticket Context: Pull ticket details, company info, and device configurations into session context for FlowPilot AI. The get_ticket(), get_company(), and get_ticket_configurations() methods are already in the PSA interface.
  • Phase C — Callbacks: Register CW webhooks to receive ticket events and suggest relevant Flows. Requires a public webhook endpoint and callback signature verification.
  • Autotask provider: Implement AutOtaskProvider(PSAProvider) in services/psa/autotask/. Same interface, different auth (REST API with API key + secret) and field mapping.
  • Template-based posting: Preset formats ("Quick Update", "Full Analysis", "Resolution Summary") that pull different amounts of session data.
  • Background queue: Automatic retry with queued posts for when CW is unreachable.
  • Knowledge Base sync: Auto-generate CW KB articles from completed sessions.
  • Time entry logging: Auto-log session duration as CW time entries.
  • Plan-based feature gating: Consider gating PSA integration by subscription plan (e.g., free tier gets ticket linking only, paid tiers get posting). Architecture supports this via plan_limits table.
  • Provider switching (CW → Autotask): When an account changes PSA providers, existing psa_post_log entries and session ticket links become read-only historical data tied to the old psa_connection_id. Member mappings are deleted with the old connection (CASCADE). No data migration needed — the provider field and FK relationships provide full lineage.
  • Concurrent status updates: Two engineers posting to the same ticket simultaneously is safe (CW appends notes), but status updates are last-write-wins. Consider optimistic concurrency: compare current status before posting and warn if it changed since preview was loaded.