15 KiB
PSA Ticket Management — Design Spec
Date: 2026-04-16
Status: Approved
Author: Michael Chihlas + Claude
Overview
Add PSA ticket management to ResolutionFlow so MSP engineers can view, manage, and create ConnectWise tickets without leaving the app. The feature surfaces in three places: a dedicated Tickets page, a dashboard widget on QuickStartPage, and a spin-off ticket flow inside ResolutionAssist sessions.
Decisions Made
| Question | Decision |
|---|---|
| Where does ticket management live? | Both: dedicated /tickets page + dashboard widget on QuickStartPage |
| List layout | Flat list with rich filters + pagination |
| Row density | Compact single-line rows |
| Ticket detail | Right-side slide-out panel (~50% width) |
| Ticket creation | Two-tab modal: Quick Create (AI) + Full Form |
| Resource member list | All CW members, RF-mapped users visually highlighted |
| Architecture | Dedicated ticket_service.py + normalized DTOs |
Section 1 — Backend
New Endpoints
All added to backend/app/api/endpoints/integrations.py, backed by backend/app/services/ticket_service.py.
| Method | Path | Purpose |
|---|---|---|
POST |
/integrations/psa/tickets |
Create a ticket |
PATCH |
/integrations/psa/tickets/{id}/status |
Update ticket status |
GET |
/integrations/psa/tickets/{id}/resources |
List current assignees |
POST |
/integrations/psa/tickets/{id}/resources |
Add a resource (member) |
DELETE |
/integrations/psa/tickets/{id}/resources/{member_id} |
Remove a resource |
POST |
/integrations/psa/tickets/ai-parse |
Natural language → structured pre-fill payload |
Existing endpoints (search_tickets, get_ticket, get_ticket_statuses, list_members, list_boards) are unchanged.
ticket_service.py
New service wrapping the PSA provider for ticket mutations. Keeps integrations.py clean and PSA-agnostic for future Autotask support.
Methods:
create_ticket(account_id, payload) → PSATicketCreatedadd_resource(account_id, ticket_id, member_id) → PSAResourceremove_resource(account_id, ticket_id, member_id) → Noneupdate_status(account_id, ticket_id, status_id) → PSATicketStatusUpdatelist_resources(account_id, ticket_id) → list[PSAResource]
PSA Provider — New Abstract Methods
Four explicit methods added to PSAProvider base class and ConnectWiseProvider:
async def list_resources(self, ticket_id: int) -> list[PSAResource]: ...
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: ...
async def remove_resource(self, ticket_id: int, member_id: int) -> None: ...
async def create_ticket(self, payload: TicketCreatePayload) -> PSATicketCreated: ...
update_status already exists on the provider — no change needed there.
ConnectWise implementation:
list_resources→GET /service/tickets/{id}/membersadd_resource→POST /service/tickets/{id}/membersremove_resource→DELETE /service/tickets/{id}/members/{member_id}create_ticket→POST /service/tickets
Normalized DTOs (Pydantic Schemas)
New schemas in backend/app/schemas/psa_tickets.py:
class PSAResource(BaseModel):
member_id: int
member_name: str
member_identifier: str # CW username
is_rf_user: bool # True if mapped in RF member mappings
class PSATicketCreated(BaseModel):
id: int
summary: str
board_name: str
status_name: str
priority_name: str
company_name: str
resources: list[PSAResource]
class PSATicketStatusUpdate(BaseModel):
ticket_id: int
previous_status: str
new_status: str
class TicketCreatePayload(BaseModel):
summary: str
company_id: int
board_id: int
status_id: int
priority_id: int
description: str | None = None
assigned_member_id: int | None = None
class TicketListResponse(BaseModel):
items: list[PSATicketSearchResult] # existing schema
total: int
page: int
page_size: int
search_tickets endpoint updated to return TicketListResponse (was a plain list). Backend sorts results by priority desc, dateEntered desc via CW orderBy param.
AI Parse Endpoint
POST /integrations/psa/tickets/ai-parse
Request:
{ "prompt": "New ticket for Acme Corp, Outlook not syncing, high priority, assign to me" }
Response — all pre-fill fields nullable, explicit missing_fields and warnings:
{
"summary": "Outlook not syncing",
"company_id": 42,
"board_id": null,
"priority_id": null,
"status_id": null,
"assigned_member_id": 17,
"description": "User reports Outlook calendar not syncing since yesterday morning.",
"missing_fields": ["board_id", "priority_id", "status_id"],
"warnings": ["Could not determine board from context"]
}
Frontend uses missing_fields to highlight required fields still needing engineer input. No ticket is created at this step — it is a parse-only endpoint.
Section 2 — Frontend Architecture
New Files
| File | Purpose |
|---|---|
pages/TicketsPage.tsx |
Main tickets page — filter bar + paginated list |
components/tickets/TicketListRow.tsx |
Compact single-line row |
components/tickets/TicketFilterBar.tsx |
Config-driven filter bar (7 filters) |
components/tickets/TicketDetailPanel.tsx |
Slide-out panel orchestrator |
components/tickets/detail/TicketDetailHeader.tsx |
ID, summary, company, board, SLA |
components/tickets/detail/TicketResourceManager.tsx |
Assignee list + add/remove |
components/tickets/detail/TicketNotesFeed.tsx |
Chronological notes history |
components/tickets/detail/TicketAddNote.tsx |
Inline note composer |
components/tickets/detail/TicketConfigs.tsx |
Attached devices/configs |
components/tickets/detail/TicketRelated.tsx |
Related tickets list |
components/tickets/NewTicketModal.tsx |
Two-tab modal (owns draft state) |
components/tickets/AiTicketParseForm.tsx |
Prompt input → emits parsed values upward |
api/tickets.ts |
All ticket API calls (typed, .then(r => r.data) pattern) |
types/tickets.ts |
TypeScript interfaces mirroring normalized DTOs |
Existing Files Touched
router.tsx— add/ticketsroute (lazy, vialazyWithRetry)AppLayout.tsx— add "Tickets" nav item in sidebar under RESOLVE sectionAssistantChatPage.tsx— handlecreate_spin_off_ticketaction type in TaskLane + add "New Ticket" button to session headerQuickStartPage.tsx— addMyQueueWidgetcomponent in collapsible Dashboard section
Shared Types (types/tickets.ts)
export interface TicketFilters {
search: string;
board_id: number | null;
status_id: number | null;
priority: string | null;
company_id: number | null;
assigned: 'me' | 'unassigned' | 'all' | number; // number = specific member_id
include_closed: boolean;
}
export interface TicketCreationPayload {
summary: string;
company_id: number | null;
board_id: number | null;
status_id: number | null;
priority_id: number | null;
description: string;
assigned_member_id: number | null;
}
export interface AiParseResponse {
summary: string | null;
company_id: number | null;
board_id: number | null;
priority_id: number | null;
status_id: number | null;
assigned_member_id: number | null;
description: string | null;
missing_fields: string[];
warnings: string[];
}
export interface PSAResource {
member_id: number;
member_name: string;
member_identifier: string;
is_rf_user: boolean;
}
// TicketSearchResult is the existing PSATicketSearchResult type from types/integrations.ts
// Re-export or import from there — do not redefine
export interface TicketListResponse {
items: PSATicketSearchResult[];
total: number;
page: number;
page_size: number;
}
TicketsPage — Filter & Pagination State
All filter and pagination state lives in URL query params via useSearchParams:
| Param | Type | Default |
|---|---|---|
search |
string | "" |
board |
number | — |
status |
number | — |
priority |
string | — |
company |
number | — |
assigned |
me | unassigned | all | {id} |
all |
closed |
boolean | false |
page |
number | 1 |
Filter changes reset page to 1. Pagination: page size of 25. Controls show "Showing X–Y of Z tickets". Next disabled when page * 25 >= total.
TicketFilterBar — Config-Driven
Filters defined as a FILTER_CONFIG array. Each entry:
{ key: keyof TicketFilters, label: string, type: 'text' | 'select' | 'toggle', loadOptions?: () => Promise<Option[]> }
Adding or removing a filter is a one-line config change, not a component edit.
TicketDetailPanel — Optimistic Hydration
- Panel opens immediately with list row data (id, summary, company, board, status, priority) — no loading state for these fields
- Two parallel fetches fire: full ticket detail +
list_resources - Detail sections (contact, notes, configs, related) render skeletons until hydrated
- Resources section renders skeleton until hydrated
NewTicketModal — State Ownership
NewTicketModalowns theTicketCreationPayloaddraft stateAiTicketParseFormis a pure emitter: accepts a prompt string, callsai-parse, firesonParsed(Partial<TicketCreationPayload>)upward- Modal merges parsed values into draft, highlights
missing_fieldswith visual indicators - Two tabs: Quick Create (AI prompt → review) | Full Form (manual entry)
- Default tab: Quick Create if AI-triggered, Full Form if engineer-initiated
- Initial props:
initialValues?: Partial<TicketCreationPayload>— used for spin-off pre-population
Section 3 — ResolutionAssist Integration
Two Trigger Paths
1. AI-suggested (via [ACTIONS] marker)
When the AI identifies a second distinct issue during a session, it emits:
[ACTIONS]
create_spin_off_ticket: "Printer offline on 2nd floor"
[/ACTIONS]
Action payload shape (parsed by unified_chat_service.py):
{
type: "create_spin_off_ticket",
label: string, // displayed as button text in TaskLane
summary_hint?: string // pre-populates the Quick Create prompt input only
}
summary_hint populates the AI prompt input, not the summary field directly. The engineer still runs the AI parse step and reviews all output. This prevents bypassing review with potentially hallucinated values.
2. Engineer-initiated
A "New Ticket" button in the ResolutionAssist session header. Always visible regardless of AI suggestion. Opens NewTicketModal with Full Form tab as default.
Both Paths — NewTicketModal Pre-population
When a session has a linked ticket, the modal receives:
initialValues: {
company_id: linkedTicket.company_id, // from session's linked ticket
board_id: linkedTicket.board_id,
}
When no linked ticket exists: initialValues is omitted. company_id and board_id render empty, requiring manual selection. No silent defaults, no errors.
TaskLane Action Lifecycle
- Opening the modal does not remove the action from TaskLane
- Dismissing the modal without submitting leaves the action visible
- Successful ticket creation removes the action and shows a success toast:
"Ticket #1042 created in ConnectWise"
System Prompt Addition
New rule added to the ResolutionAssist system prompt:
When you identify a second distinct issue that is separate from the primary topic of this session, suggest creating a spin-off ticket using the
[ACTIONS]marker with typecreate_spin_off_ticket. Only suggest this when the issue is clearly separate — do not suggest for every tangential mention.
Backend
create_spin_off_ticket added to the recognized action types list in flowpilot_engine.py. No new backend endpoints — the modal reuses POST /integrations/psa/tickets and POST /integrations/psa/tickets/ai-parse.
Section 4 — Dashboard Widget (QuickStartPage)
Placement
In the collapsible Dashboard section of QuickStartPage, alongside PendingEscalations and ActiveFlowPilotSessions. Component: MyQueueWidget.
Data Fetching
On mount: GET /integrations/psa/member-mappings first to detect mapping state, then searchTickets({ assigned_to_me: true, include_closed: false, page_size: 5 }) if a mapping exists for the current user.
Member mapping detection is explicit — the widget checks the mappings response, not the ticket result. "No mapping" and "no tickets" are distinct states.
Widget States
| State | Condition | Display |
|---|---|---|
| Hidden | No PSA connection | Widget not rendered |
| Prompt | PSA connected, no member mapping | "Map your PSA member to see your queue" → /account/integrations |
| Loading | Fetching | 3 skeleton rows |
| Populated | Tickets returned | Up to 5 compact rows + "View All Tickets →" |
| Empty | No assigned open tickets | "No open tickets assigned to you" — muted, no CTA |
| Error | PSA fetch fails | Silent — returns [], no toast (per Lesson 111) |
Row Display
Compact row matching Tickets page style: #ID · Summary · Status badge · Priority dot
Clicking a row opens TicketDetailPanel as a right-side sheet rendered at the QuickStartPage level. Does not navigate away.
"View All Tickets" Link
Links to /tickets?assigned=me. TicketsPage reads assigned from useSearchParams on mount and applies it as the initial filter state — consistent with Section 2 URL param contract.
Sorting
Backend search_tickets() adds orderBy=priority desc,dateEntered desc to the CW API query. Widget does not sort client-side.
Files Changed Summary
New Backend Files
backend/app/services/ticket_service.pybackend/app/schemas/psa_tickets.py
Modified Backend Files
backend/app/api/endpoints/integrations.py— 6 new endpoints, update search to returnTicketListResponsebackend/app/services/psa/base.py— 4 new abstract methodsbackend/app/services/psa/connectwise/provider.py— implement 4 new methodsbackend/app/services/flowpilot_engine.py— addcreate_spin_off_ticketaction typebackend/app/services/unified_chat_service.py— parsecreate_spin_off_ticketfrom[ACTIONS]
New Frontend Files
frontend/src/pages/TicketsPage.tsxfrontend/src/api/tickets.tsfrontend/src/types/tickets.tsfrontend/src/components/tickets/TicketListRow.tsxfrontend/src/components/tickets/TicketFilterBar.tsxfrontend/src/components/tickets/TicketDetailPanel.tsxfrontend/src/components/tickets/NewTicketModal.tsxfrontend/src/components/tickets/AiTicketParseForm.tsxfrontend/src/components/tickets/detail/TicketDetailHeader.tsxfrontend/src/components/tickets/detail/TicketResourceManager.tsxfrontend/src/components/tickets/detail/TicketNotesFeed.tsxfrontend/src/components/tickets/detail/TicketAddNote.tsxfrontend/src/components/tickets/detail/TicketConfigs.tsxfrontend/src/components/tickets/detail/TicketRelated.tsx
Modified Frontend Files
frontend/src/router.tsx—/ticketsroutefrontend/src/components/layout/AppLayout.tsx— Tickets nav itemfrontend/src/pages/AssistantChatPage.tsx— spin-off action + New Ticket buttonfrontend/src/pages/QuickStartPage.tsx— MyQueueWidget
Out of Scope
- Autotask provider implementation (schema-ready, not implemented)
- Time entry creation from ticket detail (provider method exists, no UI)
- Ticket editing beyond status (summary, description, priority changes)
- Bulk ticket operations
- Real-time ticket updates / polling