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>
32 KiB
ConnectWise PSA Integration — Design Spec
Date: 2026-03-14 Status: Approved Phase: Phase A (Session → Ticket Notes), with architecture for Phase B (Ticket Context → Session) and Phase C (Callback-Driven Flow Suggestions)
1. Overview
Problem
MSP engineers using ResolutionFlow generate high-quality troubleshooting documentation through sessions, but manually copy-paste that documentation into ConnectWise PSA ticket notes. This is tedious, error-prone, and discourages thorough documentation.
Solution
Integrate ResolutionFlow with ConnectWise PSA so engineers can post session documentation directly to CW tickets — with a preview/edit step, note type selection, and optional ticket status update — all from within ResolutionFlow.
Scope
Phase A (this spec): Credential management, ticket linking, session documentation posting, member mapping.
Phase B (future): Pull ticket/company/device context into sessions for FlowPilot AI.
Phase C (future): CW callback webhooks to suggest relevant Flows on ticket events.
The architecture is designed to support all three phases. Phase A builds the foundation that B and C extend.
Prerequisite
Admin account_role: Before starting Slice 1, add an admin account_role between owner and engineer. Admins can manage integrations, members, categories — but not billing, account transfer, or account deletion. This ensures 5-15 engineer MSPs can delegate integration management without giving full owner access. Role hierarchy: super_admin > owner > admin > engineer > viewer.
All "owner+" auth references in this spec should be read as "admin+" once the admin role ships.
Delivery Strategy
Incremental slices — each independently shippable and testable:
- Foundation — PSA abstraction, credentials, connection management UI
- Ticket Linking — Link sessions to CW tickets (manual ID entry)
- Ticket Search — Search/browse CW tickets from within ResolutionFlow
- Update Ticket Modal — Post session docs + update ticket status
- Member Mapping — Map RF users to CW members
2. Architecture
2.1 PSA Abstraction Layer
All PSA integration code lives in backend/app/services/psa/. The abstraction supports ConnectWise now and Autotask in the future.
services/psa/
├── __init__.py
├── base.py # Abstract PSAProvider interface
├── registry.py # Factory — resolves provider by account
├── connectwise/
│ ├── __init__.py
│ ├── client.py # HTTP client, auth, base URL resolution, retry, pagination
│ ├── tickets.py # Ticket ops: get, search, post notes
│ ├── companies.py # Company/contact/configuration lookups
│ ├── members.py # Member list for mapping
│ └── provider.py # ConnectWiseProvider(PSAProvider)
PSAProvider Abstract Interface
class PSAProvider(ABC):
"""Abstract base for PSA integrations."""
@abstractmethod
async def test_connection(self) -> ConnectionTestResult: ...
@abstractmethod
async def get_ticket(self, ticket_id: str) -> PSATicket: ...
@abstractmethod
async def search_tickets(self, query: str, **filters) -> list[PSATicket]: ...
@abstractmethod
async def post_note(
self,
ticket_id: str,
text: str,
note_type: NoteType,
) -> PSANote: ...
@abstractmethod
async def update_ticket_status(
self,
ticket_id: str,
status_id: int,
) -> PSATicket: ...
@abstractmethod
async def get_ticket_statuses(self, board_id: int) -> list[PSAStatus]: ...
@abstractmethod
async def list_companies(self, **filters) -> list[PSACompany]: ...
@abstractmethod
async def get_company(self, company_id: str) -> PSACompany: ...
@abstractmethod
async def list_members(self) -> list[PSAMember]: ...
@abstractmethod
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]: ...
Return types (PSATicket, PSANote, PSACompany, etc.) are normalized Pydantic models — provider-agnostic. The ConnectWiseProvider maps CW-specific field names and shapes to these common models.
Provider Registry
async def get_provider_for_account(account_id: UUID, db: AsyncSession) -> PSAProvider:
"""Look up account's psa_connections row, decrypt credentials, instantiate provider."""
Reads provider field from psa_connections to determine which class to instantiate.
2.2 ConnectWise Client
connectwise/client.py handles all HTTP communication with the CW API:
- Auth:
Authorization: Basic {base64(companyId+publicKey:privateKey)}+clientIdheader 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 anapi-prefix on the hostname (e.g.,api-na.myconnectwise.net). On-premise returnsv4_6_release/with no prefix needed. The client must handle both cases. - Pagination: Navigable pagination with
page/pageSizeparams, max 1000 per page, while-loop pattern - Retry: Respect 429
Retry-Afterheader. 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
\nin JSON bodies is "Not Supported" for some fields. Must test against a real CW sandbox during Slice 4 development to confirm that notetextpreserves markdown line breaks. If CW collapses newlines, use\\nliteral strings or markdown double-space line breaks. - Partial responses: Use
fieldsquery 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_KEYvia 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. Thepsa_ticket_idremains 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_mappingsfor that user are deleted (their mapping is no longer relevant).psa_post_logentries 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_logentries retain the oldticket_idthey 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
{
"note_type": "internal_analysis",
"content": "**Session Analysis — DNS Resolution Failure**\n...",
"update_status_id": 5
}
note_type: Required. One ofinternal_analysis,resolution,description.content: Required. The (potentially edited) session documentation text.update_status_id: Optional. If provided and different from current status, PATCH the ticket status. If omitted or null, no status change.
Note Type → CW Flag Mapping
| ResolutionFlow Note Type | CW internalAnalysisFlag |
CW resolutionFlag |
CW detailDescriptionFlag |
CW internalFlag |
Visibility |
|---|---|---|---|---|---|
internal_analysis |
true |
false |
false |
true |
Internal only |
resolution |
false |
true |
false |
true |
Internal only |
description |
false |
false |
true |
false |
Customer visible |
UI warning: The Update Ticket modal must display a visible indicator when "Description" is selected, noting that this note type is customer-facing. A subtle warning like "This note will be visible to the customer" below the radio button.
processNotifications Field
When posting notes, the CW processNotifications field controls whether CW triggers its internal notification workflows. Default behavior:
internal_analysis:processNotifications: false(avoid spamming CW notification rules)resolution:processNotifications: true(resolution notes typically trigger workflows)description:processNotifications: true(customer-visible notes should trigger notifications)
This can be made configurable in a future iteration if needed.
Preview Generation
GET /sessions/{id}/psa-post/preview reuses the existing session export pipeline (format=psa) to generate the documentation content. The preview response includes:
{
"content": "**Session Analysis — DNS Resolution Failure**\n...",
"ticket": {
"id": "48291",
"summary": "DNS not resolving for client workstations",
"company": "Acme Corp",
"board": "Help Desk",
"current_status": { "id": 1, "name": "New" }
},
"available_statuses": [
{ "id": 1, "name": "New" },
{ "id": 2, "name": "In Progress" },
{ "id": 3, "name": "Completed" },
{ "id": 4, "name": "Closed" }
],
"character_count": 847,
"previous_posts": 0
}
5. Frontend Design
5.1 New Pages & Components
Account Admin Shell Page
- Route:
/account/admin - Purpose: Container page for account administration features
- Content: Link cards for "Integrations" and "Team Members" (Team Members links to existing functionality)
- Auth: Account owner or super admin only (
requiredRole="owner") - Pattern: Same as
AccountSettingsPage— link cards that navigate to sub-pages
Integrations Page (Tabbed)
- Route:
/account/admin/integrations - Tabs: Connection, Member Mapping, Post History
- Connection tab: Displays connection status, redacted credentials, site URL, company ID, last validated timestamp. Edit/Test/Disconnect actions. When no connection exists, shows a setup form.
- Member Mapping tab: Table of RF users with their mapped CW member (or "Unmapped"). "Auto-Match" button runs email matching. Dropdowns to manually assign CW members.
- Post History tab: Filterable log of all posts made by the account. Shows session name, ticket #, note type, status, timestamp, posted by.
Ticket Picker Modal
- Trigger: Button/link in session header area, or on session start
- Layout: Modal with search input, direct ticket ID entry with "Look Up" button, and a results list showing recent/matching tickets
- Each result shows: Ticket #, summary, company name, board, priority (color-coded), status
- Actions: Select a ticket to link, or Skip to proceed without linking
- Validation: On select, backend validates ticket exists and returns context
Update Ticket Modal
- Trigger: "Update Ticket" button in session UI (visible when a ticket is linked)
- Layout: Split panel — content preview/editor on left, controls on right
- Left panel: Editable textarea pre-filled from session export (PSA format). Markdown supported. Character count. Small copy button (📋 Copy) in top-right corner.
- Right panel:
- Note Type — Radio buttons: Internal Analysis (default), Resolution, Description
- Ticket Status — Dropdown populated from CW board statuses, defaults to current status. Only patches if changed.
- Update Ticket — Primary action button (cyan gradient)
- Character count warning: Show a warning at 15,000+ characters — CW has undocumented note text limits (typically 20-50K chars depending on deployment). Validate against sandbox during development.
- Error state: Bottom bar with error message, retry button. Copy fallback always available.
- Success state: Toast notification with "View in ConnectWise" link (if we can construct the CW web URL)
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>):
[Ticket icon] CW #48291 — DNS not resolving for client workstations
Acme Corp • Help Desk • New
[Update Ticket] [Unlink]
If no ticket is linked and the account has a CW connection, show a subtle "Link Ticket" button.
5.2 Frontend API Client
New module: frontend/src/api/integrations.ts
export const integrationsApi = {
// Connection management
getConnection: () => apiClient.get('/integrations/psa/connections'),
createConnection: (data: CreateConnectionPayload) => apiClient.post('/integrations/psa/connections', data),
updateConnection: (id: string, data: UpdateConnectionPayload) => apiClient.put(`/integrations/psa/connections/${id}`, data),
deleteConnection: (id: string) => apiClient.delete(`/integrations/psa/connections/${id}`),
testConnection: (id: string) => apiClient.post(`/integrations/psa/connections/${id}/test`),
// Members & mapping
listMembers: () => apiClient.get('/integrations/psa/members'),
getMemberMappings: () => apiClient.get('/integrations/psa/member-mappings'),
saveMemberMappings: (mappings: MemberMappingPayload[]) => apiClient.post('/integrations/psa/member-mappings', mappings),
autoMatchMembers: () => apiClient.post('/integrations/psa/member-mappings/auto-match'),
// Tickets
searchTickets: (params: TicketSearchParams) => apiClient.get('/integrations/psa/tickets/search', { params }),
getTicket: (id: string) => apiClient.get(`/integrations/psa/tickets/${id}`),
getTicketStatuses: (id: string) => apiClient.get(`/integrations/psa/tickets/${id}/statuses`),
}
export const sessionPsaApi = {
linkTicket: (sessionId: string, data: TicketLinkPayload) => apiClient.patch(`/sessions/${sessionId}/ticket-link`, data),
getPostPreview: (sessionId: string) => apiClient.get(`/sessions/${sessionId}/psa-post/preview`),
postToTicket: (sessionId: string, data: PostToTicketPayload) => apiClient.post(`/sessions/${sessionId}/psa-post`, data),
getPostHistory: (sessionId: string) => apiClient.get(`/sessions/${sessionId}/psa-posts`),
}
5.3 State Management
No new Zustand store needed. The integration settings pages use local React state (consistent with AssistantChatPage pattern — lesson #41). The ticket link and post modals also use local state since they're self-contained interactions.
The session's psa_ticket_id is part of the session data already managed by existing session state.
Note: The SessionResponse Pydantic schema must be updated to include psa_ticket_id (and optionally psa_connection_id) so the frontend can render the ticket link indicator.
5.4 Empty & Disabled States
| Component | No Connection | Connection Exists, No Data |
|---|---|---|
| Session header | No "Link Ticket" button shown | "Link Ticket" button visible |
| Ticket Picker search | N/A (modal won't open) | "No tickets found" message with search tips |
| Member Mapping tab | "Set up a connection first" message | "No CW members found" or "All users unmapped" |
| Post History tab | "No posts yet" | "No posts yet — link a ticket and post session docs" |
| Update Ticket modal | N/A (button hidden) | N/A (only opens when ticket is linked) |
| Session with deleted connection | "Connection removed" indicator, "Update Ticket" hidden, ticket ID shown as historical reference | N/A |
| Preview endpoint | Returns 422 if no ticket linked | Normal response |
6. CW API Caching Strategy
Board statuses, ticket types, and company lists change infrequently. Cache these at the account level:
| Data | Cache Duration | Invalidation |
|---|---|---|
| Board statuses | 1 hour | On connection re-test |
| Board list | 1 hour | On connection re-test |
| Company list | 15 minutes | Manual refresh |
| Member list | 15 minutes | On auto-match trigger |
Cache implementation: In-memory dict keyed by (account_id, resource_type, resource_id) with TTL expiry. Simple and sufficient for current scale.
Known limitation: In-memory cache is not shared across Uvicorn workers. In production with multiple workers, each worker maintains its own cache, leading to redundant CW API calls. Acceptable at current scale — can move to Redis if needed later.
7. Error Handling
CW API Errors
| CW Status | Behavior |
|---|---|
| 401 | PSAAuthError — "Invalid credentials. Check your API keys." |
| 403 | PSAPermissionError — "Insufficient permissions. Check the API member's security role." |
| 404 | PSANotFoundError — "Ticket not found." |
| 429 | PSARateLimitError — Respect Retry-After header, retry once |
| 500 | PSAServerError — "ConnectWise is experiencing issues. Try again." |
| Timeout | PSATimeoutError — "Connection timed out." |
Post Failure UX
- Error message displayed inline in the Update Ticket modal bottom bar
- Retry button to attempt the same post again
- Copy button always available as a manual fallback
- Failed posts logged in
psa_post_logwithstatus='failed'anderror_message - 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
MYaccess level, notALL, per CW best practices. - RBAC: Connection management restricted to account owner and super_admin (via
require_account_ownerdependency). 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-providedsite_urlis used to make outbound HTTP requests. To prevent SSRF attacks (e.g., pointing to internal IPs or cloud metadata endpoints), validatesite_urlagainst 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 withbase.py(abstract interface),registry.py - Create
services/psa/connectwise/client.py(HTTP client, auth, URL resolution including cloudapi-prefix handling) - Create
services/psa/connectwise/provider.py(implementstest_connectiononly) - Create
PsaConnectionmodel + 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_ownerdependency indeps.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 stringticket_idparam tointinternally, but we store asVARCHARin our DB for PSA-agnostic compatibility since Autotask may use string IDs) - Add
psa_ticket_id,psa_connection_idcolumns to sessions table + migration - Update
SessionResponsePydantic schema to includepsa_ticket_id PATCH /sessions/{id}/ticket-linkendpoint with CW validation
Frontend:
- Ticket picker modal — manual ID entry + "Look Up" button only (no search yet)
- Session header ticket link indicator (Lucide
Ticketicon) - 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/searchendpoint with query, board, status filtersGET /integrations/psa/tickets/{id}/statusesendpoint- 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
processNotificationsfield (false for internal_analysis, true for resolution/description) - Create
PsaPostLogmodel + migration (import inalembic/env.py) POST /sessions/{id}/psa-postendpointGET /sessions/{id}/psa-post/previewendpoint (reuses PSA export — verify PSA format produces CW-compatible markdown perPSA-Markdown.md)GET /sessions/{id}/psa-postsendpoint (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
processNotificationsset correctly per note type
Slice 5: Member Mapping
Backend:
- Implement
list_members()in ConnectWise provider - Create
PsaMemberMappingmodel + migration (import inalembic/env.py) - Member mapping CRUD endpoints
- Auto-match by email endpoint
- Attribute posted notes to mapped CW member via the
memberfield 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(), andget_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)inservices/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_limitstable. - Provider switching (CW → Autotask): When an account changes PSA providers, existing
psa_post_logentries and session ticket links become read-only historical data tied to the oldpsa_connection_id. Member mappings are deleted with the old connection (CASCADE). No data migration needed — theproviderfield 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.