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>
This commit is contained in:
@@ -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 `<span>`):
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user