# 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) → PSATicketCreated` - `add_resource(account_id, ticket_id, member_id) → PSAResource` - `remove_resource(account_id, ticket_id, member_id) → None` - `update_status(account_id, ticket_id, status_id) → PSATicketStatusUpdate` - `list_resources(account_id, ticket_id) → list[PSAResource]` ### PSA Provider — New Abstract Methods Four explicit methods added to `PSAProvider` base class and `ConnectWiseProvider`: ```python 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}/members` - `add_resource` → `POST /service/tickets/{id}/members` - `remove_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`: ```python 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: ```json { "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`: ```json { "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 `/tickets` route (lazy, via `lazyWithRetry`) - `AppLayout.tsx` — add "Tickets" nav item in sidebar under RESOLVE section - `AssistantChatPage.tsx` — handle `create_spin_off_ticket` action type in TaskLane + add "New Ticket" button to session header - `QuickStartPage.tsx` — add `MyQueueWidget` component in collapsible Dashboard section ### Shared Types (`types/tickets.ts`) ```typescript 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: ```typescript { key: keyof TicketFilters, label: string, type: 'text' | 'select' | 'toggle', loadOptions?: () => Promise } ``` Adding or removing a filter is a one-line config change, not a component edit. ### TicketDetailPanel — Optimistic Hydration 1. Panel opens immediately with list row data (id, summary, company, board, status, priority) — no loading state for these fields 2. Two parallel fetches fire: full ticket detail + `list_resources` 3. Detail sections (contact, notes, configs, related) render skeletons until hydrated 4. Resources section renders skeleton until hydrated ### NewTicketModal — State Ownership - `NewTicketModal` owns the `TicketCreationPayload` draft state - `AiTicketParseForm` is a pure emitter: accepts a prompt string, calls `ai-parse`, fires `onParsed(Partial)` upward - Modal merges parsed values into draft, highlights `missing_fields` with 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` — 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`): ```typescript { 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: ```typescript 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 type `create_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.py` - `backend/app/schemas/psa_tickets.py` ### Modified Backend Files - `backend/app/api/endpoints/integrations.py` — 6 new endpoints, update search to return `TicketListResponse` - `backend/app/services/psa/base.py` — 4 new abstract methods - `backend/app/services/psa/connectwise/provider.py` — implement 4 new methods - `backend/app/services/flowpilot_engine.py` — add `create_spin_off_ticket` action type - `backend/app/services/unified_chat_service.py` — parse `create_spin_off_ticket` from `[ACTIONS]` ### New Frontend Files - `frontend/src/pages/TicketsPage.tsx` - `frontend/src/api/tickets.ts` - `frontend/src/types/tickets.ts` - `frontend/src/components/tickets/TicketListRow.tsx` - `frontend/src/components/tickets/TicketFilterBar.tsx` - `frontend/src/components/tickets/TicketDetailPanel.tsx` - `frontend/src/components/tickets/NewTicketModal.tsx` - `frontend/src/components/tickets/AiTicketParseForm.tsx` - `frontend/src/components/tickets/detail/TicketDetailHeader.tsx` - `frontend/src/components/tickets/detail/TicketResourceManager.tsx` - `frontend/src/components/tickets/detail/TicketNotesFeed.tsx` - `frontend/src/components/tickets/detail/TicketAddNote.tsx` - `frontend/src/components/tickets/detail/TicketConfigs.tsx` - `frontend/src/components/tickets/detail/TicketRelated.tsx` ### Modified Frontend Files - `frontend/src/router.tsx` — `/tickets` route - `frontend/src/components/layout/AppLayout.tsx` — Tickets nav item - `frontend/src/pages/AssistantChatPage.tsx` — spin-off action + New Ticket button - `frontend/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