From 80e094215f38c74243764bf8c5f5f9a57f8e862b Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 20:31:03 -0400 Subject: [PATCH] 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) --- ...3-14-connectwise-psa-integration-design.md | 654 ++++++++++++++++++ 1 file changed, 654 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-14-connectwise-psa-integration-design.md diff --git a/docs/superpowers/specs/2026-03-14-connectwise-psa-integration-design.md b/docs/superpowers/specs/2026-03-14-connectwise-psa-integration-design.md new file mode 100644 index 00000000..0b0ad6cb --- /dev/null +++ b/docs/superpowers/specs/2026-03-14-connectwise-psa-integration-design.md @@ -0,0 +1,654 @@ +# 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.