# 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.
