diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index 3f8b28d4..40346b43 100644 --- a/backend/app/api/endpoints/integrations.py +++ b/backend/app/api/endpoints/integrations.py @@ -389,6 +389,7 @@ async def search_tickets( query: str = "", board_id: int | None = None, status_id: int | None = None, + status_name: str | None = None, include_closed: bool = False, assigned_to_me: bool = False, unassigned: bool = False, @@ -448,6 +449,7 @@ async def search_tickets( query, board_id=board_id, status_id=status_id, + status_name=status_name, include_closed=include_closed, member_identifier=member_identifier, unassigned=unassigned, diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py index 74f338c8..7c50235d 100644 --- a/backend/app/services/psa/connectwise/provider.py +++ b/backend/app/services/psa/connectwise/provider.py @@ -7,6 +7,7 @@ from datetime import datetime, timezone from app.services.psa.base import PSAProvider from app.services.psa.cache import psa_cache +from app.services.psa.exceptions import PSAError from app.services.psa.types import ( ConnectionTestResult, PSATicket, @@ -81,6 +82,9 @@ class ConnectWiseProvider(PSAProvider): conditions.append(f"board/id = {filters['board_id']}") if filters.get("status_id"): conditions.append(f"status/id = {filters['status_id']}") + elif filters.get("status_name"): + safe_status = str(filters["status_name"]).replace("'", "") + conditions.append(f"status/name = '{safe_status}'") if not filters.get("include_closed", False): conditions.append("closedFlag = false") if filters.get("member_identifier") is not None: @@ -627,44 +631,81 @@ class ConnectWiseProvider(PSAProvider): # ── Resource management ─────────────────────────────────────────── + async def _get_ticket_resource_identifiers(self, ticket_id: int) -> list[str]: + """Fetch the ticket's `resources` field (comma-separated identifiers).""" + data = await self.client.get( + f"/service/tickets/{ticket_id}", + params={"fields": "id,resources"}, + ) + raw = data.get("resources") if isinstance(data, dict) else None + if not raw: + return [] + return [p.strip() for p in str(raw).split(",") if p.strip()] + 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", ""), - )) + """List members assigned to a CW ticket via the `resources` string field.""" + identifiers = await self._get_ticket_resource_identifiers(ticket_id) + if not identifiers: + return [] + members = await self.list_members() + by_identifier = {m.identifier: m for m in members if m.identifier} + results: list[PSAResource] = [] + for ident in identifiers: + m = by_identifier.get(ident) + if m: + results.append(PSAResource( + member_id=int(m.id), + member_name=m.name, + member_identifier=m.identifier, + )) + else: + # Unknown identifier — surface it so the UI doesn't silently drop it + results.append(PSAResource( + member_id=0, + member_name=ident, + member_identifier=ident, + )) 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}}, + """Assign a member to a CW ticket by updating the ticket's `resources` field.""" + members = await self.list_members() + target = next((m for m in members if str(m.id) == str(member_id)), None) + if target is None or not target.identifier: + raise PSAError(f"Member {member_id} not found or has no identifier") + + current = await self._get_ticket_resource_identifiers(ticket_id) + if target.identifier not in current: + current.append(target.identifier) + new_value = ",".join(current) + + await self.client.patch( + f"/service/tickets/{ticket_id}", + json_body=[{"op": "replace", "path": "resources", "value": new_value}], ) - 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", ""), + member_id=int(target.id), + member_name=target.name, + member_identifier=target.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: + """Remove a member from a CW ticket by updating the `resources` field (idempotent).""" + members = await self.list_members() + target = next((m for m in members if str(m.id) == str(member_id)), None) + if target is None or not target.identifier: + return # Unknown member — treat as idempotent + + current = await self._get_ticket_resource_identifiers(ticket_id) + if target.identifier not in current: return # Already not assigned — idempotent - await self.client.delete(f"/service/tickets/{ticket_id}/members/{record_id}") + new_list = [i for i in current if i != target.identifier] + new_value = ",".join(new_list) + + await self.client.patch( + f"/service/tickets/{ticket_id}", + json_body=[{"op": "replace", "path": "resources", "value": new_value}], + ) # ── Ticket creation ─────────────────────────────────────────────── diff --git a/frontend/src/api/tickets.ts b/frontend/src/api/tickets.ts index fb049cb1..f8f5d305 100644 --- a/frontend/src/api/tickets.ts +++ b/frontend/src/api/tickets.ts @@ -35,6 +35,7 @@ export const ticketsApi = { query?: string board_id?: number | null status_id?: number | null + status_name?: string | null include_closed?: boolean assigned_to_me?: boolean unassigned?: boolean diff --git a/frontend/src/pages/TicketsPage.tsx b/frontend/src/pages/TicketsPage.tsx index 64f11cb7..52145ff2 100644 --- a/frontend/src/pages/TicketsPage.tsx +++ b/frontend/src/pages/TicketsPage.tsx @@ -55,25 +55,49 @@ export default function TicketsPage() { .catch(() => {}) }, []) - // Load statuses when board changes + // Load statuses when board changes. If no board is selected, aggregate statuses + // across all boards (deduped by name) so the filter is useful before the user + // picks a board. useEffect(() => { + let cancelled = false if (filters.board_id) { integrationsApi.getBoardStatuses(filters.board_id) - .then(setStatuses).catch(() => {}) + .then(s => { if (!cancelled) setStatuses(s) }) + .catch(() => { if (!cancelled) setStatuses([]) }) + } else if (boards.length > 0) { + Promise.all(boards.map(b => + integrationsApi.getBoardStatuses(b.id).catch(() => [] as PSATicketStatusItem[]) + )) + .then(lists => { + if (cancelled) return + const byName = new Map() + lists.flat().forEach(s => { + if (!byName.has(s.name)) byName.set(s.name, s) + }) + setStatuses(Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name))) + }) + .catch(() => { if (!cancelled) setStatuses([]) }) } else { setStatuses([]) } - }, [filters.board_id]) + return () => { cancelled = true } + }, [filters.board_id, boards]) // Fetch tickets on filter/page change const fetchTickets = useCallback(async () => { setLoading(true) setPsaError(null) try { + // When no board is selected, statuses are aggregated across boards — filter by + // name instead of id so we match the same status across every board. + const selectedStatusName = filters.status_id + ? statuses.find(s => s.id === filters.status_id)?.name + : undefined const result = await ticketsApi.searchTickets({ query: filters.search || undefined, board_id: filters.board_id ?? undefined, - status_id: filters.status_id ?? undefined, + status_id: filters.board_id && filters.status_id ? filters.status_id : undefined, + status_name: !filters.board_id && selectedStatusName ? selectedStatusName : undefined, include_closed: filters.include_closed, assigned_to_me: filters.assigned === 'me', unassigned: filters.assigned === 'unassigned', @@ -111,7 +135,7 @@ export default function TicketsPage() { setLoading(false) } }, [filters.search, filters.board_id, filters.status_id, filters.include_closed, - filters.assigned, filters.priority, filters.company_id, page]) + filters.assigned, filters.priority, filters.company_id, page, statuses]) useEffect(() => { fetchTickets() }, [fetchTickets])