From 52b369680b937005f30ba3cdfaf91d6d441be5c5 Mon Sep 17 00:00:00 2001
From: Michael Chihlas
Date: Thu, 16 Apr 2026 01:36:27 +0000
Subject: [PATCH 01/36] docs: add PSA ticket management design spec
Co-Authored-By: Claude Sonnet 4.6
---
...2026-04-16-psa-ticket-management-design.md | 410 ++++++++++++++++++
1 file changed, 410 insertions(+)
create mode 100644 docs/superpowers/specs/2026-04-16-psa-ticket-management-design.md
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