docs: add PSA ticket management design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
# 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<Option[]> }
|
||||
```
|
||||
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<TicketCreationPayload>)` 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<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`):
|
||||
```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
|
||||
Reference in New Issue
Block a user