Merge main into feat/flowpilot-migration
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / backend (pull_request) Failing after 36s
CI / frontend (pull_request) Failing after 1m7s
CI / e2e (pull_request) Has been skipped

Brings in PR #141 (PSA ticket management) so FlowPilot can ship on top
of a unified main. Two manual conflict resolutions:

1. CLAUDE.md — kept the FlowPilot ai-handoff rewrite (`.ai/`-driven
   protocol). The pre-rewrite reference content (CW integration notes,
   lessons archive, env vars table) lives in `docs/connectwise/`,
   `docs/LESSONS-ARCHIVE.md`, and DEV-ENV.md by design.

2. frontend/src/pages/AssistantChatPage.tsx — both conflict regions
   were purely additive. Concatenated FlowPilot's Phase 2-9 state hooks
   (facts, activeFix, preview*, scriptPanelOpen, templatizeQueue) with
   PSA's spin-off ticket state (linkedTicket, showNewTicket, spinOffHint).
   Both modal mounts (TemplatizePrompt, ShortcutsHelpOverlay,
   NewTicketModal) kept. All setters wired by either branch are intact.

Verification:
- `tsc -b` clean across the merged tree.
- Browser smoke-test (Session B fixture): Phase 9 ProposalBanner
  ("Run AI-drafted PowerShell to recover SSL VPN") renders alongside
  PSA's new Tickets sidebar icon. Console clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 01:03:33 -04:00
45 changed files with 9951 additions and 106 deletions

View File

@@ -1,4 +1,4 @@
# Lessons Archive (1-40)
# Lessons Archive (1-70)
> These lessons were originally in CLAUDE.md. They've been archived because the fixes are now baked into the codebase. Consult this file if you encounter a regression in any of these areas.
@@ -81,3 +81,67 @@
**39. Platform settings for feature toggles:** Use `SettingsManager.get("key", db, default=True)`.
**40. Survey public routes:** Add at top level in `router.tsx` alongside `/login`.
---
## Archived Lessons (41-70)
**41. Assistant chat uses local React state, not Zustand:** `AssistantChatPage.tsx` uses `useState` for `chats`, `messages`, `input`, `loading`. No store.
**42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and no-auth pages use `fetch()` with full URL. `apiClient` requires auth tokens.
**43. Adding new email types:** Add static async method to `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail).
**44. AI Chat Builder is flow-type-aware:** `ai_chat_service.py` dispatches by `flow_type`. Troubleshooting: `[TREE_UPDATE]` markers. Procedural: `[STEPS_UPDATE]` markers. Both support `[METADATA]`.
**45. Intake form field schema:** Uses `variable_name` and `field_type` (NOT `name` and `type`).
**46. `CreateFlowDropdown` uses `AIPromptDialog`:** Opens prompt modal, starts AI session, generates flow, navigates to editor with `{ state: { aiPanelOpen: true, sessionId } }`.
**47. Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI` hook. Ghost nodes use `_suggestion: true` flag. Delta responses use `[DELTA]...[/DELTA]` markers.
**48. Tree orphan validation uses dynamic root ID:** Orphan check compares against `state.treeStructure?.id` (NOT hardcoded `'root'`).
**49. Full-stack features — verify both ends:** schema → endpoint → API client → hook → store → UI.
**50. Anthropic SDK retry:** Set `max_retries=1` to fail fast. Default `max_retries=2` can take 3× timeout.
**51. AI model tier routing:** Use `settings.get_model_for_action(action_type)`. Model IDs: alias form (`claude-sonnet-4-6`).
**52. Mobile scroll-to-top:** Use `ref.current.scrollIntoView()`, not `window.scrollTo()`. Trigger via `useEffect`.
**53. Flex height chain:** Every ancestor must be a flex container for `flex-1` to work. Missing `flex` class collapses React Flow to 0 height.
**54. React Flow CSS in Tailwind v4:** Import in `index.css`, not component JS. Override dark theme using `--xy-*` CSS custom properties.
**55. App shell height chain:** Every wrapper between `.main-content` and canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`.
**56. Railway backend service name is `patherly`:** Production DB name is `railway`. Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
**57. Node field priority:** `title``question``description``content``label`. See `copilot_service.py`.
**58. `scriptGeneratorStore.generate()` optional param:** Always wrap: `onClick={() => generate()}`, never `onClick={generate}`.
**59. ConnectWise `clientId` is server-side config:** Set in `config.py` as `CW_CLIENT_ID`. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
**60. Dockerfile build args for Vite env vars:** Any new `VITE_*` var must be added as `ARG` + `ENV` in `frontend/Dockerfile`. Railway env vars are runtime-only without this; `import.meta.env.VITE_*` resolves to `undefined` in production builds.
**61. Procedural sessions auto-start on page load:** `ProceduralNavigationPage` calls `startSession()` immediately in `loadTree()` — no intake form screen or "Start" button. Variables filled inline. Troubleshooting flows DO have a start screen.
**62. Playwright strict mode — scope selectors:** Step titles appear in both sidebar and main heading. Use `getByRole('heading', { name })` for main content.
**63. Node 20 required for frontend builds:** `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. Or: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
**64. PostHog product analytics:** `PostHogProvider` in `main.tsx`. Event helpers in `lib/analytics.ts`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`.
**65. Local Docker Compose uses `resolutionflow` database on port 5433:** Container `resolutionflow_postgres`, DB `resolutionflow` (not `patherly`), port `5433`. Playwright config defaults must match.
**66. Dev environment runs on Hostinger VPS (46.202.92.250):** CORS must include VPS IP in `CORS_ORIGINS` and `FRONTEND_URL`. See DEV-ENV.md.
**67. Tree editor route is `/trees/new`:** NOT `/editor/new`. Use `getTreeEditorPath()` from `@/lib/routing`.
**68. APScheduler jobs need `max_instances=1`:** Without it, overlapping runs can process the same records twice (TOCTOU race).
**69. PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg:** Cast to `int()` before storing in Pydantic `dict[str, Any]` fields.
**70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`.

View File

@@ -0,0 +1,63 @@
# ConnectWise integration docs
Reference material for ResolutionFlow's ConnectWise Manage integration.
This folder pairs a **human-editable source** (the XLSX) with two
**generated artifacts** (YAML + Markdown). Code reads the YAML; humans
read the Markdown; edits happen in the XLSX.
## Files
| File | Role | Edit? |
|------|------|-------|
| `api-member-security-roles.md` | Human-readable reference — browse on GitHub, link in PRs, onboard new contributors. | Generated — do not edit |
| `api-member-security-roles.yaml` | Machine-readable source of truth — imported by integration code, queried by Claude Code when writing permission checks. | Generated — do not edit |
| `source/Security_Roles_Matrix_11132017.xlsx` | Canonical source. The matrix as published by ConnectWise (with any corrections we've applied). | Yes — this is the editing surface |
| `source/generate_role_docs.py` | Regenerates the YAML and Markdown from the XLSX. Deterministic. | Only if the matrix schema itself changes |
| `source/requirements.txt` | Python deps for the generator (`openpyxl`, `PyYAML`). | Only when bumping deps |
## Regeneration workflow
After editing the XLSX:
```bash
cd docs/integrations/connectwise/source
pip install -r requirements.txt
python generate_role_docs.py \
--source Security_Roles_Matrix_11132017.xlsx \
--out-yaml ../api-member-security-roles.yaml \
--out-md ../api-member-security-roles.md
```
Commit all three files together (XLSX, YAML, MD). The diff on the YAML
is what reviewers should scrutinize — it is the source of truth for code.
## Querying the YAML from integration code
The YAML groups permissions by module and action. Example — checking
what `Inquire: ALL` means for Service Desk → Service Tickets:
```python
import yaml
from pathlib import Path
doc = yaml.safe_load(
Path("docs/integrations/connectwise/api-member-security-roles.yaml").read_text()
)
levels = doc["modules"]["Service Desk"]["actions"]["Service Tickets"]["inquire"]["levels"]
print(levels["ALL"])
```
This is the pattern `ConnectWiseAuthManager` and the proxy authorization
layer should use when the required permission level for a given API
endpoint needs to be documented or validated against an assigned role.
## Conventions
- **Levels are ordered most-to-least privileged:** `ALL`, `MY`, `MINE`, `NONE`.
- **Verbs are always in this order:** `add`, `edit`, `delete`, `inquire`.
- **`Not applicable` notes** in a verb's cell mean the meaningful level
is documented under another verb (almost always `inquire`) — the
generator preserves these as `note:` fields rather than inventing
placeholder levels.
- **The XLSX is the single source of input.** Never hand-edit the YAML
or Markdown; your changes will be overwritten on the next regeneration.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,361 @@
"""
Generate ConnectWise security-role documentation from the source XLSX.
Produces:
- api-member-security-roles.yaml : machine-readable source of truth
- api-member-security-roles.md : human-readable reference
Re-run this script after editing the source XLSX. Both outputs are
deterministic — they will produce identical content from identical input,
so diffs in version control reflect only real permission-model changes.
Usage:
python generate_role_docs.py \
--source source/Security_Roles_Matrix_11132017.xlsx \
--out-yaml ../api-member-security-roles.yaml \
--out-md ../api-member-security-roles.md
"""
from __future__ import annotations
import argparse
import re
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from typing import Dict, List, Optional
import yaml
from openpyxl import load_workbook
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
# A level description line looks like "ALL: text..." or "NONE: text..."
# We capture the prefix (ALL | NONE | MINE | MY) and the trailing description.
LEVEL_LINE = re.compile(r"^(ALL|NONE|MINE|MY)\s*:\s*(.*)$", re.DOTALL)
# Recognized ConnectWise permission levels, most-to-least privileged.
LEVEL_ORDER = ["ALL", "MY", "MINE", "NONE"]
VERBS = ["add", "edit", "delete", "inquire"]
VERB_COLS = {"add": 3, "edit": 4, "delete": 5, "inquire": 6}
@dataclass
class CellPermission:
"""Parsed contents of a single (action, verb) cell."""
levels: Dict[str, str] = field(default_factory=dict) # level -> description
note: Optional[str] = None # for "Not applicable. See Inquire level." etc.
raw: str = "" # original cell text, preserved for audit
@dataclass
class ActionRow:
module: str
action: str
permissions: Dict[str, CellPermission] # verb -> CellPermission
def parse_cell(raw: Optional[str]) -> CellPermission:
"""Parse a single cell's multi-line content into levels + note."""
if raw is None:
return CellPermission(raw="")
text = str(raw).strip()
cp = CellPermission(raw=text)
if not text:
return cp
# Split into candidate entries. Each entry is typically one line that
# starts with a level prefix, but description text can itself contain
# newlines. We therefore split on newlines and accumulate continuation
# lines into the preceding entry.
current_level: Optional[str] = None
current_buf: List[str] = []
note_buf: List[str] = []
def flush_level() -> None:
nonlocal current_level, current_buf
if current_level is not None:
cp.levels[current_level] = " ".join(current_buf).strip()
current_level = None
current_buf = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
m = LEVEL_LINE.match(line)
if m:
flush_level()
current_level = m.group(1).upper()
current_buf = [m.group(2).strip()]
elif current_level is not None:
current_buf.append(line)
else:
# No level prefix yet — belongs to the note.
note_buf.append(line)
flush_level()
if note_buf:
cp.note = " ".join(note_buf).strip()
return cp
def read_matrix(xlsx_path: Path) -> List[ActionRow]:
wb = load_workbook(xlsx_path, data_only=True)
ws = wb.active # Single sheet in this workbook.
# Header row is row 2 per the source file; data begins row 3.
actions: List[ActionRow] = []
for r in range(3, ws.max_row + 1):
module = ws.cell(row=r, column=1).value
action = ws.cell(row=r, column=2).value
if not (module or action):
continue # skip fully empty rows
if not module or not action:
# Partial row — keep but flag. This shouldn't happen in the
# current source; if it does, the generator should fail loudly
# rather than silently produce wrong output.
raise ValueError(
f"Row {r} has a missing Module or Action: "
f"module={module!r}, action={action!r}"
)
perms: Dict[str, CellPermission] = {}
for verb, col in VERB_COLS.items():
perms[verb] = parse_cell(ws.cell(row=r, column=col).value)
actions.append(
ActionRow(module=module.strip(), action=action.strip(), permissions=perms)
)
return actions
# ---------------------------------------------------------------------------
# Output: YAML
# ---------------------------------------------------------------------------
def build_yaml_document(actions: List[ActionRow], source_file: str) -> dict:
"""Build a plain-dict representation that YAML dumps cleanly."""
# Group by module, preserving action order within each module.
modules: Dict[str, List[ActionRow]] = {}
for a in actions:
modules.setdefault(a.module, []).append(a)
doc = {
"metadata": {
"source_file": source_file,
"generated_on": date.today().isoformat(),
"generator": "docs/integrations/connectwise/source/generate_role_docs.py",
"description": (
"ConnectWise security-role matrix. Each (module, action) entry "
"describes what each access level (ALL, MY, MINE, NONE) means "
"for the Add, Edit, Delete, and Inquire verbs. This is a "
"reference catalog, not a per-role assignment — role "
"assignments live in ConnectWise and are mirrored in the "
"ResolutionFlow integration config."
),
"level_order_most_to_least_privileged": LEVEL_ORDER,
},
"modules": {},
}
for module_name, rows in modules.items():
module_block = {"actions": {}}
for a in rows:
action_block: Dict[str, object] = {}
for verb in VERBS:
cell = a.permissions[verb]
entry: Dict[str, object] = {}
if cell.levels:
# Emit levels in canonical order, only those present.
entry["levels"] = {
lvl: cell.levels[lvl]
for lvl in LEVEL_ORDER
if lvl in cell.levels
}
if cell.note:
entry["note"] = cell.note
if not entry:
# Truly empty cell — represent explicitly so downstream
# consumers can distinguish "empty" from "missing".
entry["note"] = "(no description provided)"
action_block[verb] = entry
module_block["actions"][a.action] = action_block
doc["modules"][module_name] = module_block
return doc
class _LiteralStr(str):
"""Marker type so PyYAML renders long strings as block literals."""
def _literal_presenter(dumper, data):
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
yaml.add_representer(_LiteralStr, _literal_presenter)
def _use_block_style_for_long_strings(obj):
"""Recursively wrap long strings so the YAML is readable, not one-line."""
if isinstance(obj, dict):
return {k: _use_block_style_for_long_strings(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_use_block_style_for_long_strings(v) for v in obj]
if isinstance(obj, str) and (len(obj) > 80 or "\n" in obj):
return _LiteralStr(obj)
return obj
def dump_yaml(doc: dict, out_path: Path) -> None:
prepared = _use_block_style_for_long_strings(doc)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", encoding="utf-8") as f:
f.write("# ConnectWise API Member Security Roles — reference matrix.\n")
f.write("# Generated from the source XLSX; do not edit by hand.\n")
f.write("# Re-run generate_role_docs.py after updating the XLSX.\n\n")
yaml.dump(
prepared,
f,
sort_keys=False,
allow_unicode=True,
width=100,
default_flow_style=False,
)
# ---------------------------------------------------------------------------
# Output: Markdown
# ---------------------------------------------------------------------------
def _md_escape(text: str) -> str:
"""Escape pipes and collapse whitespace for Markdown table cells."""
return text.replace("|", "\\|").replace("\n", " ").strip()
def build_markdown(actions: List[ActionRow], source_file: str) -> str:
modules: Dict[str, List[ActionRow]] = {}
for a in actions:
modules.setdefault(a.module, []).append(a)
lines: List[str] = []
lines.append("# ConnectWise API Member — Security Roles Reference")
lines.append("")
lines.append(
f"_Generated {date.today().isoformat()} from "
f"`{source_file}`. Do not edit by hand — update the XLSX and "
f"re-run `generate_role_docs.py`._"
)
lines.append("")
lines.append("## How to read this document")
lines.append("")
lines.append(
"Each ConnectWise module lists the actions it governs. For every "
"action, four permission verbs — **Add**, **Edit**, **Delete**, "
"**Inquire** — can be granted at one of these levels, most to "
"least privileged:"
)
lines.append("")
lines.append("| Level | Meaning |")
lines.append("|-------|---------|")
lines.append("| `ALL` | Access to all records in the system. |")
lines.append("| `MY` | Access to records owned by the user's team. |")
lines.append("| `MINE` | Access only to records owned by the user. |")
lines.append("| `NONE` | No access. |")
lines.append("")
lines.append(
"Not every level applies to every action — the source matrix "
"only documents the levels that are meaningful for each cell. "
"Cells marked _Not applicable_ reference another verb (usually "
"Inquire) where the meaningful level is defined."
)
lines.append("")
lines.append(
"The machine-readable form of this document is "
"[`api-member-security-roles.yaml`](./api-member-security-roles.yaml). "
"Use the YAML when writing integration code; use this Markdown "
"when reviewing, discussing, or onboarding."
)
lines.append("")
lines.append("## Table of contents")
lines.append("")
for module_name in modules:
anchor = module_name.lower().replace(" ", "-").replace("/", "")
lines.append(f"- [{module_name}](#{anchor}) — {len(modules[module_name])} actions")
lines.append("")
for module_name, rows in modules.items():
lines.append(f"## {module_name}")
lines.append("")
for a in rows:
lines.append(f"### {a.action}")
lines.append("")
lines.append("| Verb | Level | Description |")
lines.append("|------|-------|-------------|")
wrote_any = False
for verb in VERBS:
cell = a.permissions[verb]
if cell.levels:
for lvl in LEVEL_ORDER:
if lvl in cell.levels:
lines.append(
f"| {verb.capitalize()} | `{lvl}` | "
f"{_md_escape(cell.levels[lvl])} |"
)
wrote_any = True
elif cell.note:
lines.append(
f"| {verb.capitalize()} | — | "
f"_{_md_escape(cell.note)}_ |"
)
wrote_any = True
if not wrote_any:
lines.append("| — | — | _(no description provided)_ |")
lines.append("")
return "\n".join(lines) + "\n"
def write_markdown(md_text: str, out_path: Path) -> None:
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(md_text, encoding="utf-8")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, required=True,
help="Path to the source .xlsx")
parser.add_argument("--out-yaml", type=Path, required=True,
help="Path to write the YAML output")
parser.add_argument("--out-md", type=Path, required=True,
help="Path to write the Markdown output")
args = parser.parse_args()
actions = read_matrix(args.source)
doc = build_yaml_document(actions, source_file=args.source.name)
dump_yaml(doc, args.out_yaml)
md = build_markdown(actions, source_file=args.source.name)
write_markdown(md, args.out_md)
# Quick data-quality summary to stdout — helpful when re-running after edits.
from collections import Counter
modules_seen = Counter(a.module for a in actions)
print(f"Parsed {len(actions)} actions across {len(modules_seen)} modules:")
for m, n in modules_seen.most_common():
print(f" {m}: {n}")
print(f"\nWrote {args.out_yaml}")
print(f"Wrote {args.out_md}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,5 @@
# Dependencies for generate_role_docs.py.
# These are only needed when regenerating the role docs from the XLSX —
# they are not runtime dependencies of ResolutionFlow itself.
openpyxl>=3.1,<4.0
PyYAML>=6.0,<7.0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,485 @@
# 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 |
**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
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 and Paginated Result Type
**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: ...
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` — 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`)
```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 XY of Z tickets". Next disabled when `page * 25 >= total`.
### TicketFilterBar — Config-Driven
Filters defined as a `FILTER_CONFIG` array. Each entry:
```typescript
{ key: keyof TicketFilters, label: string, type: 'text' | 'select' | 'toggle', loadOptions?: () => Promise<Option[]> }
```
Adding or removing a filter is a one-line config change, not a component edit.
### TicketDetailPanel — Optimistic Hydration
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 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
- `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 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]
[
{
"label": "Create ticket: Printer offline on 2nd floor",
"command": "create_spin_off_ticket",
"description": "Printer offline on 2nd floor"
}
]
[/ACTIONS]
```
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` (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**
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
**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` 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<PSATicketInfo | null>(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,
board_id: linkedTicket.board_id,
}
```
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
- 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 `ASSISTANT_SYSTEM_PROMPT` in `backend/app/services/assistant_chat_service.py`:
> 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
- **`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`.
---
## Section 4 — Dashboard Widget (QuickStartPage)
### Placement
`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 `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.
### 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/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)
### 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` — handle `create_spin_off_ticket` command in action renderer + add "New Ticket" button to session header
- `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
- `frontend/src/components/session/TicketPickerModal.tsx` — read `.items` from paginated response
---
## 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