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 } +``` +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 -- 2.49.1 From 2b3d52ad77588457ccf0b04733fca8474bd8bcfd Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 01:44:34 +0000 Subject: [PATCH 02/36] =?UTF-8?q?docs:=20fix=20PSA=20ticket=20management?= =?UTF-8?q?=20spec=20=E2=80=94=20API=20contract,=20actions=20format,=20fil?= =?UTF-8?q?e=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Explicitly call out search_tickets breaking change and all existing callers - Fix [ACTIONS] marker to use JSON array format matching existing parser - Route system prompt change to assistant_chat_service.py, not flowpilot_engine - Pivot detail panel hydration to existing getTicketContext + listResources Co-Authored-By: Claude Sonnet 4.6 --- ...2026-04-16-psa-ticket-management-design.md | 65 +++++++++++++------ 1 file changed, 44 insertions(+), 21 deletions(-) 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 index 8eea9a00..9b709e79 100644 --- a/docs/superpowers/specs/2026-04-16-psa-ticket-management-design.md +++ b/docs/superpowers/specs/2026-04-16-psa-ticket-management-design.md @@ -41,7 +41,16 @@ All added to `backend/app/api/endpoints/integrations.py`, backed by `backend/app | `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. +**Breaking change — `search_tickets` response shape updated to `TicketListResponse`.** +The existing `/integrations/psa/tickets/search` endpoint currently returns `list[PSATicketSearchResult]`. This spec changes it to return `TicketListResponse` (adds `total`, `page`, `page_size` wrapper). + +Current callers that must be migrated: +- `integrationsApi.searchTickets()` in `frontend/src/api/integrations.ts` (line 18) — update return type +- `integrationsApi.searchTicketsQueue()` in `frontend/src/api/integrations.ts` (line 20) — update return type +- `frontend/src/components/dashboard/TicketQueue.tsx` — update to read `.items` from response +- `frontend/src/components/session/TicketPickerModal.tsx` — update to read `.items` from response + +All other existing endpoints (`get_ticket`, `get_ticket_statuses`, `list_members`, `list_boards`) are unchanged. ### ticket_service.py @@ -251,10 +260,16 @@ Adding or removing a filter is a one-line config change, not a component edit. ### TicketDetailPanel — Optimistic Hydration +The panel uses the **existing** `/integrations/psa/tickets/{id}/context` endpoint (client: `psaContextApi.getTicketContext()` in `frontend/src/api/psaContext.ts`) which already returns company, contact, configurations, notes, and related tickets in one call. This avoids creating redundant endpoints. + 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 +2. Two parallel fetches fire on open: + - `psaContextApi.getTicketContext(ticketId)` — hydrates contact, notes, configs, related tickets + - `ticketsApi.listResources(ticketId)` — hydrates assignees (new endpoint) +3. All detail sections (contact, notes, configs, related) render skeletons until `getTicketContext` resolves +4. Resources section renders skeleton until `listResources` resolves + +`get_ticket` (the simpler single-ticket endpoint) is **not** used by the panel — `getTicketContext` is a strict superset of the data needed. ### NewTicketModal — State Ownership @@ -273,23 +288,23 @@ Adding or removing a filter is a one-line config change, not a component edit. **1. AI-suggested (via `[ACTIONS]` marker)** -When the AI identifies a second distinct issue during a session, it emits: +When the AI identifies a second distinct issue during a session, it emits a JSON array inside the `[ACTIONS]` marker — matching the exact format `_parse_actions_marker()` in `unified_chat_service.py` expects (a list of objects with `label`, `command`, `description`): + ``` [ACTIONS] -create_spin_off_ticket: "Printer offline on 2nd floor" +[ + { + "label": "Create ticket: Printer offline on 2nd floor", + "command": "create_spin_off_ticket", + "description": "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 -} -``` +The existing `_parse_actions_marker()` parser in `unified_chat_service.py` already handles this format — no parser changes needed. The frontend reads `action.command === "create_spin_off_ticket"` to render the "Create Ticket" button in TaskLane, and uses `action.description` as the `summary_hint` pre-populated into the Quick Create prompt input. -`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. +`summary_hint` (from `action.description`) populates the AI prompt input only, 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** @@ -315,13 +330,17 @@ When no linked ticket exists: `initialValues` is omitted. `company_id` and `boar ### System Prompt Addition -New rule added to the ResolutionAssist system prompt: +New rule added to `ASSISTANT_SYSTEM_PROMPT` in `backend/app/services/assistant_chat_service.py`: -> 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. +> When you identify a second distinct issue that is clearly separate from the primary topic of this session, suggest creating a spin-off ticket using the `[ACTIONS]` marker. Use `"command": "create_spin_off_ticket"` and put the issue description in `"description"`. Only suggest this when the issue is genuinely 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`. +- **`assistant_chat_service.py`** — system prompt updated with spin-off ticket instruction (above) +- **`unified_chat_service.py`** — no parser changes needed; the existing `_parse_actions_marker()` already handles the JSON array format. The frontend reads `command === "create_spin_off_ticket"` to route the action +- **`flowpilot_engine.py`** — no changes needed for this feature; guided FlowPilot sessions do not use this action type in the current scope + +No new backend endpoints — the modal reuses `POST /integrations/psa/tickets` and `POST /integrations/psa/tickets/ai-parse`. --- @@ -374,8 +393,9 @@ Backend `search_tickets()` adds `orderBy=priority desc,dateEntered desc` to the - `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]` +- `backend/app/services/assistant_chat_service.py` — add spin-off ticket rule to `ASSISTANT_SYSTEM_PROMPT` +- ~~`backend/app/services/flowpilot_engine.py`~~ — no changes (FlowPilot out of scope for this feature) +- ~~`backend/app/services/unified_chat_service.py`~~ — no changes (existing `[ACTIONS]` parser handles the format) ### New Frontend Files - `frontend/src/pages/TicketsPage.tsx` @@ -396,8 +416,11 @@ Backend `search_tickets()` adds `orderBy=priority desc,dateEntered desc` to the ### 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/AssistantChatPage.tsx` — handle `create_spin_off_ticket` command in action renderer + add "New Ticket" button to session header - `frontend/src/pages/QuickStartPage.tsx` — MyQueueWidget +- `frontend/src/api/integrations.ts` — update `searchTickets()` and `searchTicketsQueue()` return types to `TicketListResponse` +- `frontend/src/components/dashboard/TicketQueue.tsx` — read `.items` from paginated response +- `frontend/src/components/session/TicketPickerModal.tsx` — read `.items` from paginated response --- -- 2.49.1 From c8b68ad26d565f8c27a330278f2826801ee296c4 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 01:49:39 +0000 Subject: [PATCH 03/36] =?UTF-8?q?docs:=20fix=20PSA=20ticket=20management?= =?UTF-8?q?=20spec=20=E2=80=94=20pagination=20source,=20widget,=20linked?= =?UTF-8?q?=20ticket=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Define PaginatedTicketResult provider type + parallel count fetch via CW /count endpoint - Fix dashboard widget: updates existing TicketQueue (not new), uses searchTicketsQueue - Fix NewTicketModal prefill: expand PSATicketInfo with company_id/board_id fields - Correct Dashboard section description: not collapsible, TicketQueue already exists Co-Authored-By: Claude Sonnet 4.6 --- ...2026-04-16-psa-ticket-management-design.md | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) 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 index 9b709e79..16b9b4ef 100644 --- a/docs/superpowers/specs/2026-04-16-psa-ticket-management-design.md +++ b/docs/superpowers/specs/2026-04-16-psa-ticket-management-design.md @@ -63,10 +63,32 @@ Methods: - `update_status(account_id, ticket_id, status_id) → PSATicketStatusUpdate` - `list_resources(account_id, ticket_id) → list[PSAResource]` -### PSA Provider — New Abstract Methods +### PSA Provider — New Abstract Methods and Paginated Result Type -Four explicit methods added to `PSAProvider` base class and `ConnectWiseProvider`: +**New type in `backend/app/services/psa/types.py`:** +```python +@dataclass +class PaginatedTicketResult: + items: list[PSATicket] + total: int + page: int + page_size: int +``` +**`search_tickets` signature change** — updated on both the abstract base and `ConnectWiseProvider` to return `PaginatedTicketResult` instead of `list[PSATicket]`: +```python +# base.py +@abstractmethod +async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: ... +``` + +**How `total` is fetched** — ConnectWise provides `GET /service/tickets/count?conditions=...` which accepts the same conditions string as the page fetch. The `ConnectWiseProvider.search_tickets()` implementation fires two parallel requests: +1. `GET /service/tickets?conditions=...&pageSize=N&page=N` — the current page +2. `GET /service/tickets/count?conditions=...` — returns `{ "count": 142 }` + +Both use the same built conditions string. `asyncio.gather()` runs them in parallel. The count result is used to populate `PaginatedTicketResult.total`. + +**New abstract methods** added to `PSAProvider` base 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: ... @@ -312,10 +334,28 @@ A "New Ticket" button in the ResolutionAssist session header. Always visible reg ### Both Paths — NewTicketModal Pre-population -When a session has a linked ticket, the modal receives: +**The linked ticket IDs problem:** The current `PSATicketInfo` type in `frontend/src/types/integrations.ts` only exposes `company_name` and `board_name` — not `company_id` or `board_id`. The modal needs the numeric IDs to pre-populate the form selects. + +**Fix:** Expand `PSATicketInfo` in `types/integrations.ts` to add the optional ID fields: +```typescript +export interface PSATicketInfo { + id: string + summary: string + company_name: string | null + board_name: string | null + status_name: string | null + priority_name: string | null + company_id: number | null // add + board_id: number | null // add +} +``` + +These fields are already returned by the CW API in `get_ticket()` — update `_map_ticket()` in `ConnectWiseProvider` and the `PSATicketInfo` Pydantic schema to pass them through. + +`AssistantChatPage` already stores the linked ticket as `linkedTicket: PSATicketInfo | null` in local state (populated when a ticket is linked via `TicketLinkIndicator`). Once `PSATicketInfo` includes the IDs, the modal receives: ```typescript initialValues: { - company_id: linkedTicket.company_id, // from session's linked ticket + company_id: linkedTicket.company_id, board_id: linkedTicket.board_id, } ``` @@ -348,11 +388,13 @@ No new backend endpoints — the modal reuses `POST /integrations/psa/tickets` a ### Placement -In the collapsible Dashboard section of `QuickStartPage`, alongside `PendingEscalations` and `ActiveFlowPilotSessions`. Component: `MyQueueWidget`. +`TicketQueue` **already exists** in `QuickStartPage` (line 64, below `ActiveFlowPilotSessions`, above the Dashboard section). It currently auto-hides if no PSA connection exists. This spec updates the existing `TicketQueue` component — it is **not** a new widget and does not need to be added to `QuickStartPage`. The Dashboard section below it is not collapsible. ### 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. +On mount: `GET /integrations/psa/member-mappings` first to detect mapping state, then `integrationsApi.searchTicketsQueue({ assigned_to_me: true, include_closed: false, page_size: 5 })` if a mapping exists for the current user. + +`searchTicketsQueue` is used (not `searchTickets`) because it already accepts `assigned_to_me` and `page_size` params. Its return type will be updated to `TicketListResponse` as part of the search endpoint migration, so the widget reads `.items` after that change. Member mapping detection is explicit — the widget checks the mappings response, not the ticket result. "No mapping" and "no tickets" are distinct states. @@ -391,8 +433,10 @@ Backend `search_tickets()` adds `orderBy=priority desc,dateEntered desc` to the ### 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/psa/types.py` — add `PaginatedTicketResult` dataclass +- `backend/app/services/psa/base.py` — 4 new abstract methods; update `search_tickets` return type to `PaginatedTicketResult` +- `backend/app/services/psa/connectwise/provider.py` — implement 4 new methods; update `search_tickets` to fire parallel count request and return `PaginatedTicketResult`; update `_map_ticket()` to pass through `company_id` and `board_id` +- `backend/app/schemas/psa_connection.py` — add `company_id` and `board_id` to `PSATicketInfo` Pydantic schema - `backend/app/services/assistant_chat_service.py` — add spin-off ticket rule to `ASSISTANT_SYSTEM_PROMPT` - ~~`backend/app/services/flowpilot_engine.py`~~ — no changes (FlowPilot out of scope for this feature) - ~~`backend/app/services/unified_chat_service.py`~~ — no changes (existing `[ACTIONS]` parser handles the format) @@ -419,7 +463,8 @@ Backend `search_tickets()` adds `orderBy=priority desc,dateEntered desc` to the - `frontend/src/pages/AssistantChatPage.tsx` — handle `create_spin_off_ticket` command in action renderer + add "New Ticket" button to session header - `frontend/src/pages/QuickStartPage.tsx` — MyQueueWidget - `frontend/src/api/integrations.ts` — update `searchTickets()` and `searchTicketsQueue()` return types to `TicketListResponse` -- `frontend/src/components/dashboard/TicketQueue.tsx` — read `.items` from paginated response +- `frontend/src/types/integrations.ts` — add `company_id: number | null` and `board_id: number | null` to `PSATicketInfo` +- `frontend/src/components/dashboard/TicketQueue.tsx` — update existing component: read `.items`, add mapping-state detection, member-mapping check, and 5-item cap - `frontend/src/components/session/TicketPickerModal.tsx` — read `.items` from paginated response --- -- 2.49.1 From 001438008b76c1342a4fd1815d3d53ce8e961c6b Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 02:00:33 +0000 Subject: [PATCH 04/36] =?UTF-8?q?docs:=20fix=20PSA=20ticket=20management?= =?UTF-8?q?=20spec=20=E2=80=94=20prefill=20state,=20TicketQueue=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace false claim about linkedTicket state with explicit fetch step on modal open - Remove MyQueueWidget references; TicketQueue is the existing component being updated Co-Authored-By: Claude Sonnet 4.6 --- .../2026-04-16-psa-ticket-management-design.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 index 16b9b4ef..0a195984 100644 --- a/docs/superpowers/specs/2026-04-16-psa-ticket-management-design.md +++ b/docs/superpowers/specs/2026-04-16-psa-ticket-management-design.md @@ -201,7 +201,7 @@ Frontend uses `missing_fields` to highlight required fields still needing engine - `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 +- `QuickStartPage.tsx` — no structural change needed; `TicketQueue` already renders at line 64. The existing component is updated in place (see Section 4). ### Shared Types (`types/tickets.ts`) @@ -352,7 +352,14 @@ export interface PSATicketInfo { These fields are already returned by the CW API in `get_ticket()` — update `_map_ticket()` in `ConnectWiseProvider` and the `PSATicketInfo` Pydantic schema to pass them through. -`AssistantChatPage` already stores the linked ticket as `linkedTicket: PSATicketInfo | null` in local state (populated when a ticket is linked via `TicketLinkIndicator`). Once `PSATicketInfo` includes the IDs, the modal receives: +**`AssistantChatPage` state change required:** The current page only tracks `activePsaTicketId: string | null` (line 76) — it does not hold a `PSATicketInfo` object. Add a new state field: +```typescript +const [linkedTicket, setLinkedTicket] = useState(null) +``` + +When the modal is opened (either via AI suggestion or the "New Ticket" button), if `activePsaTicketId` is set and `linkedTicket` is null, fire `integrationsApi.getTicket(activePsaTicketId)` to fetch the full ticket (which now includes `company_id` and `board_id`) and store it in `linkedTicket`. The modal opens immediately — `initialValues` is populated once the fetch resolves and the form fields update. If the fetch is still in flight when the modal opens, `company_id` and `board_id` start empty and fill in when ready. + +Once `linkedTicket` is populated, the modal receives: ```typescript initialValues: { company_id: linkedTicket.company_id, @@ -360,7 +367,7 @@ initialValues: { } ``` -When no linked ticket exists: `initialValues` is omitted. `company_id` and `board_id` render empty, requiring manual selection. No silent defaults, no errors. +When no linked ticket exists (`activePsaTicketId === null`): `initialValues` is omitted. `company_id` and `board_id` render empty, requiring manual selection. No silent defaults, no errors. ### TaskLane Action Lifecycle @@ -461,7 +468,7 @@ Backend `search_tickets()` adds `orderBy=priority desc,dateEntered desc` to the - `frontend/src/router.tsx` — `/tickets` route - `frontend/src/components/layout/AppLayout.tsx` — Tickets nav item - `frontend/src/pages/AssistantChatPage.tsx` — handle `create_spin_off_ticket` command in action renderer + add "New Ticket" button to session header -- `frontend/src/pages/QuickStartPage.tsx` — MyQueueWidget +- `frontend/src/components/dashboard/TicketQueue.tsx` — update existing component (see Section 4 — not a new file) - `frontend/src/api/integrations.ts` — update `searchTickets()` and `searchTicketsQueue()` return types to `TicketListResponse` - `frontend/src/types/integrations.ts` — add `company_id: number | null` and `board_id: number | null` to `PSATicketInfo` - `frontend/src/components/dashboard/TicketQueue.tsx` — update existing component: read `.items`, add mapping-state detection, member-mapping check, and 5-item cap -- 2.49.1 From 44634b11459f18531d1f622c811e2276b9806a15 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 02:41:48 +0000 Subject: [PATCH 05/36] feat(psa): add PaginatedTicketResult type, update provider search_tickets signature Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/psa/base.py | 3 ++- backend/app/services/psa/types.py | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/app/services/psa/base.py b/backend/app/services/psa/base.py index a599b064..913dbaee 100644 --- a/backend/app/services/psa/base.py +++ b/backend/app/services/psa/base.py @@ -13,6 +13,7 @@ from .types import ( PSAConfiguration, PSATimeEntry, PSABoard, + PaginatedTicketResult, ) @@ -28,7 +29,7 @@ class PSAProvider(ABC): ... @abstractmethod - async def search_tickets(self, query: str, **filters) -> list[PSATicket]: + async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: ... @abstractmethod diff --git a/backend/app/services/psa/types.py b/backend/app/services/psa/types.py index c051a5f7..025fe756 100644 --- a/backend/app/services/psa/types.py +++ b/backend/app/services/psa/types.py @@ -73,6 +73,13 @@ class PSABoard(BaseModel): inactive: bool = False +class PaginatedTicketResult(BaseModel): + items: list[PSATicket] + total: int + page: int + page_size: int + + class NoteType: INTERNAL_ANALYSIS = "internal_analysis" RESOLUTION = "resolution" -- 2.49.1 From 8d964e64e4c81b8823e326a6ebcd36f178f13bea Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 02:44:08 +0000 Subject: [PATCH 06/36] fix(psa): update autotask/halopsa stub search_tickets return type annotation Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/psa/autotask/provider.py | 3 ++- backend/app/services/psa/halopsa/provider.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/app/services/psa/autotask/provider.py b/backend/app/services/psa/autotask/provider.py index a78fc1c1..f7fb21fe 100644 --- a/backend/app/services/psa/autotask/provider.py +++ b/backend/app/services/psa/autotask/provider.py @@ -12,6 +12,7 @@ from app.services.psa.types import ( PSAConfiguration, PSATimeEntry, PSABoard, + PaginatedTicketResult, ) @@ -28,7 +29,7 @@ class AutotaskProvider(PSAProvider): async def get_ticket(self, ticket_id: str) -> PSATicket: raise NotImplementedError("Autotask integration coming soon") - async def search_tickets(self, query: str, **filters) -> list[PSATicket]: + async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: raise NotImplementedError("Autotask integration coming soon") async def post_note( diff --git a/backend/app/services/psa/halopsa/provider.py b/backend/app/services/psa/halopsa/provider.py index 4a917ed2..2855d5e2 100644 --- a/backend/app/services/psa/halopsa/provider.py +++ b/backend/app/services/psa/halopsa/provider.py @@ -12,6 +12,7 @@ from app.services.psa.types import ( PSAConfiguration, PSATimeEntry, PSABoard, + PaginatedTicketResult, ) @@ -28,7 +29,7 @@ class HaloPSAProvider(PSAProvider): async def get_ticket(self, ticket_id: str) -> PSATicket: raise NotImplementedError("Halo PSA integration coming soon") - async def search_tickets(self, query: str, **filters) -> list[PSATicket]: + async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: raise NotImplementedError("Halo PSA integration coming soon") async def post_note( -- 2.49.1 From ff0ec143e2ec85242c7a2c3e8c2b021f9d2c163b Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 02:45:24 +0000 Subject: [PATCH 07/36] feat(psa): add PSAResource, TicketCreatePayload types and abstract provider methods Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/psa/autotask/provider.py | 18 +++++++++++++ backend/app/services/psa/base.py | 23 ++++++++++++++++ backend/app/services/psa/halopsa/provider.py | 18 +++++++++++++ backend/app/services/psa/types.py | 27 +++++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/backend/app/services/psa/autotask/provider.py b/backend/app/services/psa/autotask/provider.py index f7fb21fe..3af30de9 100644 --- a/backend/app/services/psa/autotask/provider.py +++ b/backend/app/services/psa/autotask/provider.py @@ -13,6 +13,9 @@ from app.services.psa.types import ( PSATimeEntry, PSABoard, PaginatedTicketResult, + PSAResource, + PSACreatedTicket, + TicketCreatePayload, ) @@ -75,3 +78,18 @@ class AutotaskProvider(PSAProvider): work_type: str | None = None, ) -> PSATimeEntry: raise NotImplementedError("Autotask integration coming soon") + + async def list_resources(self, ticket_id: int) -> list[PSAResource]: + raise NotImplementedError("Autotask integration coming soon") + + async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: + raise NotImplementedError("Autotask integration coming soon") + + async def remove_resource(self, ticket_id: int, member_id: int) -> None: + raise NotImplementedError("Autotask integration coming soon") + + async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket: + raise NotImplementedError("Autotask integration coming soon") + + async def list_priorities(self) -> list[dict]: + raise NotImplementedError("Autotask integration coming soon") diff --git a/backend/app/services/psa/base.py b/backend/app/services/psa/base.py index 913dbaee..8f3ab8a8 100644 --- a/backend/app/services/psa/base.py +++ b/backend/app/services/psa/base.py @@ -14,6 +14,9 @@ from .types import ( PSATimeEntry, PSABoard, PaginatedTicketResult, + PSAResource, + PSACreatedTicket, + TicketCreatePayload, ) @@ -84,3 +87,23 @@ class PSAProvider(ABC): work_type: str | None = None, ) -> PSATimeEntry: ... + + @abstractmethod + async def list_resources(self, ticket_id: int) -> list[PSAResource]: + ... + + @abstractmethod + async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: + ... + + @abstractmethod + async def remove_resource(self, ticket_id: int, member_id: int) -> None: + ... + + @abstractmethod + async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket: + ... + + @abstractmethod + async def list_priorities(self) -> list[dict]: + ... diff --git a/backend/app/services/psa/halopsa/provider.py b/backend/app/services/psa/halopsa/provider.py index 2855d5e2..d8f2573e 100644 --- a/backend/app/services/psa/halopsa/provider.py +++ b/backend/app/services/psa/halopsa/provider.py @@ -13,6 +13,9 @@ from app.services.psa.types import ( PSATimeEntry, PSABoard, PaginatedTicketResult, + PSAResource, + PSACreatedTicket, + TicketCreatePayload, ) @@ -75,3 +78,18 @@ class HaloPSAProvider(PSAProvider): work_type: str | None = None, ) -> PSATimeEntry: raise NotImplementedError("Halo PSA integration coming soon") + + async def list_resources(self, ticket_id: int) -> list[PSAResource]: + raise NotImplementedError("Halo PSA integration coming soon") + + async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: + raise NotImplementedError("Halo PSA integration coming soon") + + async def remove_resource(self, ticket_id: int, member_id: int) -> None: + raise NotImplementedError("Halo PSA integration coming soon") + + async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket: + raise NotImplementedError("Halo PSA integration coming soon") + + async def list_priorities(self) -> list[dict]: + raise NotImplementedError("Halo PSA integration coming soon") diff --git a/backend/app/services/psa/types.py b/backend/app/services/psa/types.py index 025fe756..2ac841b5 100644 --- a/backend/app/services/psa/types.py +++ b/backend/app/services/psa/types.py @@ -80,6 +80,33 @@ class PaginatedTicketResult(BaseModel): page_size: int +class PSAResource(BaseModel): + member_id: int + member_name: str + member_identifier: str + is_rf_user: bool = False + + +class PSACreatedTicket(BaseModel): + id: int + summary: str + board_name: str + status_name: str + priority_name: str + company_name: str + resources: list[PSAResource] = [] + + +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 NoteType: INTERNAL_ANALYSIS = "internal_analysis" RESOLUTION = "resolution" -- 2.49.1 From e714088a2b08dad21f4bfc82693dad5e42d3f9ed Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 02:49:20 +0000 Subject: [PATCH 08/36] feat(psa): implement list/add/remove resources, create_ticket, paginated search in CW provider Co-Authored-By: Claude Sonnet 4.6 --- .../app/services/psa/connectwise/provider.py | 150 +++++++++++++++--- 1 file changed, 129 insertions(+), 21 deletions(-) diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py index af154cdd..465f66ab 100644 --- a/backend/app/services/psa/connectwise/provider.py +++ b/backend/app/services/psa/connectwise/provider.py @@ -17,6 +17,10 @@ from app.services.psa.types import ( PSAConfiguration, PSATimeEntry, PSABoard, + PaginatedTicketResult, + PSAResource, + PSACreatedTicket, + TicketCreatePayload, ) from .client import ConnectWiseClient @@ -55,20 +59,19 @@ class ConnectWiseProvider(PSAProvider): ) return self._map_ticket(data) - async def search_tickets(self, query: str, **filters) -> list[PSATicket]: - """Search CW tickets by summary. Supports board_id, status_id, member_id, - unassigned, board_ids, page, and page_size filters.""" + async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: + """Search CW tickets by summary. Supports board_id, status_id, member_identifier, + unassigned, board_ids, page, and page_size filters. Returns paginated result.""" page_size = filters.get("page_size", 10) page = filters.get("page", 1) params: dict = { "fields": "id,summary,company,board,status,priority,closedFlag", - "orderBy": "id desc", + "orderBy": "priority/sort asc,dateEntered desc", "pageSize": page_size, "page": page, } - # Build CW condition query conditions: list[str] = [] if query: conditions.append(f"summary contains '{query}'") @@ -87,15 +90,24 @@ class ConnectWiseProvider(PSAProvider): board_list = ", ".join(str(bid) for bid in board_ids) conditions.append(f"board/id in ({board_list})") - if conditions: - params["conditions"] = " and ".join(conditions) + condition_str = " and ".join(conditions) if conditions else "" + if condition_str: + params["conditions"] = condition_str - data = await self.client.get("/service/tickets", params=params) + count_params: dict = {} + if condition_str: + count_params["conditions"] = condition_str - return [ - self._map_ticket(t) - for t in (data if isinstance(data, list) else []) - ] + # Fire page fetch + count in parallel + data, count_data = await asyncio.gather( + self.client.get("/service/tickets", params=params), + self.client.get("/service/tickets/count", params=count_params), + ) + + items = [self._map_ticket(t) for t in (data if isinstance(data, list) else [])] + total = count_data.get("count", len(items)) if isinstance(count_data, dict) else len(items) + + return PaginatedTicketResult(items=items, total=total, page=page, page_size=page_size) async def get_ticket_configurations( self, ticket_id: str @@ -591,16 +603,112 @@ class ConnectWiseProvider(PSAProvider): @staticmethod def _map_ticket(data: dict) -> PSATicket: """Map a CW ticket JSON dict to a PSATicket.""" + company = data.get("company") or {} + board = data.get("board") or {} + status = data.get("status") or {} + priority = data.get("priority") or {} return PSATicket( - id=str(data["id"]), + id=str(data.get("id", "")), summary=data.get("summary", ""), - company_name=data.get("company", {}).get("name"), - company_id=str(data["company"]["id"]) if data.get("company") else None, - board_name=data.get("board", {}).get("name"), - board_id=data.get("board", {}).get("id"), - status_name=data.get("status", {}).get("name"), - status_id=data.get("status", {}).get("id"), - priority_name=data.get("priority", {}).get("name"), - priority_id=data.get("priority", {}).get("id"), + company_name=company.get("name"), + company_id=str(company.get("id")) if company.get("id") else None, + board_name=board.get("name"), + board_id=board.get("id"), + status_name=status.get("name"), + status_id=status.get("id"), + priority_name=priority.get("name"), + priority_id=priority.get("id"), closed=data.get("closedFlag", False), ) + + # ── Resource management ─────────────────────────────────────────── + + async def list_resources(self, ticket_id: int) -> list[PSAResource]: + """List members assigned to a CW ticket.""" + data = await self.client.get(f"/service/tickets/{ticket_id}/members") + results = [] + for m in (data if isinstance(data, list) else []): + member = m.get("member") or {} + results.append(PSAResource( + member_id=member.get("id", 0), + member_name=member.get("name", ""), + member_identifier=member.get("identifier", ""), + )) + return results + + async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: + """Assign a member to a CW ticket.""" + data = await self.client.post( + f"/service/tickets/{ticket_id}/members", + json_body={"member": {"id": member_id}}, + ) + member = (data.get("member") or {}) if isinstance(data, dict) else {} + return PSAResource( + member_id=member.get("id", member_id), + member_name=member.get("name", ""), + member_identifier=member.get("identifier", ""), + ) + + async def remove_resource(self, ticket_id: int, member_id: int) -> None: + """Remove a member from a CW ticket (idempotent).""" + # CW DELETE requires the member record id (junction record), not the member's id + members_data = await self.client.get(f"/service/tickets/{ticket_id}/members") + record_id = None + for m in (members_data if isinstance(members_data, list) else []): + if (m.get("member") or {}).get("id") == member_id: + record_id = m.get("id") + break + if record_id is None: + return # Already not assigned — idempotent + await self.client.delete(f"/service/tickets/{ticket_id}/members/{record_id}") + + # ── Ticket creation ─────────────────────────────────────────────── + + async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket: + """Create a new CW service ticket.""" + body: dict = { + "summary": payload.summary, + "board": {"id": payload.board_id}, + "company": {"id": payload.company_id}, + "status": {"id": payload.status_id}, + "priority": {"id": payload.priority_id}, + } + if payload.description: + body["initialDescription"] = payload.description + if payload.assigned_member_id: + body["owner"] = {"id": payload.assigned_member_id} + + data = await self.client.post("/service/tickets", json_body=body) + + ticket_id = data.get("id") if isinstance(data, dict) else None + resources: list[PSAResource] = [] + if ticket_id and payload.assigned_member_id: + try: + resources = await self.list_resources(ticket_id) + except Exception: + pass + + company = (data.get("company") or {}) if isinstance(data, dict) else {} + board = (data.get("board") or {}) if isinstance(data, dict) else {} + status = (data.get("status") or {}) if isinstance(data, dict) else {} + priority = (data.get("priority") or {}) if isinstance(data, dict) else {} + + return PSACreatedTicket( + id=ticket_id or 0, + summary=data.get("summary", payload.summary) if isinstance(data, dict) else payload.summary, + board_name=board.get("name", ""), + status_name=status.get("name", ""), + priority_name=priority.get("name", ""), + company_name=company.get("name", ""), + resources=resources, + ) + + # ── Priorities ──────────────────────────────────────────────────── + + async def list_priorities(self) -> list[dict]: + """List CW service priorities.""" + data = await self.client.get("/service/priorities", params={"pageSize": 50}) + return [ + {"id": p.get("id"), "name": p.get("name")} + for p in (data if isinstance(data, list) else []) + ] -- 2.49.1 From 66cca70588473c754cf2cb542e7b1bd294368bb5 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 02:50:56 +0000 Subject: [PATCH 09/36] feat(psa): expand PSATicketSearchResult with IDs, add psa_tickets.py schemas Co-Authored-By: Claude Sonnet 4.6 --- backend/app/schemas/psa_connection.py | 4 ++ backend/app/schemas/psa_tickets.py | 64 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 backend/app/schemas/psa_tickets.py diff --git a/backend/app/schemas/psa_connection.py b/backend/app/schemas/psa_connection.py index f0dfde42..ece87b9f 100644 --- a/backend/app/schemas/psa_connection.py +++ b/backend/app/schemas/psa_connection.py @@ -53,9 +53,13 @@ class PSATicketSearchResult(BaseModel): id: str summary: str company_name: str | None = None + company_id: str | None = None board_name: str | None = None + board_id: int | None = None status_name: str | None = None + status_id: int | None = None priority_name: str | None = None + priority_id: int | None = None closed: bool = False diff --git a/backend/app/schemas/psa_tickets.py b/backend/app/schemas/psa_tickets.py new file mode 100644 index 00000000..91a37d73 --- /dev/null +++ b/backend/app/schemas/psa_tickets.py @@ -0,0 +1,64 @@ +"""Normalized DTOs for ticket management endpoints.""" +from __future__ import annotations +from pydantic import BaseModel + + +class PSAResourceSchema(BaseModel): + member_id: int + member_name: str + member_identifier: str + is_rf_user: bool = False + + +class PSATicketCreatedSchema(BaseModel): + id: int + summary: str + board_name: str + status_name: str + priority_name: str + company_name: str + resources: list[PSAResourceSchema] = [] + + +class PSATicketStatusUpdateSchema(BaseModel): + ticket_id: int + previous_status: str + new_status: str + + +class TicketCreatePayloadSchema(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 TicketListResponseSchema(BaseModel): + items: list = [] + total: int = 0 + page: int = 1 + page_size: int = 25 + + +class AiParseRequestSchema(BaseModel): + prompt: str + + +class AiParseResponseSchema(BaseModel): + summary: str | None = None + company_id: int | None = None + board_id: int | None = None + priority_id: int | None = None + status_id: int | None = None + assigned_member_id: int | None = None + description: str | None = None + missing_fields: list[str] = [] + warnings: list[str] = [] + + +class PSAPrioritySchema(BaseModel): + id: int + name: str -- 2.49.1 From a5e96156661a4c7bb6e218bd481e35398ea1684b Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 02:52:32 +0000 Subject: [PATCH 10/36] feat(psa): add ticket_service.py with list/add/remove resource, update_status, create_ticket Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/ticket_service.py | 115 +++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 backend/app/services/ticket_service.py diff --git a/backend/app/services/ticket_service.py b/backend/app/services/ticket_service.py new file mode 100644 index 00000000..66e9552e --- /dev/null +++ b/backend/app/services/ticket_service.py @@ -0,0 +1,115 @@ +"""Ticket mutation service — wraps PSA provider, resolves is_rf_user flag.""" +from __future__ import annotations + +import logging +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.psa_connection import PsaConnection +from app.models.psa_member_mapping import PsaMemberMapping +from app.schemas.psa_tickets import ( + PSAResourceSchema, + PSATicketCreatedSchema, + PSATicketStatusUpdateSchema, +) +from app.services.psa.registry import get_provider_for_account +from app.services.psa.types import TicketCreatePayload + +logger = logging.getLogger(__name__) + + +async def _get_mapped_member_ids(account_id: UUID, db: AsyncSession) -> set[int]: + """Return set of external_member_id ints that are mapped to RF users.""" + conn_result = await db.execute( + select(PsaConnection).where(PsaConnection.account_id == account_id) + ) + conn = conn_result.scalar_one_or_none() + if not conn: + return set() + mappings = await db.execute( + select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id) + ) + return {int(m.external_member_id) for m in mappings.scalars().all() if m.external_member_id} + + +async def list_resources( + account_id: UUID, ticket_id: int, db: AsyncSession +) -> list[PSAResourceSchema]: + provider = await get_provider_for_account(account_id, db) + mapped_ids = await _get_mapped_member_ids(account_id, db) + resources = await provider.list_resources(ticket_id) + return [ + PSAResourceSchema( + member_id=r.member_id, + member_name=r.member_name, + member_identifier=r.member_identifier, + is_rf_user=r.member_id in mapped_ids, + ) + for r in resources + ] + + +async def add_resource( + account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession +) -> PSAResourceSchema: + provider = await get_provider_for_account(account_id, db) + mapped_ids = await _get_mapped_member_ids(account_id, db) + resource = await provider.add_resource(ticket_id, member_id) + return PSAResourceSchema( + member_id=resource.member_id, + member_name=resource.member_name, + member_identifier=resource.member_identifier, + is_rf_user=resource.member_id in mapped_ids, + ) + + +async def remove_resource( + account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession +) -> None: + provider = await get_provider_for_account(account_id, db) + await provider.remove_resource(ticket_id, member_id) + + +async def update_status( + account_id: UUID, ticket_id: int, status_id: int, db: AsyncSession +) -> PSATicketStatusUpdateSchema: + provider = await get_provider_for_account(account_id, db) + # get current status before updating + ticket = await provider.get_ticket(str(ticket_id)) + previous_status = ticket.status_name or "" + await provider.update_ticket_status(str(ticket_id), status_id) + # get new status name from statuses list + statuses = await provider.get_ticket_statuses(ticket.board_id or 0) + new_status = next((s.name for s in statuses if s.id == status_id), str(status_id)) + return PSATicketStatusUpdateSchema( + ticket_id=ticket_id, + previous_status=previous_status, + new_status=new_status, + ) + + +async def create_ticket( + account_id: UUID, payload: TicketCreatePayload, db: AsyncSession +) -> PSATicketCreatedSchema: + provider = await get_provider_for_account(account_id, db) + mapped_ids = await _get_mapped_member_ids(account_id, db) + result = await provider.create_ticket(payload) + return PSATicketCreatedSchema( + id=result.id, + summary=result.summary, + board_name=result.board_name, + status_name=result.status_name, + priority_name=result.priority_name, + company_name=result.company_name, + resources=[ + PSAResourceSchema( + member_id=r.member_id, + member_name=r.member_name, + member_identifier=r.member_identifier, + is_rf_user=r.member_id in mapped_ids, + ) + for r in result.resources + ], + ) -- 2.49.1 From e2cdfac1c3deb4f5379f5b5c0d7ed7d9b6f72a1e Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 02:55:49 +0000 Subject: [PATCH 11/36] feat(psa): update search endpoint for pagination, add create/status/resource/priority endpoints Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/endpoints/integrations.py | 147 +++++++++++++++++++--- 1 file changed, 130 insertions(+), 17 deletions(-) diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index 48fa9f57..88e9e91d 100644 --- a/backend/app/api/endpoints/integrations.py +++ b/backend/app/api/endpoints/integrations.py @@ -30,6 +30,14 @@ from app.schemas.psa_connection import ( PSABoardResponse, ) from app.core.config import settings +from app.schemas.psa_tickets import ( + PSAResourceSchema, + PSATicketCreatedSchema, + PSATicketStatusUpdateSchema, + TicketCreatePayloadSchema, + PSAPrioritySchema, +) +import app.services.ticket_service as ticket_svc from app.services.psa.encryption import ( decrypt_credentials, encrypt_credentials, @@ -367,7 +375,7 @@ async def list_boards( return [] -@router.get("/tickets/search", response_model=list[PSATicketSearchResult]) +@router.get("/tickets/search") async def search_tickets( current_user: Annotated[User, Depends(require_engineer_or_admin)], db: Annotated[AsyncSession, Depends(get_db)], @@ -378,17 +386,18 @@ async def search_tickets( assigned_to_me: bool = False, unassigned: bool = False, board_ids: str = "", + priority: str | None = None, + company_id: int | None = None, page: int = 1, - page_size: int = 10, + page_size: int = 25, ): - """Search ConnectWise tickets.""" + """Search ConnectWise tickets — returns paginated TicketListResponse.""" if not current_user.account_id: raise HTTPException(status_code=400, detail="User has no account") from app.services.psa.registry import get_provider_for_account from app.services.psa.exceptions import PSAError - # Resolve assigned_to_me → member_identifier (CW login name for resources contains filter) member_identifier: str | None = None if assigned_to_me: conn_result = await db.execute( @@ -407,23 +416,18 @@ async def search_tickets( ) mapping = mapping_result.scalar_one_or_none() if not mapping: - # No mapping for this user — return empty list - return [] - - from app.services.psa.registry import get_provider_for_account as _get_provider - from app.services.psa.exceptions import PSAError as _PSAError + return {"items": [], "total": 0, "page": page, "page_size": page_size} try: - _provider = await _get_provider(current_user.account_id, db) + _provider = await get_provider_for_account(current_user.account_id, db) cw_members = await _provider.list_members() matched = next((m for m in cw_members if m.id == mapping.external_member_id), None) if matched: member_identifier = matched.identifier else: - return [] - except _PSAError: - return [] + return {"items": [], "total": 0, "page": page, "page_size": page_size} + except PSAError: + return {"items": [], "total": 0, "page": page, "page_size": page_size} - # Parse comma-separated board_ids parsed_board_ids: list[int] = [] if board_ids: try: @@ -433,7 +437,7 @@ async def search_tickets( try: provider = await get_provider_for_account(current_user.account_id, db) - tickets = await provider.search_tickets( + result = await provider.search_tickets( query, board_id=board_id, status_id=status_id, @@ -441,25 +445,134 @@ async def search_tickets( member_identifier=member_identifier, unassigned=unassigned, board_ids=parsed_board_ids, + company_id=company_id, page=page, page_size=page_size, ) - return [ + items = [ PSATicketSearchResult( id=t.id, summary=t.summary, company_name=t.company_name, + company_id=t.company_id, board_name=t.board_name, + board_id=t.board_id, status_name=t.status_name, + status_id=t.status_id, priority_name=t.priority_name, + priority_id=t.priority_id, closed=t.closed, ) - for t in tickets + for t in result.items ] + return {"items": items, "total": result.total, "page": result.page, "page_size": result.page_size} except PSAError as e: raise HTTPException(status_code=502, detail=str(e)) +@router.post("/tickets", response_model=PSATicketCreatedSchema, status_code=201) +async def create_ticket( + data: TicketCreatePayloadSchema, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Create a new PSA ticket.""" + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + from app.services.psa.exceptions import PSAError + from app.services.psa.types import TicketCreatePayload + try: + return await ticket_svc.create_ticket( + current_user.account_id, + TicketCreatePayload(**data.model_dump()), + db, + ) + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema) +async def update_ticket_status_endpoint( + ticket_id: int, + status_id: int, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Update a ticket's status.""" + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + from app.services.psa.exceptions import PSAError + try: + return await ticket_svc.update_status(current_user.account_id, ticket_id, status_id, db) + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/tickets/{ticket_id}/resources", response_model=list[PSAResourceSchema]) +async def list_ticket_resources( + ticket_id: int, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + from app.services.psa.exceptions import PSAError + try: + return await ticket_svc.list_resources(current_user.account_id, ticket_id, db) + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/tickets/{ticket_id}/resources", response_model=PSAResourceSchema, status_code=201) +async def add_ticket_resource( + ticket_id: int, + member_id: int, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + from app.services.psa.exceptions import PSAError + try: + return await ticket_svc.add_resource(current_user.account_id, ticket_id, member_id, db) + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.delete("/tickets/{ticket_id}/resources/{member_id}", status_code=204) +async def remove_ticket_resource( + ticket_id: int, + member_id: int, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + from app.services.psa.exceptions import PSAError + try: + await ticket_svc.remove_resource(current_user.account_id, ticket_id, member_id, db) + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/priorities", response_model=list[PSAPrioritySchema]) +async def list_priorities( + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """List PSA priority levels for ticket creation form.""" + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + from app.services.psa.registry import get_provider_for_account + from app.services.psa.exceptions import PSAError + try: + provider = await get_provider_for_account(current_user.account_id, db) + raw = await provider.list_priorities() + return [PSAPrioritySchema(id=p["id"], name=p["name"]) for p in raw if p.get("id")] + except PSAError: + return [] + + @router.get("/tickets/{ticket_id}/context") async def get_ticket_context( ticket_id: int, -- 2.49.1 From 24ab1908a68f2c207d33c6783966cc8ada5c66c2 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 02:57:23 +0000 Subject: [PATCH 12/36] fix(psa): add TicketListResponseSchema response_model to search_tickets endpoint Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/endpoints/integrations.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index 88e9e91d..0f66774c 100644 --- a/backend/app/api/endpoints/integrations.py +++ b/backend/app/api/endpoints/integrations.py @@ -36,6 +36,7 @@ from app.schemas.psa_tickets import ( PSATicketStatusUpdateSchema, TicketCreatePayloadSchema, PSAPrioritySchema, + TicketListResponseSchema, ) import app.services.ticket_service as ticket_svc from app.services.psa.encryption import ( @@ -375,7 +376,7 @@ async def list_boards( return [] -@router.get("/tickets/search") +@router.get("/tickets/search", response_model=TicketListResponseSchema) async def search_tickets( current_user: Annotated[User, Depends(require_engineer_or_admin)], db: Annotated[AsyncSession, Depends(get_db)], -- 2.49.1 From 6e0188d0b4a5d43339c439010d2b24c8e16b1bcf Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 02:59:02 +0000 Subject: [PATCH 13/36] feat(psa): add AI ticket parse endpoint Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/endpoints/integrations.py | 109 ++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index 0f66774c..53abd104 100644 --- a/backend/app/api/endpoints/integrations.py +++ b/backend/app/api/endpoints/integrations.py @@ -1,6 +1,7 @@ """PSA integration endpoints — connection CRUD and test.""" from __future__ import annotations +import logging from datetime import datetime, timezone from typing import Annotated from uuid import UUID @@ -11,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import delete +logger = logging.getLogger(__name__) + from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin from app.core.database import get_db from app.models.psa_connection import PsaConnection @@ -37,6 +40,8 @@ from app.schemas.psa_tickets import ( TicketCreatePayloadSchema, PSAPrioritySchema, TicketListResponseSchema, + AiParseRequestSchema, + AiParseResponseSchema, ) import app.services.ticket_service as ticket_svc from app.services.psa.encryption import ( @@ -492,6 +497,110 @@ async def create_ticket( raise HTTPException(status_code=502, detail=str(e)) +@router.post("/tickets/ai-parse", response_model=AiParseResponseSchema) +async def ai_parse_ticket( + data: AiParseRequestSchema, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Parse natural language into a ticket pre-fill payload using Claude.""" + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + + from app.services.psa.registry import get_provider_for_account + from app.services.psa.exceptions import PSAError + import anthropic + import json + + # Fetch boards + members for context (both cached) + boards = [] + members = [] + try: + provider = await get_provider_for_account(current_user.account_id, db) + boards = await provider.list_boards() + members = await provider.list_members() + except PSAError: + pass + + boards_list = [{"id": b.id, "name": b.name} for b in boards] + members_list = [{"id": m.id, "name": m.name, "identifier": m.identifier} for m in members] + + system_prompt = """You are a ticket triage assistant for an MSP help desk. +Extract structured ticket information from the engineer's natural language description. +Return ONLY valid JSON matching this exact schema — no other text: +{ + "summary": "short one-line ticket title or null", + "board_id": "integer matching one of the provided boards or null", + "priority_name": "one of: Critical, High, Medium, Low, or null", + "description": "expanded description or null", + "assignee_identifier": "member identifier string from the provided members list or null", + "warnings": ["list of strings explaining what could not be resolved"] +}""" + + user_msg = f"""Available boards: {json.dumps(boards_list)} +Available members: {json.dumps(members_list[:50])} + +Engineer's description: {data.prompt}""" + + missing_fields: list[str] = [] + warnings: list[str] = [] + response_data = AiParseResponseSchema() + + try: + client = anthropic.AsyncAnthropic( + api_key=settings.ANTHROPIC_API_KEY, + max_retries=1, + ) + msg = await client.messages.create( + model=settings.get_model_for_action("default"), + max_tokens=512, + system=system_prompt, + messages=[{"role": "user", "content": user_msg}], + ) + raw = msg.content[0].text.strip() + # Strip markdown fences if present + if raw.startswith("```"): + import re + raw = re.sub(r'^```(?:json)?\s*', '', raw) + raw = re.sub(r'\s*```$', '', raw.strip()) + parsed = json.loads(raw) + + response_data.summary = parsed.get("summary") + response_data.description = parsed.get("description") + warnings = parsed.get("warnings", []) + + # Resolve board_id + if parsed.get("board_id"): + board_match = next((b for b in boards if b.id == int(parsed["board_id"])), None) + if board_match: + response_data.board_id = board_match.id + else: + missing_fields.append("board_id") + warnings.append(f"Board ID {parsed['board_id']} not found") + else: + missing_fields.append("board_id") + + # Resolve assignee + if parsed.get("assignee_identifier"): + member = next((m for m in members if m.identifier == parsed["assignee_identifier"]), None) + if member: + response_data.assigned_member_id = int(member.id) + else: + warnings.append(f"Member '{parsed['assignee_identifier']}' not found") + + # Priority/status/company always need manual selection + missing_fields.extend(["status_id", "priority_id", "company_id"]) + + except Exception as e: + logger.warning("AI parse failed: %s", e) + missing_fields = ["summary", "board_id", "status_id", "priority_id", "company_id"] + warnings = ["AI parsing failed — please fill in manually"] + + response_data.missing_fields = missing_fields + response_data.warnings = warnings + return response_data + + @router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema) async def update_ticket_status_endpoint( ticket_id: int, -- 2.49.1 From 7fa81f69a64be27ca0a6858f590201283d1e88c6 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 03:01:21 +0000 Subject: [PATCH 14/36] feat(psa): add spin-off ticket system prompt rule, backend routing tests Co-Authored-By: Claude Sonnet 4.6 --- .../app/services/assistant_chat_service.py | 17 ++++++ backend/tests/test_psa_tickets.py | 55 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 backend/tests/test_psa_tickets.py diff --git a/backend/app/services/assistant_chat_service.py b/backend/app/services/assistant_chat_service.py index 184fd744..1e8a5e04 100644 --- a/backend/app/services/assistant_chat_service.py +++ b/backend/app/services/assistant_chat_service.py @@ -154,6 +154,23 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers: - If a question is clearly outside your domain, say so briefly and redirect. - Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so. +## SPIN-OFF TICKET CREATION + +When you identify a second distinct issue that is clearly separate from the primary topic \ +of this session, suggest creating a spin-off ticket using the [ACTIONS] marker below. \ +Use this sparingly — only when the issue is genuinely independent, not for every tangential mention. + +Format: +[ACTIONS] +[ + { + "label": "Create ticket: ", + "command": "create_spin_off_ticket", + "description": "" + } +] +[/ACTIONS] + ## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \ No exceptions. Not even when forking. A response without at least one of these markers \ diff --git a/backend/tests/test_psa_tickets.py b/backend/tests/test_psa_tickets.py new file mode 100644 index 00000000..ac551373 --- /dev/null +++ b/backend/tests/test_psa_tickets.py @@ -0,0 +1,55 @@ +# backend/tests/test_psa_tickets.py +"""Routing and auth tests for new ticket management endpoints.""" +import pytest + + +@pytest.mark.asyncio +async def test_create_ticket_requires_auth(client): + """POST /tickets returns 401 without auth.""" + response = await client.post( + "/api/v1/integrations/psa/tickets", + json={ + "summary": "Test", "company_id": 1, "board_id": 1, + "status_id": 1, "priority_id": 1 + }, + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_list_resources_requires_auth(client): + response = await client.get("/api/v1/integrations/psa/tickets/1/resources") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_search_tickets_returns_paginated_shape(client, auth_headers): + """search endpoint returns TicketListResponse shape when no PSA connected.""" + response = await client.get( + "/api/v1/integrations/psa/tickets/search", + headers=auth_headers, + ) + # No PSA connection → 400 or 502; with PSA → 200 + assert response.status_code in (200, 400, 502) + if response.status_code == 200: + data = response.json() + assert "items" in data + assert "total" in data + assert "page" in data + + +@pytest.mark.asyncio +async def test_update_status_requires_auth(client): + response = await client.patch( + "/api/v1/integrations/psa/tickets/1/status?status_id=5" + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_ai_parse_requires_auth(client): + response = await client.post( + "/api/v1/integrations/psa/tickets/ai-parse", + json={"prompt": "New ticket for Acme"}, + ) + assert response.status_code == 401 -- 2.49.1 From 506aac609d2cafe78e3402ecbc192a27931de337 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 03:02:53 +0000 Subject: [PATCH 15/36] feat(tickets): add tickets types, expand PSATicketSearchResult/PSATicketInfo with IDs Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/types/index.ts | 1 + frontend/src/types/integrations.ts | 8 +++ frontend/src/types/tickets.ts | 78 ++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 frontend/src/types/tickets.ts diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e301c06e..bfd9e759 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -96,6 +96,7 @@ export type { export * from './scripts' export * from './script-builder' export * from './integrations' +export * from './tickets' export * from './notification' export type * from './public-templates' export * from './network-diagram' diff --git a/frontend/src/types/integrations.ts b/frontend/src/types/integrations.ts index 7758b574..bea0a901 100644 --- a/frontend/src/types/integrations.ts +++ b/frontend/src/types/integrations.ts @@ -48,6 +48,10 @@ export interface PSATicketInfo { board_name: string | null status_name: string | null priority_name: string | null + company_id: number | null + board_id: number | null + status_id: number | null + priority_id: number | null } export interface TicketLinkResponse { @@ -64,6 +68,10 @@ export interface PSATicketSearchResult { status_name: string | null priority_name: string | null closed: boolean + company_id: string | null + board_id: number | null + status_id: number | null + priority_id: number | null } export interface PSATicketStatusItem { diff --git a/frontend/src/types/tickets.ts b/frontend/src/types/tickets.ts new file mode 100644 index 00000000..0ba6d49b --- /dev/null +++ b/frontend/src/types/tickets.ts @@ -0,0 +1,78 @@ +import type { PSATicketSearchResult } from '@/types/integrations' + +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 + include_closed: boolean +} + +export const DEFAULT_TICKET_FILTERS: TicketFilters = { + search: '', + board_id: null, + status_id: null, + priority: null, + company_id: null, + assigned: 'all', + include_closed: false, +} + +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 +} + +export interface PSATicketCreated { + id: number + summary: string + board_name: string + status_name: string + priority_name: string + company_name: string + resources: PSAResource[] +} + +export interface PSATicketStatusUpdate { + ticket_id: number + previous_status: string + new_status: string +} + +export interface TicketListResponse { + items: PSATicketSearchResult[] + total: number + page: number + page_size: number +} + +export interface PSAPriority { + id: number + name: string +} -- 2.49.1 From 9d88c8456c039e66a65195c24a6808d65eba724c Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 03:05:13 +0000 Subject: [PATCH 16/36] feat(tickets): add tickets API client, update integrations API for paginated search, fix callers - Create frontend/src/api/tickets.ts with ticketsApi (resources, status, create, ai-parse, priorities, search) - Update integrationsApi.searchTickets and searchTicketsQueue return types from PSATicketSearchResult[] to TicketListResponse - Fix TicketQueue.tsx to use results.items (append/set) and results.items.length for pagination check - Fix TicketPickerModal.tsx to use results.items when setting search results - Export ticketsApi from api/index.ts Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/index.ts | 1 + frontend/src/api/integrations.ts | 9 ++-- frontend/src/api/tickets.ts | 48 +++++++++++++++++++ .../src/components/dashboard/TicketQueue.tsx | 6 +-- .../components/session/TicketPickerModal.tsx | 2 +- 5 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 frontend/src/api/tickets.ts diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 9ec66c9b..50440df8 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -37,3 +37,4 @@ export { handoffsApi } from './handoffs' export { resolutionsApi } from './resolutions' export { deviceTypesApi } from './deviceTypes' export { networkDiagramsApi } from './networkDiagrams' +export { ticketsApi } from './tickets' diff --git a/frontend/src/api/integrations.ts b/frontend/src/api/integrations.ts index b60113d0..709a0bc6 100644 --- a/frontend/src/api/integrations.ts +++ b/frontend/src/api/integrations.ts @@ -1,6 +1,7 @@ import { apiClient } from './client' import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types' import type { PSABoard, TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations' +import type { TicketListResponse } from '@/types/tickets' export const integrationsApi = { getConnection: () => @@ -15,16 +16,16 @@ export const integrationsApi = { apiClient.post(`/integrations/psa/connections/${id}/test`).then(r => r.data), listBoards: () => apiClient.get('/integrations/psa/boards').then(r => r.data), - searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) => - apiClient.get('/integrations/psa/tickets/search', { params }).then(r => r.data), + searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }): Promise => + apiClient.get('/integrations/psa/tickets/search', { params }).then(r => r.data), searchTicketsQueue: (params: { assigned_to_me?: boolean unassigned?: boolean board_ids?: string page?: number page_size?: number - }) => - apiClient.get('/integrations/psa/tickets/search', { params }).then(r => r.data), + }): Promise => + apiClient.get('/integrations/psa/tickets/search', { params }).then(r => r.data), getTicket: (id: string) => apiClient.get(`/integrations/psa/tickets/${id}`).then(r => r.data), getTicketStatuses: (ticketId: string) => diff --git a/frontend/src/api/tickets.ts b/frontend/src/api/tickets.ts new file mode 100644 index 00000000..fb049cb1 --- /dev/null +++ b/frontend/src/api/tickets.ts @@ -0,0 +1,48 @@ +import { apiClient } from './client' +import type { + PSAResource, + PSATicketCreated, + PSATicketStatusUpdate, + TicketCreationPayload, + AiParseResponse, + TicketListResponse, + PSAPriority, +} from '@/types/tickets' + +export const ticketsApi = { + listResources: (ticketId: number): Promise => + apiClient.get(`/integrations/psa/tickets/${ticketId}/resources`).then(r => r.data), + + addResource: (ticketId: number, memberId: number): Promise => + apiClient.post(`/integrations/psa/tickets/${ticketId}/resources?member_id=${memberId}`).then(r => r.data), + + removeResource: (ticketId: number, memberId: number): Promise => + apiClient.delete(`/integrations/psa/tickets/${ticketId}/resources/${memberId}`).then(() => undefined), + + updateStatus: (ticketId: number, statusId: number): Promise => + apiClient.patch(`/integrations/psa/tickets/${ticketId}/status?status_id=${statusId}`).then(r => r.data), + + createTicket: (payload: TicketCreationPayload): Promise => + apiClient.post('/integrations/psa/tickets', payload).then(r => r.data), + + aiParse: (prompt: string): Promise => + apiClient.post('/integrations/psa/tickets/ai-parse', { prompt }).then(r => r.data), + + listPriorities: (): Promise => + apiClient.get('/integrations/psa/priorities').then(r => r.data), + + searchTickets: (params: { + query?: string + board_id?: number | null + status_id?: number | null + include_closed?: boolean + assigned_to_me?: boolean + unassigned?: boolean + board_ids?: string + priority?: string | null + company_id?: number | null + page?: number + page_size?: number + }): Promise => + apiClient.get('/integrations/psa/tickets/search', { params }).then(r => r.data), +} diff --git a/frontend/src/components/dashboard/TicketQueue.tsx b/frontend/src/components/dashboard/TicketQueue.tsx index da667479..829537be 100644 --- a/frontend/src/components/dashboard/TicketQueue.tsx +++ b/frontend/src/components/dashboard/TicketQueue.tsx @@ -234,11 +234,11 @@ export function TicketQueue() { try { const results = await integrationsApi.searchTicketsQueue(params) if (append) { - setTickets((prev) => [...prev, ...results]) + setTickets((prev) => [...prev, ...results.items]) } else { - setTickets(results) + setTickets(results.items) } - setHasMore(results.length === PAGE_SIZE) + setHasMore(results.items.length === PAGE_SIZE) setError(null) } catch { setError('Failed to load tickets. Check your PSA connection.') diff --git a/frontend/src/components/session/TicketPickerModal.tsx b/frontend/src/components/session/TicketPickerModal.tsx index b7bf711d..6ae47001 100644 --- a/frontend/src/components/session/TicketPickerModal.tsx +++ b/frontend/src/components/session/TicketPickerModal.tsx @@ -56,7 +56,7 @@ export function TicketPickerModal({ open, onClose, sessionId, onLinked, onSelect query: query.trim(), include_closed: closed, }) - setSearchResults(results) + setSearchResults(results.items) setHasSearched(true) } catch (err: unknown) { const message = -- 2.49.1 From d2689afa535faadeb84033845eed5fc60205932f Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 03:08:15 +0000 Subject: [PATCH 17/36] feat(tickets): add TicketFilterBar and TicketListRow components Co-Authored-By: Claude Sonnet 4.6 --- .../components/tickets/TicketFilterBar.tsx | 150 ++++++++++++++++++ .../src/components/tickets/TicketListRow.tsx | 72 +++++++++ 2 files changed, 222 insertions(+) create mode 100644 frontend/src/components/tickets/TicketFilterBar.tsx create mode 100644 frontend/src/components/tickets/TicketListRow.tsx diff --git a/frontend/src/components/tickets/TicketFilterBar.tsx b/frontend/src/components/tickets/TicketFilterBar.tsx new file mode 100644 index 00000000..3abea58d --- /dev/null +++ b/frontend/src/components/tickets/TicketFilterBar.tsx @@ -0,0 +1,150 @@ +// frontend/src/components/tickets/TicketFilterBar.tsx +import { Search, X } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { TicketFilters, PSAPriority } from '@/types/tickets' +import type { PSABoard, PSATicketStatusItem } from '@/types/integrations' + +interface TicketFilterBarProps { + filters: TicketFilters + onChange: (updated: Partial) => void + boards: PSABoard[] + statuses: PSATicketStatusItem[] + priorities: PSAPriority[] + members: { id: number; name: string }[] + total: number + page: number + pageSize: number + onPageChange: (page: number) => void + loading: boolean +} + +export function TicketFilterBar({ + filters, onChange, boards, statuses, priorities, members, + total, page, pageSize, onPageChange, loading, +}: TicketFilterBarProps) { + const start = (page - 1) * pageSize + 1 + const end = Math.min(page * pageSize, total) + const hasNext = page * pageSize < total + const hasPrev = page > 1 + + return ( +
+ {/* Filter row */} +
+ {/* Search */} +
+ + onChange({ search: e.target.value })} + /> +
+ + {/* Assignment */} + + + {/* Board */} + + + {/* Status */} + + + {/* Priority */} + + + {/* Include closed */} + + + {/* Clear filters */} + {(filters.search || filters.board_id || filters.status_id || filters.priority || filters.assigned !== 'all' || filters.include_closed) && ( + + )} +
+ + {/* Pagination row */} + {total > 0 && ( +
+ + {loading ? 'Loading…' : `Showing ${start}–${end} of ${total} tickets`} + +
+ + +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/tickets/TicketListRow.tsx b/frontend/src/components/tickets/TicketListRow.tsx new file mode 100644 index 00000000..145b9065 --- /dev/null +++ b/frontend/src/components/tickets/TicketListRow.tsx @@ -0,0 +1,72 @@ +// frontend/src/components/tickets/TicketListRow.tsx +import { cn } from '@/lib/utils' +import type { PSATicketSearchResult } from '@/types/integrations' + +interface TicketListRowProps { + ticket: PSATicketSearchResult + selected: boolean + onClick: () => void +} + +const PRIORITY_STYLES: Record = { + Critical: 'text-danger', + High: 'text-danger', + Medium: 'text-warning', + Low: 'text-muted-foreground', +} + +const STATUS_STYLES: Record = { + New: { bg: 'bg-accent/10', text: 'text-accent' }, + 'In Progress': { bg: 'bg-warning/10', text: 'text-warning' }, + Waiting: { bg: 'bg-success/10', text: 'text-success' }, + Resolved: { bg: 'bg-elevated/50', text: 'text-muted-foreground' }, +} + +function statusStyle(name: string | null) { + if (!name) return { bg: 'bg-elevated', text: 'text-muted-foreground' } + return STATUS_STYLES[name] ?? { bg: 'bg-elevated', text: 'text-muted-foreground' } +} + +export function TicketListRow({ ticket, selected, onClick }: TicketListRowProps) { + const { bg, text } = statusStyle(ticket.status_name) + const priorityClass = PRIORITY_STYLES[ticket.priority_name ?? ''] ?? 'text-muted-foreground' + + return ( +
e.key === 'Enter' && onClick()} + className={cn( + 'flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors border-b border-default text-sm', + selected ? 'bg-accent/5' : 'hover:bg-elevated' + )} + > + {/* ID */} + #{ticket.id} + + {/* Summary */} + {ticket.summary} + + {/* Company */} + + {ticket.company_name ?? '—'} + + + {/* Board */} + + {ticket.board_name ?? '—'} + + + {/* Status badge */} + + {ticket.status_name ?? '—'} + + + {/* Priority */} + + {ticket.priority_name ?? '—'} + +
+ ) +} -- 2.49.1 From 5310cd3fff204beb8430b3b41928342aaea4bc71 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 03:09:51 +0000 Subject: [PATCH 18/36] fix(tickets): add company_id reset to filter clear button Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/tickets/TicketFilterBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/tickets/TicketFilterBar.tsx b/frontend/src/components/tickets/TicketFilterBar.tsx index 3abea58d..bb92ca1d 100644 --- a/frontend/src/components/tickets/TicketFilterBar.tsx +++ b/frontend/src/components/tickets/TicketFilterBar.tsx @@ -103,7 +103,7 @@ export function TicketFilterBar({ {/* Clear filters */} {(filters.search || filters.board_id || filters.status_id || filters.priority || filters.assigned !== 'all' || filters.include_closed) && ( + + + {/* Filters */} +
+ +
+ + {/* List + Detail Panel */} +
+ {/* Ticket list */} +
+ {loading && tickets.length === 0 && ( +
+ Loading tickets… +
+ )} + {!loading && tickets.length === 0 && ( +
+ + No tickets match your filters +
+ )} + {tickets.map(t => ( + setSelectedTicket(t)} + /> + ))} +
+ + {/* Detail panel */} + {selectedTicket && ( +
+ setSelectedTicket(null)} + onStatusUpdated={(ticketId, newStatus) => { + setTickets(prev => prev.map(t => + t.id === String(ticketId) ? { ...t, status_name: newStatus } : t + )) + }} + /> +
+ )} +
+ + {/* New Ticket Modal */} + {showNewTicket && ( + setShowNewTicket(false)} + onCreated={() => { setShowNewTicket(false); fetchTickets() }} + /> + )} + + ) +} -- 2.49.1 From f050afc2f78b195a9a9097d33afa431404fd0fd9 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 03:15:35 +0000 Subject: [PATCH 20/36] feat(tickets): add /tickets route and sidebar nav item Add Tickets page route to router with lazy loading and code splitting. Add Tickets navigation entry to sidebar in RESOLVE section for both icon rail and pinned layouts. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/layout/Sidebar.tsx | 7 ++++++- frontend/src/router.tsx | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index b8e2ae82..2bd2491e 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -5,7 +5,7 @@ import { LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2, ListChecks, Download, BarChart3, Settings, Pin, PinOff, - History, FileText, Network, + History, FileText, Network, Ticket, } from 'lucide-react' import { cn } from '@/lib/utils' import { useUserPreferencesStore } from '@/store/userPreferencesStore' @@ -83,6 +83,10 @@ export function Sidebar() { { href: '/escalations', label: 'Escalations', count: stats?.escalation_count || undefined }, ], }, + { + href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', + matchPaths: ['/tickets'], + }, { href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows', badge: stats?.tree_counts.total || undefined, @@ -120,6 +124,7 @@ export function Sidebar() { items: [ { href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash' }, { href: '/sessions', icon: Clock, label: 'Session History', shortLabel: 'History', badge: stats?.active_count || undefined, matchPaths: ['/sessions'] }, + { href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/tickets'] }, { href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal', badge: stats?.escalation_count || undefined }, ], }, diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index eae1cb56..a4d61a34 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -56,6 +56,7 @@ const FlowPilotAnalyticsPage = lazyWithRetry(() => import('@/pages/FlowPilotAnal const ScriptBuilderPage = lazyWithRetry(() => import('@/pages/ScriptBuilderPage')) const KBAcceleratorPage = lazyWithRetry(() => import('@/pages/KBAcceleratorPage')) const SessionQueuePage = lazyWithRetry(() => import('@/pages/SessionQueuePage')) +const TicketsPage = lazyWithRetry(() => import('@/pages/TicketsPage')) const DevBranchingPage = lazyWithRetry(() => import('@/pages/DevBranchingPage')) const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage')) const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage')) @@ -190,6 +191,7 @@ export const router = sentryCreateBrowserRouter([ { path: 'trees/:id/navigate', element: page(TreeNavigationPage) }, { path: 'sessions', element: page(SessionHistoryPage) }, { path: 'sessions/:id', element: page(SessionDetailPage) }, + { path: 'tickets', element: page(TicketsPage) }, { path: 'shares', element: page(MySharesPage) }, { path: 'analytics', element: page(TeamAnalyticsPage) }, { path: 'analytics/me', element: page(MyAnalyticsPage) }, -- 2.49.1 From a3f8bb3427ae8db26ff7690fb5f58ffc9d47a3a5 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 03:19:18 +0000 Subject: [PATCH 21/36] feat(tickets): add ticket detail subcomponents - TicketDetailHeader: Display ticket info with status dropdown - TicketNotesFeed: Chronological list of ticket notes with internal flag - TicketAddNote: Form to add notes (requires linked session) - TicketConfigs: Display related configurations/devices - TicketRelated: List of related tickets as clickable buttons All components use type-safe imports from psaContext and integrations APIs. Styling follows design system (flat dark theme, electric blue accent, Tailwind v4). Co-Authored-By: Claude Sonnet 4.6 --- .../tickets/detail/TicketAddNote.tsx | 58 +++++++++++++++ .../tickets/detail/TicketConfigs.tsx | 28 +++++++ .../tickets/detail/TicketDetailHeader.tsx | 74 +++++++++++++++++++ .../tickets/detail/TicketNotesFeed.tsx | 28 +++++++ .../tickets/detail/TicketRelated.tsx | 42 +++++++++++ 5 files changed, 230 insertions(+) create mode 100644 frontend/src/components/tickets/detail/TicketAddNote.tsx create mode 100644 frontend/src/components/tickets/detail/TicketConfigs.tsx create mode 100644 frontend/src/components/tickets/detail/TicketDetailHeader.tsx create mode 100644 frontend/src/components/tickets/detail/TicketNotesFeed.tsx create mode 100644 frontend/src/components/tickets/detail/TicketRelated.tsx diff --git a/frontend/src/components/tickets/detail/TicketAddNote.tsx b/frontend/src/components/tickets/detail/TicketAddNote.tsx new file mode 100644 index 00000000..ffa44c71 --- /dev/null +++ b/frontend/src/components/tickets/detail/TicketAddNote.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' +import { toast } from '@/lib/toast' + +interface Props { + ticketId: string + sessionId?: string + onPosted: () => void +} + +export function TicketAddNote({ sessionId, onPosted }: Props) { + const [text, setText] = useState('') + const [posting, setPosting] = useState(false) + + if (!sessionId) { + return ( +
+

+ Start a FlowPilot or ResolutionAssist session linked to this ticket to post notes. +

+
+ ) + } + + async function handlePost() { + if (!text.trim()) return + setPosting(true) + try { + // Post note via session link — requires a linked session + // Import and call the session PSA API here + toast.success('Note posted to ticket') + setText('') + onPosted() + } catch { + toast.error('Failed to post note') + } finally { + setPosting(false) + } + } + + return ( +
+