diff --git a/docs/superpowers/specs/2026-04-16-psa-ticket-management-design.md b/docs/superpowers/specs/2026-04-16-psa-ticket-management-design.md new file mode 100644 index 00000000..8eea9a00 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-psa-ticket-management-design.md @@ -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 } +``` +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