# 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. ```text 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 ```python 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 ```python 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_id` → `SET 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_id` → `SET NULL`. Post history remains for audit purposes but is read-only. - `psa_member_mappings` → `CASCADE 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+ | #### Ticket Link Validation 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 ```json { "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: ```json { "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) #### Session Header — Ticket Link Indicator When a session has a linked ticket, show a compact indicator in the session header using the Lucide `Ticket` icon (wrapped in ``): ```text [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` ```typescript 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 ### Slice 3: Ticket Search **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.