Files
resolutionflow/docs/superpowers/specs/2026-04-16-psa-ticket-management-design.md
2026-04-16 01:36:27 +00:00

15 KiB
Raw Blame History

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:

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_resourcesGET /service/tickets/{id}/members
  • add_resourcePOST /service/tickets/{id}/members
  • remove_resourceDELETE /service/tickets/{id}/members/{member_id}
  • create_ticketPOST /service/tickets

Normalized DTOs (Pydantic Schemas)

New schemas in backend/app/schemas/psa_tickets.py:

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:

{ "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:

{
  "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)

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 XY of Z tickets". Next disabled when page * 25 >= total.

TicketFilterBar — Config-Driven

Filters defined as a FILTER_CONFIG array. Each entry:

{ key: keyof TicketFilters, label: string, type: 'text' | 'select' | 'toggle', loadOptions?: () => Promise<Option[]> }

Adding or removing a filter is a one-line config change, not a component edit.

TicketDetailPanel — Optimistic Hydration

  1. Panel opens immediately with list row data (id, summary, company, board, status, priority) — no loading state for these fields
  2. Two parallel fetches fire: full ticket detail + list_resources
  3. Detail sections (contact, notes, configs, related) render skeletons until hydrated
  4. Resources section renders skeleton until hydrated

NewTicketModal — State Ownership

  • NewTicketModal owns the TicketCreationPayload draft state
  • AiTicketParseForm is a pure emitter: accepts a prompt string, calls ai-parse, fires onParsed(Partial<TicketCreationPayload>) upward
  • Modal merges parsed values into draft, highlights missing_fields with visual indicators
  • Two tabs: Quick Create (AI prompt → review) | Full Form (manual entry)
  • Default tab: Quick Create if AI-triggered, Full Form if engineer-initiated
  • Initial props: initialValues?: Partial<TicketCreationPayload> — used for spin-off pre-population

Section 3 — ResolutionAssist Integration

Two Trigger Paths

1. AI-suggested (via [ACTIONS] marker)

When the AI identifies a second distinct issue during a session, it emits:

[ACTIONS]
create_spin_off_ticket: "Printer offline on 2nd floor"
[/ACTIONS]

Action payload shape (parsed by unified_chat_service.py):

{
  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:

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.

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